django-nplus1 0.1.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,14 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from django_nplus1.detect import nplus1_allow
4
+ from django_nplus1.exceptions import NPlus1Error
5
+ from django_nplus1.middleware import NPlus1Middleware
6
+ from django_nplus1.profiler import Profiler
7
+ from django_nplus1.signals import nplus1_detected
8
+
9
+ try:
10
+ __version__ = version("django-nplus1")
11
+ except PackageNotFoundError:
12
+ __version__ = "0.0.0+unknown"
13
+
14
+ __all__ = ["NPlus1Error", "NPlus1Middleware", "Profiler", "__version__", "nplus1_allow", "nplus1_detected"]
django_nplus1/apps.py ADDED
@@ -0,0 +1,9 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoNPlus1Config(AppConfig):
5
+ name = "django_nplus1"
6
+ verbose_name = "Django N+1"
7
+
8
+ def ready(self) -> None:
9
+ from django_nplus1 import patch # noqa: F401
@@ -0,0 +1,438 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import fnmatch
5
+ import re
6
+ from collections import defaultdict
7
+ from contextvars import ContextVar
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Generator, Sequence
12
+
13
+
14
+ class Rule:
15
+ def __init__(self, label: str | None = None, model: str | type | None = None, field: str | None = None) -> None:
16
+ self.label = label
17
+ self.model = model
18
+ self.field = field
19
+
20
+ def compare(self, label: str, model: type, field: str) -> bool:
21
+ return bool(
22
+ (self.label or self.model or self.field)
23
+ and (self.label is None or self.label == label)
24
+ and (self.model is None or self.match_model(model))
25
+ and (self.field is None or self.match_field(field)),
26
+ )
27
+
28
+ def match_field(self, field: str) -> bool:
29
+ if self.field is None:
30
+ return True
31
+ return fnmatch.fnmatch(field, self.field)
32
+
33
+ def match_model(self, model: type) -> bool:
34
+ if self.model is model:
35
+ return True
36
+ if isinstance(self.model, str):
37
+ return fnmatch.fnmatch(model.__name__, self.model)
38
+ return False
39
+
40
+
41
+ _allow_rules: ContextVar[list[Rule]] = ContextVar("nplus1_allow_rules")
42
+
43
+
44
+ def is_allowed(message: Message) -> bool:
45
+ """Check if a message is suppressed by nplus1_allow rules."""
46
+ try:
47
+ rules = _allow_rules.get()
48
+ except LookupError:
49
+ return False
50
+ return message.match(rules) if rules else False
51
+
52
+
53
+ @contextlib.contextmanager
54
+ def nplus1_allow(whitelist: list[dict[str, Any]] | None = None) -> Generator[None]:
55
+ """Context manager to suppress N+1 detection for specific model/field combinations.
56
+
57
+ With no arguments, suppresses all detections. With a whitelist, suppresses only
58
+ matching detections. Uses the same format as ``Profiler(whitelist=...)`` and
59
+ ``@pytest.mark.nplus1(whitelist=...)``.
60
+
61
+ Usage::
62
+
63
+ # Suppress all detections
64
+ with nplus1_allow():
65
+ ...
66
+
67
+ # Suppress specific model/field
68
+ with nplus1_allow([{"model": "User", "field": "hobbies"}]):
69
+ ...
70
+
71
+ # Suppress all fields on a model (supports fnmatch wildcards)
72
+ with nplus1_allow([{"model": "User"}]):
73
+ ...
74
+ """
75
+ rules = [Rule(**item) for item in whitelist] if whitelist else [Rule(model="*", field="*")]
76
+
77
+ try:
78
+ current = _allow_rules.get()
79
+ except LookupError:
80
+ current = []
81
+ token = _allow_rules.set([*current, *rules])
82
+ try:
83
+ yield
84
+ finally:
85
+ _allow_rules.reset(token)
86
+
87
+
88
+ class Message:
89
+ label: str = ""
90
+ formatter: str = ""
91
+
92
+ def __init__(
93
+ self,
94
+ model: type,
95
+ field: str,
96
+ caller: tuple[str, int, str] | None = None,
97
+ callers: list[list[tuple[str, int, str]]] | None = None,
98
+ ) -> None:
99
+ self.model = model
100
+ self.field = field
101
+ self.caller = caller
102
+ self.callers = callers
103
+
104
+ @property
105
+ def message(self) -> str:
106
+ base = self.formatter.format(
107
+ label=self.label,
108
+ model=self.model.__name__,
109
+ field=self.field,
110
+ )
111
+ if self.callers:
112
+ parts = [base, " with calls:"]
113
+ for i, stack in enumerate(self.callers, 1):
114
+ parts.append(f"\nCALL {i}:")
115
+ for fn, lineno, funcname in stack:
116
+ parts.append(f"\n {fn}:{lineno} in {funcname}")
117
+ return "".join(parts)
118
+ if self.caller:
119
+ filename, lineno, funcname = self.caller
120
+ return f"{base} at {filename}:{lineno} in {funcname}"
121
+ return base
122
+
123
+ def match(self, rules: Sequence[Rule]) -> bool:
124
+ return any(rule.compare(self.label, self.model, self.field) for rule in rules)
125
+
126
+
127
+ class LazyLoadMessage(Message):
128
+ label = "n_plus_one"
129
+ formatter = "Potential n+1 query detected on `{model}.{field}`"
130
+
131
+
132
+ class EagerLoadMessage(Message):
133
+ label = "unused_eager_load"
134
+ formatter = "Potential unnecessary eager load detected on `{model}.{field}`"
135
+
136
+
137
+ class GetLoopMessage(Message):
138
+ label = "get_in_loop"
139
+ formatter = "Potential n+1 query detected on `{model}.{field}`"
140
+
141
+
142
+ class Listener:
143
+ def __init__(self, parent: Any) -> None:
144
+ self.parent = parent
145
+
146
+ def setup(self) -> None:
147
+ pass
148
+
149
+ def teardown(self) -> None:
150
+ pass
151
+
152
+
153
+ class LazyListener(Listener):
154
+ loaded: set[str]
155
+ ignore: set[str]
156
+ counts: defaultdict[tuple[type, str], int]
157
+ show_all_callers: bool
158
+ call_stacks: defaultdict[tuple[type, str], list[list[tuple[str, int, str]]]]
159
+
160
+ def setup(self) -> None:
161
+ from django.conf import settings
162
+
163
+ from django_nplus1 import signals
164
+
165
+ self.loaded = set()
166
+ self.ignore = set()
167
+ self.counts = defaultdict(int)
168
+ self.threshold = getattr(settings, "NPLUS1_THRESHOLD", 2)
169
+ self.show_all_callers = getattr(settings, "NPLUS1_SHOW_ALL_CALLERS", False)
170
+ self.call_stacks = defaultdict(list)
171
+ signals.connect(signals.LOAD, self.handle_load)
172
+ signals.connect(signals.IGNORE_LOAD, self.handle_ignore)
173
+ signals.connect(signals.LAZY_LOAD, self.handle_lazy)
174
+ signals.connect(signals.EAGER_LOAD, self.handle_eager)
175
+
176
+ def teardown(self) -> None:
177
+ from django_nplus1 import signals
178
+
179
+ signals.disconnect(signals.LOAD, self.handle_load)
180
+ signals.disconnect(signals.IGNORE_LOAD, self.handle_ignore)
181
+ signals.disconnect(signals.LAZY_LOAD, self.handle_lazy)
182
+ signals.disconnect(signals.EAGER_LOAD, self.handle_eager)
183
+
184
+ def handle_load(
185
+ self,
186
+ args: tuple[Any, ...] | None = None,
187
+ kwargs: dict[str, Any] | None = None,
188
+ context: dict[str, Any] | None = None,
189
+ ret: Any = None,
190
+ parser: Any = None,
191
+ ) -> None:
192
+ instances = parser(args, kwargs, context, ret)
193
+ self.loaded.update(instances)
194
+
195
+ def handle_ignore(
196
+ self,
197
+ args: tuple[Any, ...] | None = None,
198
+ kwargs: dict[str, Any] | None = None,
199
+ context: dict[str, Any] | None = None,
200
+ ret: Any = None,
201
+ parser: Any = None,
202
+ ) -> None:
203
+ instances = parser(args, kwargs, context, ret)
204
+ self.ignore.update(instances)
205
+
206
+ def handle_lazy(
207
+ self,
208
+ args: tuple[Any, ...] | None = None,
209
+ kwargs: dict[str, Any] | None = None,
210
+ context: dict[str, Any] | None = None,
211
+ ret: Any = None,
212
+ parser: Any = None,
213
+ ) -> None:
214
+ model, instance, field = parser(args, kwargs, context)
215
+ if instance in self.loaded and instance not in self.ignore:
216
+ key = (model, field)
217
+ self.counts[key] += 1
218
+ if self.show_all_callers:
219
+ from django_nplus1.util import get_stack
220
+
221
+ self.call_stacks[key].append(get_stack())
222
+ if self.counts[key] == self.threshold:
223
+ if self.show_all_callers:
224
+ message = LazyLoadMessage(model, field, callers=self.call_stacks[key])
225
+ else:
226
+ from django_nplus1.util import get_caller
227
+
228
+ caller = get_caller()
229
+ message = LazyLoadMessage(model, field, caller=caller)
230
+ self.parent.notify(message)
231
+
232
+ def handle_eager(
233
+ self,
234
+ args: tuple[Any, ...] | None = None,
235
+ kwargs: dict[str, Any] | None = None,
236
+ context: dict[str, Any] | None = None,
237
+ ret: Any = None,
238
+ parser: Any = None,
239
+ ) -> None:
240
+ # When a single bulk-loaded instance triggers an eager load (e.g.
241
+ # select_related on one item from a queryset), this is semantically
242
+ # an N+1 pattern, not an unused eager load. We use LazyLoadMessage
243
+ # with label "n_plus_one" intentionally.
244
+ model, field, keys, _key = parser(args, kwargs, context)
245
+ if len(keys) == 1 and keys[0] in self.loaded and keys[0] not in self.ignore:
246
+ key = (model, field)
247
+ self.counts[key] += 1
248
+ if self.show_all_callers:
249
+ from django_nplus1.util import get_stack
250
+
251
+ self.call_stacks[key].append(get_stack())
252
+ if self.counts[key] == self.threshold:
253
+ if self.show_all_callers:
254
+ message = LazyLoadMessage(model, field, callers=self.call_stacks[key])
255
+ else:
256
+ from django_nplus1.util import get_caller
257
+
258
+ caller = get_caller()
259
+ message = LazyLoadMessage(model, field, caller=caller)
260
+ self.parent.notify(message)
261
+
262
+
263
+ class EagerListener(Listener):
264
+ tracker: EagerTracker
265
+ touched: list[tuple[type, str, list[str]] | None]
266
+
267
+ def setup(self) -> None:
268
+ from django_nplus1 import signals
269
+
270
+ self.tracker = EagerTracker()
271
+ self.touched = []
272
+ signals.connect(signals.EAGER_LOAD, self.handle_eager)
273
+ signals.connect(signals.TOUCH, self.handle_touch)
274
+
275
+ def teardown(self) -> None:
276
+ from django_nplus1 import signals
277
+
278
+ signals.disconnect(signals.EAGER_LOAD, self.handle_eager)
279
+ signals.disconnect(signals.TOUCH, self.handle_touch)
280
+ self.log_eager()
281
+
282
+ def handle_eager(
283
+ self,
284
+ args: tuple[Any, ...] | None = None,
285
+ kwargs: dict[str, Any] | None = None,
286
+ context: dict[str, Any] | None = None,
287
+ ret: Any = None,
288
+ parser: Any = None,
289
+ ) -> None:
290
+ self.tracker.track(*parser(args, kwargs, context))
291
+
292
+ def handle_touch(
293
+ self,
294
+ args: tuple[Any, ...] | None = None,
295
+ kwargs: dict[str, Any] | None = None,
296
+ context: dict[str, Any] | None = None,
297
+ ret: Any = None,
298
+ parser: Any = None,
299
+ ) -> None:
300
+ self.touched.append(parser(args, kwargs, context))
301
+
302
+ def log_eager(self) -> None:
303
+ self.tracker.prune([each for each in self.touched if each])
304
+ for model, field in self.tracker.unused:
305
+ message = EagerLoadMessage(model, field)
306
+ self.parent.notify(message)
307
+
308
+
309
+ class EagerTracker:
310
+ def __init__(self) -> None:
311
+ self.data: defaultdict[tuple[type, str], defaultdict[int, set[str]]] = defaultdict(
312
+ lambda: defaultdict(set),
313
+ )
314
+
315
+ def track(self, model: type, field: str, instances: list[str], key: int) -> None:
316
+ self.data[(model, field)][key].update(instances)
317
+
318
+ def prune(self, touched: list[tuple[type, str, list[str]]]) -> None:
319
+ for model, field, touch_instances in touched:
320
+ group = self.data[(model, field)]
321
+ for key, fetch_instances in list(group.items()):
322
+ if touch_instances and fetch_instances.intersection(touch_instances):
323
+ group.pop(key, None)
324
+
325
+ @property
326
+ def unused(self) -> list[tuple[type, str]]:
327
+ return [(model, field) for (model, field), group in self.data.items() if group]
328
+
329
+
330
+ class GetLoopListener(Listener):
331
+ """Detects Model.objects.get() called repeatedly from the same call-site."""
332
+
333
+ counts: defaultdict[tuple[Any, ...], int]
334
+
335
+ def setup(self) -> None:
336
+ from django.conf import settings
337
+
338
+ from django_nplus1 import signals
339
+
340
+ self.counts = defaultdict(int)
341
+ self.threshold = getattr(settings, "NPLUS1_GET_THRESHOLD", 2)
342
+ signals.connect(signals.GET_CALL, self.handle_get)
343
+
344
+ def teardown(self) -> None:
345
+ from django_nplus1 import signals
346
+
347
+ signals.disconnect(signals.GET_CALL, self.handle_get)
348
+
349
+ def handle_get(
350
+ self,
351
+ args: tuple[Any, ...] | None = None,
352
+ kwargs: dict[str, Any] | None = None,
353
+ context: dict[str, Any] | None = None,
354
+ ret: Any = None,
355
+ parser: Any = None,
356
+ ) -> None:
357
+ model, caller = parser(args, kwargs, context, ret)
358
+ key = (model, *caller)
359
+ self.counts[key] += 1
360
+ if self.counts[key] == self.threshold:
361
+ message = GetLoopMessage(model, "get()", caller=caller)
362
+ self.parent.notify(message)
363
+
364
+
365
+ class DuplicateQueryMessage(Message):
366
+ label = "duplicate_query"
367
+ formatter = "Potential n+1 query detected: duplicate query `{field}`"
368
+
369
+
370
+ class _SQL:
371
+ pass
372
+
373
+
374
+ _SQL_LITERAL_RE = re.compile(r"'(?:[^']|'')*'|\b\d+\b")
375
+
376
+
377
+ def _fingerprint_sql(sql: str) -> str:
378
+ """Normalize a SQL query by replacing literals with ?, collapsing whitespace."""
379
+ return " ".join(_SQL_LITERAL_RE.sub("?", sql).split())
380
+
381
+
382
+ class DuplicateQueryListener(Listener):
383
+ """Detects repeated identical SQL queries (N+1 fallback for raw SQL).
384
+
385
+ Uses Django's connection.execute_wrapper to intercept all queries,
386
+ fingerprints them by normalizing literals, and counts occurrences
387
+ from the same call-site. This catches N+1 patterns from raw SQL,
388
+ .raw(), and any ORM path not covered by the descriptor-level detection.
389
+ """
390
+
391
+ def __init__(self, parent: Any) -> None:
392
+ super().__init__(parent)
393
+ self.enabled = False
394
+ self.counts: defaultdict[tuple[str, str, int, str], int] = defaultdict(int)
395
+ self._connection: Any = None
396
+
397
+ def setup(self) -> None:
398
+ from django.conf import settings
399
+ from django.db import connection
400
+
401
+ self.enabled = getattr(settings, "NPLUS1_DETECT_DUPLICATE_QUERIES", False)
402
+ if not self.enabled:
403
+ return
404
+ self.counts = defaultdict(int)
405
+ self.threshold = getattr(settings, "NPLUS1_DUPLICATE_QUERY_THRESHOLD", 2)
406
+ self._connection = connection
407
+ self._connection.execute_wrappers.append(self._wrapper)
408
+
409
+ def teardown(self) -> None:
410
+ if not self.enabled:
411
+ return
412
+ try:
413
+ self._connection.execute_wrappers.remove(self._wrapper)
414
+ except ValueError:
415
+ pass
416
+
417
+ def _wrapper(self, execute: Any, sql: str, params: Any, many: bool, context: Any) -> Any:
418
+ result = execute(sql, params, many, context)
419
+ if not many:
420
+ from django_nplus1.util import get_caller
421
+
422
+ fingerprint = _fingerprint_sql(sql)
423
+ caller = get_caller()
424
+ key = (fingerprint, *caller)
425
+ self.counts[key] += 1
426
+ if self.counts[key] == self.threshold:
427
+ short_sql = fingerprint[:120] + ("..." if len(fingerprint) > 120 else "")
428
+ message = DuplicateQueryMessage(_SQL, short_sql, caller=caller)
429
+ self.parent.notify(message)
430
+ return result
431
+
432
+
433
+ LISTENERS: dict[str, type[Listener]] = {
434
+ "lazy_load": LazyListener,
435
+ "eager_load": EagerListener,
436
+ "get_loop": GetLoopListener,
437
+ "duplicate_query": DuplicateQueryListener,
438
+ }
@@ -0,0 +1,2 @@
1
+ class NPlus1Error(Exception):
2
+ pass
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from asgiref.sync import iscoroutinefunction
7
+ from django.apps import apps
8
+ from django.conf import settings
9
+ from django.utils.decorators import sync_and_async_middleware
10
+
11
+ from django_nplus1 import notifiers
12
+ from django_nplus1.detect import LISTENERS, Message, Rule, is_allowed
13
+ from django_nplus1.exceptions import NPlus1Error
14
+ from django_nplus1.signals import nplus1_detected, setup_context, teardown_context
15
+
16
+ if TYPE_CHECKING:
17
+ from django.http import HttpRequest, HttpResponse
18
+
19
+ from django_nplus1.detect import Listener
20
+
21
+ _FNMATCH_CHARS = set("*?[]")
22
+
23
+
24
+ def _validate_whitelist(whitelist: list[dict[str, Any]]) -> None:
25
+ """Validate whitelist entries against the Django model registry.
26
+
27
+ Raises NPlus1Error for invalid model or field names.
28
+ Skips entries that use fnmatch wildcards (* ? [ ]).
29
+ """
30
+ # Build registry: {"app_label.ModelName": set_of_field_names}
31
+ registry: dict[str, set[str]] = {}
32
+ for model in apps.get_models():
33
+ key = f"{model._meta.app_label}.{model.__name__}"
34
+ fields = {f.name for f in model._meta.get_fields(include_hidden=True)}
35
+ # Include reverse relation accessor names
36
+ fields |= {name for rel in model._meta.related_objects if (name := rel.get_accessor_name()) is not None}
37
+ registry[key] = fields
38
+
39
+ for entry in whitelist:
40
+ model_pattern = entry.get("model")
41
+ if not model_pattern:
42
+ continue
43
+
44
+ # Skip fnmatch patterns
45
+ if any(c in model_pattern for c in _FNMATCH_CHARS):
46
+ continue
47
+
48
+ if model_pattern not in registry:
49
+ suffix = model_pattern.split(".")[-1].lower()
50
+ similar = sorted(k for k in registry if suffix in k.lower())[:3]
51
+ msg = f"NPLUS1_WHITELIST: model '{model_pattern}' not found in installed Django models."
52
+ if similar:
53
+ msg += f" Did you mean one of: {', '.join(similar)}?"
54
+ raise NPlus1Error(msg)
55
+
56
+ field_name = entry.get("field")
57
+ if not field_name:
58
+ continue
59
+ if any(c in field_name for c in _FNMATCH_CHARS):
60
+ continue
61
+
62
+ if field_name not in registry[model_pattern]:
63
+ raise NPlus1Error(
64
+ f"NPLUS1_WHITELIST: field '{field_name}' not found on '{model_pattern}'",
65
+ )
66
+
67
+
68
+ class DjangoRule(Rule):
69
+ def match_model(self, model: type) -> bool:
70
+ if self.model is model:
71
+ return True
72
+ if isinstance(self.model, str):
73
+ meta = getattr(model, "_meta", None)
74
+ if meta is None:
75
+ return False
76
+ return fnmatch.fnmatch(
77
+ f"{meta.app_label}.{model.__name__}",
78
+ self.model,
79
+ )
80
+ return False
81
+
82
+
83
+ _last_validated_whitelist: tuple[int, int] | None = None
84
+
85
+
86
+ def _load_config() -> tuple[list[notifiers.Notifier], list[DjangoRule]]:
87
+ """Load notifiers and whitelist from settings."""
88
+ global _last_validated_whitelist # noqa: PLW0603
89
+ nots = notifiers.init(settings)
90
+ whitelist_data = getattr(settings, "NPLUS1_WHITELIST", [])
91
+ cache_key = (id(whitelist_data), len(whitelist_data))
92
+ if cache_key != _last_validated_whitelist:
93
+ _validate_whitelist(whitelist_data)
94
+ _last_validated_whitelist = cache_key
95
+ whitelist = [DjangoRule(**item) for item in whitelist_data]
96
+ return nots, whitelist
97
+
98
+
99
+ class _DetectionContext:
100
+ """Manages listener setup/teardown for a single request scope."""
101
+
102
+ def __init__(self, nots: list[notifiers.Notifier], whitelist: list[DjangoRule]) -> None:
103
+ self.nots = nots
104
+ self.whitelist = whitelist
105
+ self.listeners: dict[str, Listener] = {}
106
+
107
+ def setup(self) -> None:
108
+ for name, listener_type in LISTENERS.items():
109
+ self.listeners[name] = listener_type(self)
110
+ self.listeners[name].setup()
111
+
112
+ def teardown(self) -> None:
113
+ for name in list(LISTENERS.keys()):
114
+ listener = self.listeners.pop(name, None)
115
+ if listener:
116
+ listener.teardown()
117
+
118
+ def notify(self, message: Message) -> None:
119
+ if not message.match(self.whitelist) and not is_allowed(message):
120
+ nplus1_detected.send(sender=NPlus1Middleware, message=message)
121
+ for notifier in self.nots:
122
+ notifier.notify(message)
123
+
124
+
125
+ @sync_and_async_middleware
126
+ def NPlus1Middleware(get_response: Any) -> Any: # noqa: N802
127
+ if iscoroutinefunction(get_response):
128
+
129
+ async def async_middleware(request: HttpRequest) -> HttpResponse:
130
+ token = setup_context()
131
+ try:
132
+ nots, whitelist = _load_config()
133
+ ctx = _DetectionContext(nots, whitelist)
134
+ ctx.setup()
135
+ try:
136
+ response = await get_response(request)
137
+ finally:
138
+ ctx.teardown()
139
+ finally:
140
+ teardown_context(token)
141
+ return response
142
+
143
+ return async_middleware
144
+
145
+ def sync_middleware(request: HttpRequest) -> HttpResponse:
146
+ token = setup_context()
147
+ try:
148
+ nots, whitelist = _load_config()
149
+ ctx = _DetectionContext(nots, whitelist)
150
+ ctx.setup()
151
+ try:
152
+ response = get_response(request)
153
+ finally:
154
+ ctx.teardown()
155
+ finally:
156
+ teardown_context(token)
157
+ return response
158
+
159
+ return sync_middleware