django-nplus1 0.1.0a1__tar.gz

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,33 @@
1
+ *.py[cod]
2
+ __pycache__/
3
+ .DS_Store
4
+ *.sql
5
+ *.bz2
6
+ *~
7
+ *.log
8
+ *.json
9
+ *.wsgi
10
+ local_settings.py
11
+ development_settings.py
12
+ *.egg-info
13
+ .project
14
+ .pydevproject
15
+ .settings
16
+ versiontools*
17
+ _build*
18
+ doc/index.html
19
+ /build/
20
+ /dist/
21
+ *.swp
22
+ \#*
23
+ .\#*
24
+ .tox
25
+ dump.rdb
26
+ .idea
27
+ .venv
28
+ .coverage
29
+ coverage.xml
30
+ cobertura.xml
31
+ CLAUDE.md
32
+ site/
33
+ *.sqlite3
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oliver Haas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-nplus1
3
+ Version: 0.1.0a1
4
+ Summary: N+1 query detection for Django
5
+ Project-URL: Homepage, https://github.com/oliverhaas/django-nplus1
6
+ Project-URL: Documentation, https://oliverhaas.github.io/django-nplus1/
7
+ Project-URL: Repository, https://github.com/oliverhaas/django-nplus1.git
8
+ Project-URL: Changelog, https://oliverhaas.github.io/django-nplus1/reference/changelog/
9
+ Author-email: Oliver Haas <ohaas@e1plus.de>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: detection,django,n+1,orm,performance,query
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Web Environment
15
+ Classifier: Framework :: Django
16
+ Classifier: Framework :: Django :: 5.2
17
+ Classifier: Framework :: Django :: 6.0
18
+ Classifier: Framework :: Pytest
19
+ Classifier: Intended Audience :: Developers
20
+ Classifier: License :: OSI Approved :: MIT License
21
+ Classifier: Operating System :: OS Independent
22
+ Classifier: Programming Language :: Python
23
+ Classifier: Programming Language :: Python :: 3
24
+ Classifier: Programming Language :: Python :: 3 :: Only
25
+ Classifier: Programming Language :: Python :: 3.12
26
+ Classifier: Programming Language :: Python :: 3.13
27
+ Classifier: Programming Language :: Python :: 3.14
28
+ Classifier: Topic :: Internet :: WWW/HTTP
29
+ Classifier: Topic :: Software Development :: Libraries
30
+ Classifier: Topic :: Software Development :: Testing
31
+ Classifier: Typing :: Typed
32
+ Requires-Python: >=3.12
33
+ Requires-Dist: django<7,>=5.2
34
+ Description-Content-Type: text/markdown
35
+
36
+ # django-nplus1
37
+
38
+ N+1 query detection for Django.
39
+
40
+ Detects N+1 queries (lazy loading related objects in a loop) and unused eager loads (`select_related`/`prefetch_related` that are never accessed) in your Django application.
41
+
42
+ Modernized fork of [nplusone](https://github.com/jmcarp/nplusone), stripped down to Django-only support with Python 3.12+ / Django 5.2+.
43
+
44
+ ## Features
45
+
46
+ - **N+1 detection**: Warns when a related object is lazily loaded on an instance that was part of a bulk query
47
+ - **Unused eager load detection**: Warns when `select_related` or `prefetch_related` results are never accessed
48
+ - **Middleware**: Automatically monitors all requests
49
+ - **pytest plugin**: `nplus1` fixture and `@pytest.mark.nplus1` marker for test-time detection
50
+ - **Profiler**: Context manager for manual use in scripts or tests
51
+ - **Whitelisting**: Ignore specific model/field combinations with wildcard support
52
+ - **Zero dependencies**: Only requires Django (no blinker, no six)
53
+
54
+ ## Quick Start
55
+
56
+ ```bash
57
+ pip install django-nplus1
58
+ ```
59
+
60
+ ```python
61
+ # settings.py
62
+ INSTALLED_APPS = [
63
+ ...,
64
+ "django_nplus1",
65
+ ]
66
+
67
+ MIDDLEWARE = [
68
+ ...,
69
+ "django_nplus1.NPlusOneMiddleware",
70
+ ]
71
+
72
+ # Optional
73
+ NPLUS1_RAISE = True # Raise exceptions instead of logging (recommended for tests)
74
+ ```
75
+
76
+ ## Documentation
77
+
78
+ Full documentation at [oliverhaas.github.io/django-nplus1](https://oliverhaas.github.io/django-nplus1/).
79
+
80
+ ## License
81
+
82
+ MIT
@@ -0,0 +1,47 @@
1
+ # django-nplus1
2
+
3
+ N+1 query detection for Django.
4
+
5
+ Detects N+1 queries (lazy loading related objects in a loop) and unused eager loads (`select_related`/`prefetch_related` that are never accessed) in your Django application.
6
+
7
+ Modernized fork of [nplusone](https://github.com/jmcarp/nplusone), stripped down to Django-only support with Python 3.12+ / Django 5.2+.
8
+
9
+ ## Features
10
+
11
+ - **N+1 detection**: Warns when a related object is lazily loaded on an instance that was part of a bulk query
12
+ - **Unused eager load detection**: Warns when `select_related` or `prefetch_related` results are never accessed
13
+ - **Middleware**: Automatically monitors all requests
14
+ - **pytest plugin**: `nplus1` fixture and `@pytest.mark.nplus1` marker for test-time detection
15
+ - **Profiler**: Context manager for manual use in scripts or tests
16
+ - **Whitelisting**: Ignore specific model/field combinations with wildcard support
17
+ - **Zero dependencies**: Only requires Django (no blinker, no six)
18
+
19
+ ## Quick Start
20
+
21
+ ```bash
22
+ pip install django-nplus1
23
+ ```
24
+
25
+ ```python
26
+ # settings.py
27
+ INSTALLED_APPS = [
28
+ ...,
29
+ "django_nplus1",
30
+ ]
31
+
32
+ MIDDLEWARE = [
33
+ ...,
34
+ "django_nplus1.NPlusOneMiddleware",
35
+ ]
36
+
37
+ # Optional
38
+ NPLUS1_RAISE = True # Raise exceptions instead of logging (recommended for tests)
39
+ ```
40
+
41
+ ## Documentation
42
+
43
+ Full documentation at [oliverhaas.github.io/django-nplus1](https://oliverhaas.github.io/django-nplus1/).
44
+
45
+ ## License
46
+
47
+ MIT
@@ -0,0 +1,11 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from django_nplus1.middleware import NPlusOneMiddleware
4
+ from django_nplus1.profiler import Profiler
5
+
6
+ try:
7
+ __version__ = version("django-nplus1")
8
+ except PackageNotFoundError:
9
+ __version__ = "0.0.0+unknown"
10
+
11
+ __all__ = ["NPlusOneMiddleware", "Profiler", "__version__"]
@@ -0,0 +1,10 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoNPlus1Config(AppConfig):
5
+ name = "django_nplus1"
6
+ verbose_name = "Django N+1"
7
+ default_auto_field = "django.db.models.BigAutoField"
8
+
9
+ def ready(self) -> None:
10
+ from django_nplus1 import patch # noqa: F401
@@ -0,0 +1,202 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ from collections import defaultdict
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ from collections.abc import Sequence
9
+
10
+
11
+ class Rule:
12
+ def __init__(self, label: str | None = None, model: str | type | None = None, field: str | None = None) -> None:
13
+ self.label = label
14
+ self.model = model
15
+ self.field = field
16
+
17
+ def compare(self, label: str, model: type, field: str) -> bool:
18
+ return bool(
19
+ (self.label or self.model or self.field)
20
+ and (self.label is None or self.label == label)
21
+ and (self.model is None or self.match_model(model))
22
+ and (self.field is None or self.field == field),
23
+ )
24
+
25
+ def match_model(self, model: type) -> bool:
26
+ if self.model is model:
27
+ return True
28
+ if isinstance(self.model, str):
29
+ return fnmatch.fnmatch(model.__name__, self.model)
30
+ return False
31
+
32
+
33
+ class Message:
34
+ label: str = ""
35
+ formatter: str = ""
36
+
37
+ def __init__(self, model: type, field: str) -> None:
38
+ self.model = model
39
+ self.field = field
40
+
41
+ @property
42
+ def message(self) -> str:
43
+ return self.formatter.format(
44
+ label=self.label,
45
+ model=self.model.__name__,
46
+ field=self.field,
47
+ )
48
+
49
+ def match(self, rules: Sequence[Rule]) -> bool:
50
+ return any(rule.compare(self.label, self.model, self.field) for rule in rules)
51
+
52
+
53
+ class LazyLoadMessage(Message):
54
+ label = "n_plus_one"
55
+ formatter = "Potential n+1 query detected on `{model}.{field}`"
56
+
57
+
58
+ class EagerLoadMessage(Message):
59
+ label = "unused_eager_load"
60
+ formatter = "Potential unnecessary eager load detected on `{model}.{field}`"
61
+
62
+
63
+ class Listener:
64
+ def __init__(self, parent: Any) -> None:
65
+ self.parent = parent
66
+
67
+ def setup(self) -> None:
68
+ pass
69
+
70
+ def teardown(self) -> None:
71
+ pass
72
+
73
+
74
+ class LazyListener(Listener):
75
+ loaded: set[str]
76
+ ignore: set[str]
77
+
78
+ def setup(self) -> None:
79
+ from django_nplus1 import signals
80
+
81
+ self.loaded = set()
82
+ self.ignore = set()
83
+ signals.connect(signals.LOAD, self.handle_load, sender=signals.get_worker())
84
+ signals.connect(signals.IGNORE_LOAD, self.handle_ignore, sender=signals.get_worker())
85
+ signals.connect(signals.LAZY_LOAD, self.handle_lazy, sender=signals.get_worker())
86
+
87
+ def teardown(self) -> None:
88
+ from django_nplus1 import signals
89
+
90
+ signals.disconnect(signals.LOAD, self.handle_load, sender=signals.get_worker())
91
+ signals.disconnect(signals.IGNORE_LOAD, self.handle_ignore, sender=signals.get_worker())
92
+ signals.disconnect(signals.LAZY_LOAD, self.handle_lazy, sender=signals.get_worker())
93
+
94
+ def handle_load(
95
+ self,
96
+ args: tuple[Any, ...] | None = None,
97
+ kwargs: dict[str, Any] | None = None,
98
+ context: dict[str, Any] | None = None,
99
+ ret: Any = None,
100
+ parser: Any = None,
101
+ ) -> None:
102
+ instances = parser(args, kwargs, context, ret)
103
+ self.loaded.update(instances)
104
+
105
+ def handle_ignore(
106
+ self,
107
+ args: tuple[Any, ...] | None = None,
108
+ kwargs: dict[str, Any] | None = None,
109
+ context: dict[str, Any] | None = None,
110
+ ret: Any = None,
111
+ parser: Any = None,
112
+ ) -> None:
113
+ instances = parser(args, kwargs, context, ret)
114
+ self.ignore.update(instances)
115
+
116
+ def handle_lazy(
117
+ self,
118
+ args: tuple[Any, ...] | None = None,
119
+ kwargs: dict[str, Any] | None = None,
120
+ context: dict[str, Any] | None = None,
121
+ ret: Any = None,
122
+ parser: Any = None,
123
+ ) -> None:
124
+ model, instance, field = parser(args, kwargs, context)
125
+ if instance in self.loaded and instance not in self.ignore:
126
+ message = LazyLoadMessage(model, field)
127
+ self.parent.notify(message)
128
+
129
+
130
+ class EagerListener(Listener):
131
+ tracker: EagerTracker
132
+ touched: list[tuple[type, str, list[str]] | None]
133
+
134
+ def setup(self) -> None:
135
+ from django_nplus1 import signals
136
+
137
+ self.tracker = EagerTracker()
138
+ self.touched = []
139
+ signals.connect(signals.EAGER_LOAD, self.handle_eager, sender=signals.get_worker())
140
+
141
+ def teardown(self) -> None:
142
+ from django_nplus1 import signals
143
+
144
+ self.log_eager()
145
+ signals.disconnect(signals.EAGER_LOAD, self.handle_eager, sender=signals.get_worker())
146
+ signals.disconnect(signals.TOUCH, self.handle_touch, sender=signals.get_worker())
147
+
148
+ def handle_eager(
149
+ self,
150
+ args: tuple[Any, ...] | None = None,
151
+ kwargs: dict[str, Any] | None = None,
152
+ context: dict[str, Any] | None = None,
153
+ ret: Any = None,
154
+ parser: Any = None,
155
+ ) -> None:
156
+ from django_nplus1 import signals
157
+
158
+ self.tracker.track(*parser(args, kwargs, context))
159
+ signals.connect(signals.TOUCH, self.handle_touch, sender=signals.get_worker())
160
+
161
+ def handle_touch(
162
+ self,
163
+ args: tuple[Any, ...] | None = None,
164
+ kwargs: dict[str, Any] | None = None,
165
+ context: dict[str, Any] | None = None,
166
+ ret: Any = None,
167
+ parser: Any = None,
168
+ ) -> None:
169
+ self.touched.append(parser(args, kwargs, context))
170
+
171
+ def log_eager(self) -> None:
172
+ self.tracker.prune([each for each in self.touched if each])
173
+ for model, field in self.tracker.unused:
174
+ message = EagerLoadMessage(model, field)
175
+ self.parent.notify(message)
176
+
177
+
178
+ class EagerTracker:
179
+ def __init__(self) -> None:
180
+ self.data: defaultdict[tuple[type, str], defaultdict[int, set[str]]] = defaultdict(
181
+ lambda: defaultdict(set),
182
+ )
183
+
184
+ def track(self, model: type, field: str, instances: list[str], key: int) -> None:
185
+ self.data[(model, field)][key].update(instances)
186
+
187
+ def prune(self, touched: list[tuple[type, str, list[str]]]) -> None:
188
+ for model, field, touch_instances in touched:
189
+ group = self.data[(model, field)]
190
+ for key, fetch_instances in list(group.items()):
191
+ if touch_instances and fetch_instances.intersection(touch_instances):
192
+ group.pop(key, None)
193
+
194
+ @property
195
+ def unused(self) -> list[tuple[type, str]]:
196
+ return [(model, field) for (model, field), group in self.data.items() if group]
197
+
198
+
199
+ LISTENERS: dict[str, type[Listener]] = {
200
+ "lazy_load": LazyListener,
201
+ "eager_load": EagerListener,
202
+ }
@@ -0,0 +1,2 @@
1
+ class NPlusOneError(Exception):
2
+ pass
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import weakref
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from django.conf import settings
8
+
9
+ from django_nplus1 import notifiers
10
+ from django_nplus1.detect import LISTENERS, Message, Rule
11
+
12
+ if TYPE_CHECKING:
13
+ from django.http import HttpRequest, HttpResponse
14
+
15
+ from django_nplus1.detect import Listener
16
+
17
+
18
+ class DjangoRule(Rule):
19
+ def match_model(self, model: type) -> bool:
20
+ if self.model is model:
21
+ return True
22
+ if isinstance(self.model, str):
23
+ return fnmatch.fnmatch(
24
+ f"{model._meta.app_label}.{model.__name__}", # type: ignore[attr-defined]
25
+ self.model,
26
+ )
27
+ return False
28
+
29
+
30
+ class NPlusOneMiddleware:
31
+ def __init__(self, get_response: Any) -> None:
32
+ self.get_response = get_response
33
+ self._listeners: weakref.WeakKeyDictionary[HttpRequest, dict[str, Listener]] = weakref.WeakKeyDictionary()
34
+ self._notifiers: list[notifiers.Notifier] = []
35
+ self._whitelist: list[DjangoRule] = []
36
+
37
+ def _load_config(self) -> None:
38
+ config = dict(vars(settings._wrapped)) # type: ignore[misc]
39
+ self._notifiers = notifiers.init(config)
40
+ self._whitelist = [DjangoRule(**item) for item in getattr(settings, "NPLUS1_WHITELIST", [])]
41
+
42
+ def __call__(self, request: HttpRequest) -> HttpResponse:
43
+ self._load_config()
44
+ self._listeners[request] = {}
45
+ for name, listener_type in LISTENERS.items():
46
+ self._listeners[request][name] = listener_type(self)
47
+ self._listeners[request][name].setup()
48
+ try:
49
+ response = self.get_response(request)
50
+ finally:
51
+ for name in list(LISTENERS.keys()):
52
+ listener = self._listeners.get(request, {}).pop(name, None)
53
+ if listener:
54
+ listener.teardown()
55
+ return response
56
+
57
+ def notify(self, message: Message) -> None:
58
+ if not message.match(self._whitelist):
59
+ for notifier in self._notifiers:
60
+ notifier.notify(message)
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from django_nplus1.exceptions import NPlusOneError
7
+
8
+ if TYPE_CHECKING:
9
+ from django_nplus1.detect import Message
10
+
11
+
12
+ class Notifier:
13
+ CONFIG_KEY: str | None = None
14
+ ENABLED_DEFAULT: bool = False
15
+
16
+ @classmethod
17
+ def is_enabled(cls, config: dict[str, Any]) -> bool:
18
+ if cls.CONFIG_KEY is None:
19
+ return cls.ENABLED_DEFAULT
20
+ if cls.CONFIG_KEY in config:
21
+ return bool(config[cls.CONFIG_KEY])
22
+ return cls.ENABLED_DEFAULT
23
+
24
+ def notify(self, message: Message) -> None:
25
+ pass
26
+
27
+
28
+ class LogNotifier(Notifier):
29
+ CONFIG_KEY = "NPLUS1_LOG"
30
+ ENABLED_DEFAULT = True
31
+
32
+ def __init__(self, config: dict[str, Any]) -> None:
33
+ self.logger: logging.Logger = config.get("NPLUS1_LOGGER", logging.getLogger("django_nplus1"))
34
+ self.level: int = config.get("NPLUS1_LOG_LEVEL", logging.WARNING)
35
+
36
+ def notify(self, message: Message) -> None:
37
+ self.logger.log(self.level, message.message)
38
+
39
+
40
+ class ErrorNotifier(Notifier):
41
+ CONFIG_KEY = "NPLUS1_RAISE"
42
+ ENABLED_DEFAULT = False
43
+
44
+ def __init__(self, config: dict[str, Any]) -> None:
45
+ self.error: type[Exception] = config.get("NPLUS1_ERROR", NPlusOneError)
46
+
47
+ def notify(self, message: Message) -> None:
48
+ raise self.error(message.message)
49
+
50
+
51
+ def init(config: dict[str, Any]) -> list[Notifier]:
52
+ return [notifier_cls(config) for notifier_cls in (LogNotifier, ErrorNotifier) if notifier_cls.is_enabled(config)]
@@ -0,0 +1,386 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import functools
5
+ import importlib
6
+ import threading
7
+ from typing import Any
8
+
9
+ from django.db.models import Model, query
10
+ from django.db.models.fields.related_descriptors import (
11
+ ForwardManyToOneDescriptor,
12
+ ReverseOneToOneDescriptor,
13
+ create_forward_many_to_many_manager,
14
+ create_reverse_many_to_one_manager,
15
+ )
16
+
17
+ from django_nplus1 import signals
18
+
19
+
20
+ def get_worker() -> str:
21
+ return str(threading.current_thread().ident)
22
+
23
+
24
+ def to_key(instance: Model) -> str:
25
+ return f"{type(instance).__name__}:{instance.pk}"
26
+
27
+
28
+ def _patch(original: Any, patched: Any) -> None:
29
+ module = importlib.import_module(original.__module__)
30
+ setattr(module, original.__name__, patched)
31
+
32
+
33
+ def signalify_queryset(
34
+ func: Any,
35
+ parser: Any = None,
36
+ **context: Any,
37
+ ) -> Any:
38
+ @functools.wraps(func)
39
+ def wrapped(*args: Any, **kwargs: Any) -> Any:
40
+ queryset = func(*args, **kwargs)
41
+ ctx = copy.copy(context)
42
+ ctx["args"] = context.get("args", args)
43
+ ctx["kwargs"] = context.get("kwargs", kwargs)
44
+ queryset._clone = signalify_queryset(queryset._clone, parser=parser, **ctx)
45
+ queryset._fetch_all = signalify_fetch_all(queryset, parser=parser, **ctx)
46
+ queryset._context = ctx
47
+ return queryset
48
+
49
+ return wrapped
50
+
51
+
52
+ def signalify_fetch_all(queryset: Any, parser: Any = None, **context: Any) -> Any:
53
+ func = queryset._fetch_all
54
+
55
+ @functools.wraps(func)
56
+ def wrapped(*args: Any, **kwargs: Any) -> Any:
57
+ if queryset._result_cache is None:
58
+ signals.send(
59
+ signals.LAZY_LOAD,
60
+ sender=get_worker(),
61
+ args=args,
62
+ kwargs=kwargs,
63
+ ret=None,
64
+ context=context,
65
+ parser=parser,
66
+ )
67
+ return func(*args, **kwargs)
68
+
69
+ return wrapped
70
+
71
+
72
+ def get_related_name(model: type[Model]) -> str:
73
+ return f"{model._meta.model_name}_set"
74
+
75
+
76
+ def parse_field(field: Any) -> tuple[type[Model], str]:
77
+ related_model = field.related_model
78
+ name = field.remote_field.name or get_related_name(field.related_model)
79
+ return related_model, name
80
+
81
+
82
+ def parse_reverse_field(field: Any) -> tuple[type[Model], str]:
83
+ return field.model, field.name
84
+
85
+
86
+ def parse_related(context: dict[str, Any]) -> tuple[type[Model], str]:
87
+ field = context["rel_field"]
88
+ model = field.related_model
89
+ related_name = field.remote_field.related_name
90
+ related_model = context["rel_model"]
91
+ return parse_related_parts(model, related_name, related_model)
92
+
93
+
94
+ def parse_related_parts(
95
+ model: type[Model], related_name: str | None, related_model: type[Model]
96
+ ) -> tuple[type[Model], str]:
97
+ return (
98
+ model,
99
+ related_name or get_related_name(related_model),
100
+ )
101
+
102
+
103
+ def parse_reverse_one_to_one_queryset(
104
+ args: Any,
105
+ kwargs: Any,
106
+ context: dict[str, Any],
107
+ ) -> tuple[type[Model], str, str]:
108
+ descriptor = context["args"][0]
109
+ field = descriptor.related.field
110
+ model, name = parse_field(field)
111
+ instance = context["kwargs"]["instance"]
112
+ return model, to_key(instance), name
113
+
114
+
115
+ def parse_forward_many_to_one_queryset(
116
+ args: Any,
117
+ kwargs: Any,
118
+ context: dict[str, Any],
119
+ ) -> tuple[type[Model], str, str]:
120
+ descriptor = context["args"][0]
121
+ instance = context["kwargs"]["instance"]
122
+ return descriptor.field.model, to_key(instance), descriptor.field.name
123
+
124
+
125
+ def parse_many_related_queryset(
126
+ args: Any,
127
+ kwargs: Any,
128
+ context: dict[str, Any],
129
+ ) -> tuple[type[Model], str, str]:
130
+ rel = context["rel"]
131
+ manager = context["args"][0]
132
+ model = manager.instance.__class__
133
+ related_model = manager.target_field.related_model
134
+ field = manager.prefetch_cache_name if rel.related_name else None
135
+ return (
136
+ model,
137
+ to_key(manager.instance),
138
+ field or get_related_name(related_model),
139
+ )
140
+
141
+
142
+ def parse_foreign_related_queryset(
143
+ args: Any,
144
+ kwargs: Any,
145
+ context: dict[str, Any],
146
+ ) -> tuple[type[Model], str, str]:
147
+ model, name = parse_related(context)
148
+ descriptor = context["args"][0]
149
+ return model, to_key(descriptor.instance), name
150
+
151
+
152
+ # Suppress lazy_load signals during prefetch_one_level
153
+ query.prefetch_one_level = signals.designalify( # type: ignore[attr-defined]
154
+ signals.LAZY_LOAD,
155
+ query.prefetch_one_level, # type: ignore[attr-defined]
156
+ )
157
+
158
+
159
+ def parse_get(
160
+ args: Any,
161
+ kwargs: Any,
162
+ context: dict[str, Any],
163
+ ret: Any,
164
+ ) -> list[str]:
165
+ return [to_key(ret)] if isinstance(ret, Model) else []
166
+
167
+
168
+ # Ignore records loaded during `get`
169
+ query.QuerySet.get = signals.signalify( # type: ignore[method-assign]
170
+ signals.IGNORE_LOAD,
171
+ query.QuerySet.get,
172
+ parser=parse_get,
173
+ )
174
+
175
+ # Patch descriptor get_queryset methods
176
+ ReverseOneToOneDescriptor.get_queryset = signalify_queryset( # type: ignore[method-assign]
177
+ ReverseOneToOneDescriptor.get_queryset,
178
+ parser=parse_reverse_one_to_one_queryset,
179
+ )
180
+ ForwardManyToOneDescriptor.get_queryset = signalify_queryset( # type: ignore[method-assign]
181
+ ForwardManyToOneDescriptor.get_queryset,
182
+ parser=parse_forward_many_to_one_queryset,
183
+ )
184
+
185
+
186
+ def _create_forward_many_to_many_manager(superclass: Any, rel: Any, **kwargs: Any) -> Any:
187
+ manager = create_forward_many_to_many_manager(superclass, rel, **kwargs)
188
+ manager.get_queryset = signalify_queryset( # type: ignore[method-assign]
189
+ manager.get_queryset,
190
+ parser=parse_many_related_queryset,
191
+ rel=rel,
192
+ rel_field=rel.field,
193
+ rel_model=rel.related_model,
194
+ )
195
+ return manager
196
+
197
+
198
+ _patch(create_forward_many_to_many_manager, _create_forward_many_to_many_manager)
199
+
200
+
201
+ def _create_reverse_many_to_one_manager(superclass: Any, rel: Any) -> Any:
202
+ manager = create_reverse_many_to_one_manager(superclass, rel)
203
+ manager.get_queryset = signalify_queryset( # type: ignore[method-assign]
204
+ manager.get_queryset,
205
+ parser=parse_foreign_related_queryset,
206
+ rel_field=rel.field,
207
+ rel_model=rel.related_model,
208
+ )
209
+ return manager
210
+
211
+
212
+ _patch(create_reverse_many_to_one_manager, _create_reverse_many_to_one_manager)
213
+
214
+
215
+ def parse_forward_many_to_one_get(
216
+ args: Any,
217
+ kwargs: Any,
218
+ context: dict[str, Any],
219
+ ) -> tuple[type[Model], str, list[str]] | None:
220
+ descriptor, instance, *_ = args
221
+ if instance is None:
222
+ return None
223
+ field, model = parse_reverse_field(descriptor.field)
224
+ return field, model, [to_key(instance)]
225
+
226
+
227
+ ForwardManyToOneDescriptor.__get__ = signals.signalify( # type: ignore[method-assign]
228
+ signals.TOUCH,
229
+ ForwardManyToOneDescriptor.__get__,
230
+ parser=parse_forward_many_to_one_get,
231
+ )
232
+
233
+
234
+ def parse_reverse_one_to_one_get(
235
+ args: Any,
236
+ kwargs: Any,
237
+ context: dict[str, Any],
238
+ ) -> tuple[type[Model], str, list[str]] | None:
239
+ descriptor, instance = args[:2]
240
+ if instance is None:
241
+ return None
242
+ model, field = parse_field(descriptor.related.field)
243
+ return model, field, [to_key(instance)]
244
+
245
+
246
+ ReverseOneToOneDescriptor.__get__ = signals.signalify( # type: ignore[method-assign]
247
+ signals.TOUCH,
248
+ ReverseOneToOneDescriptor.__get__,
249
+ parser=parse_reverse_one_to_one_get,
250
+ )
251
+
252
+
253
+ def parse_fetch_all(
254
+ args: Any,
255
+ kwargs: Any,
256
+ context: dict[str, Any],
257
+ ) -> tuple[type[Model], str, list[str]] | None:
258
+ self = args[0]
259
+ if hasattr(self, "_context"):
260
+ manager = self._context["args"][0]
261
+ instance = manager.instance
262
+ if manager.__class__.__name__ == "ManyRelatedManager":
263
+ return (
264
+ instance.__class__,
265
+ parse_manager_field(manager, self._context["rel"]),
266
+ [to_key(instance)],
267
+ )
268
+ model, field = parse_related(self._context)
269
+ return model, field, [to_key(instance)]
270
+ return None
271
+
272
+
273
+ def parse_manager_field(manager: Any, rel: Any) -> str:
274
+ if manager.reverse:
275
+ return rel.related_name or get_related_name(rel.related_model)
276
+ return rel.field.name or get_related_name(rel.model)
277
+
278
+
279
+ def parse_load(
280
+ args: Any,
281
+ kwargs: Any,
282
+ context: dict[str, Any],
283
+ ret: Any,
284
+ ) -> list[str]:
285
+ return [to_key(row) for row in ret if isinstance(row, Model)]
286
+
287
+
288
+ def is_single(low: int, high: int | None) -> bool:
289
+ return high is not None and high - low == 1
290
+
291
+
292
+ # Patch _fetch_all to emit load/ignore_load and touch signals
293
+ _original_fetch_all = query.QuerySet._fetch_all
294
+
295
+
296
+ def _fetch_all(self: Any) -> None:
297
+ if self._prefetch_done:
298
+ signals.send(
299
+ signals.TOUCH,
300
+ sender=get_worker(),
301
+ args=(self,),
302
+ parser=parse_fetch_all,
303
+ )
304
+ _original_fetch_all(self)
305
+ signal = signals.IGNORE_LOAD if is_single(self.query.low_mark, self.query.high_mark) else signals.LOAD
306
+ signals.send(
307
+ signal,
308
+ sender=get_worker(),
309
+ args=(self,),
310
+ ret=self._result_cache,
311
+ parser=parse_load,
312
+ )
313
+
314
+
315
+ query.QuerySet._fetch_all = _fetch_all # type: ignore[method-assign]
316
+
317
+
318
+ # Patch RelatedPopulator.__init__ to capture args for eager load parsing
319
+ _original_related_populator_init = query.RelatedPopulator.__init__ # type: ignore[attr-defined]
320
+
321
+
322
+ def _related_populator_init(self: Any, *args: Any, **kwargs: Any) -> None:
323
+ _original_related_populator_init(self, *args, **kwargs)
324
+ self.__nplus1__ = {"args": args, "kwargs": kwargs}
325
+
326
+
327
+ query.RelatedPopulator.__init__ = _related_populator_init # type: ignore[attr-defined]
328
+
329
+
330
+ def parse_eager_select(
331
+ args: Any,
332
+ kwargs: Any,
333
+ context: dict[str, Any],
334
+ ) -> tuple[type[Model], str, list[str], int]:
335
+ populator = args[0]
336
+ instance = args[2]
337
+ meta = populator.__nplus1__
338
+ klass_info, select, *_ = meta["args"]
339
+ field = klass_info["field"]
340
+ model, name = parse_field(field) if instance._meta.model != field.model else parse_reverse_field(field)
341
+ return model, name, [to_key(instance)], id(select)
342
+
343
+
344
+ # Emit eager_load on populating from select_related
345
+ query.RelatedPopulator.populate = signals.signalify( # type: ignore[attr-defined]
346
+ signals.EAGER_LOAD,
347
+ query.RelatedPopulator.populate, # type: ignore[attr-defined]
348
+ parser=parse_eager_select,
349
+ )
350
+
351
+
352
+ def parse_eager_join(
353
+ args: Any,
354
+ kwargs: Any,
355
+ context: dict[str, Any],
356
+ ) -> tuple[type[Model], str, list[str], int]:
357
+ instances, _descriptor, fetcher, level = args
358
+ model = instances[0].__class__
359
+ field, _ = fetcher.get_current_to_attr(level)
360
+ keys = [to_key(instance) for instance in instances]
361
+ return model, field, keys, id(instances)
362
+
363
+
364
+ # Emit eager_load on populating from prefetch_related
365
+ query.prefetch_one_level = signals.signalify( # type: ignore[attr-defined]
366
+ signals.EAGER_LOAD,
367
+ query.prefetch_one_level, # type: ignore[attr-defined]
368
+ parser=parse_eager_join,
369
+ )
370
+
371
+ # Emit touch on indexing into prefetched QuerySet instances
372
+ _original_getitem = query.QuerySet.__getitem__
373
+
374
+
375
+ def _getitem_queryset(self: Any, index: Any) -> Any:
376
+ if self._prefetch_done:
377
+ signals.send(
378
+ signals.TOUCH,
379
+ sender=get_worker(),
380
+ args=(self,),
381
+ parser=parse_fetch_all,
382
+ )
383
+ return _original_getitem(self, index)
384
+
385
+
386
+ query.QuerySet.__getitem__ = _getitem_queryset # type: ignore[method-assign]
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from django_nplus1.detect import LISTENERS, Listener, Message, Rule
6
+ from django_nplus1.exceptions import NPlusOneError
7
+
8
+ if TYPE_CHECKING:
9
+ from types import TracebackType
10
+
11
+
12
+ class Profiler:
13
+ def __init__(self, whitelist: list[dict[str, Any]] | None = None) -> None:
14
+ self.whitelist = [Rule(**item) for item in (whitelist or [])]
15
+ self._listeners: dict[str, Listener] = {}
16
+
17
+ def __enter__(self) -> Profiler:
18
+ for name, listener_type in LISTENERS.items():
19
+ self._listeners[name] = listener_type(self)
20
+ self._listeners[name].setup()
21
+ return self
22
+
23
+ def __exit__(
24
+ self,
25
+ exc_type: type[BaseException] | None,
26
+ exc_val: BaseException | None,
27
+ exc_tb: TracebackType | None,
28
+ ) -> None:
29
+ for name in list(LISTENERS.keys()):
30
+ self._listeners.pop(name).teardown()
31
+
32
+ def notify(self, message: Message) -> None:
33
+ if not message.match(self.whitelist):
34
+ raise NPlusOneError(message.message)
File without changes
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ import pytest
6
+
7
+ from django_nplus1.profiler import Profiler
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Generator
11
+
12
+
13
+ def pytest_configure(config: Any) -> None:
14
+ config.addinivalue_line("markers", "nplus1: mark test to detect N+1 queries")
15
+
16
+
17
+ @pytest.fixture
18
+ def nplus1() -> Generator[Profiler]:
19
+ with Profiler() as p:
20
+ yield p
21
+
22
+
23
+ @pytest.fixture(autouse=True)
24
+ def auto_nplus1(request: pytest.FixtureRequest) -> Generator[None]:
25
+ marker = request.node.get_closest_marker("nplus1")
26
+ if marker:
27
+ with Profiler(whitelist=marker.kwargs.get("whitelist")):
28
+ yield
29
+ else:
30
+ yield
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import functools
5
+ import threading
6
+ from collections import defaultdict
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Callable, Generator
11
+
12
+ _listeners: defaultdict[str, list[Callable[..., Any]]] = defaultdict(list)
13
+
14
+
15
+ def get_worker() -> str:
16
+ return str(threading.current_thread().ident)
17
+
18
+
19
+ def connect(signal_name: str, callback: Callable[..., Any], *, sender: str | None = None) -> None:
20
+ _listeners[_key(signal_name, sender)].append(callback)
21
+
22
+
23
+ def disconnect(signal_name: str, callback: Callable[..., Any], *, sender: str | None = None) -> None:
24
+ key = _key(signal_name, sender)
25
+ try:
26
+ _listeners[key].remove(callback)
27
+ except ValueError:
28
+ pass
29
+
30
+
31
+ def send(signal_name: str, *, sender: str | None = None, **kwargs: Any) -> None:
32
+ for callback in _listeners[_key(signal_name, sender)]:
33
+ callback(**kwargs)
34
+
35
+
36
+ def _key(signal_name: str, sender: str | None) -> str:
37
+ if sender is None:
38
+ return signal_name
39
+ return f"{signal_name}:{sender}"
40
+
41
+
42
+ def signalify(
43
+ signal_name: str, func: Callable[..., Any], *, parser: Callable[..., Any] | None = None
44
+ ) -> Callable[..., Any]:
45
+ @functools.wraps(func)
46
+ def wrapped(*args: Any, **kwargs: Any) -> Any:
47
+ ret = func(*args, **kwargs)
48
+ send(
49
+ signal_name,
50
+ sender=get_worker(),
51
+ args=args,
52
+ kwargs=kwargs,
53
+ ret=ret,
54
+ context={},
55
+ parser=parser,
56
+ )
57
+ return ret
58
+
59
+ return wrapped
60
+
61
+
62
+ def designalify(signal_name: str, func: Callable[..., Any]) -> Callable[..., Any]:
63
+ @functools.wraps(func)
64
+ def wrapped(*args: Any, **kwargs: Any) -> Any:
65
+ with suppress(signal_name):
66
+ return func(*args, **kwargs)
67
+
68
+ return wrapped
69
+
70
+
71
+ @contextlib.contextmanager
72
+ def suppress(signal_name: str, sender: str | None = None) -> Generator[None]:
73
+ sender = sender or get_worker()
74
+ key = _key(signal_name, sender)
75
+ saved = _listeners[key][:]
76
+ _listeners[key].clear()
77
+ try:
78
+ yield
79
+ finally:
80
+ _listeners[key] = saved
81
+
82
+
83
+ # Signal names as constants
84
+ LOAD = "load"
85
+ IGNORE_LOAD = "ignore_load"
86
+ LAZY_LOAD = "lazy_load"
87
+ EAGER_LOAD = "eager_load"
88
+ TOUCH = "touch"
@@ -0,0 +1,169 @@
1
+ [project]
2
+ name = "django-nplus1"
3
+ version = "0.1.0a1"
4
+ description = "N+1 query detection for Django"
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ requires-python = ">=3.12"
8
+ keywords = ["django", "n+1", "query", "detection", "performance", "orm"]
9
+ authors = [{ name = "Oliver Haas", email = "ohaas@e1plus.de" }]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Environment :: Web Environment",
13
+ "Framework :: Django",
14
+ "Framework :: Django :: 5.2",
15
+ "Framework :: Django :: 6.0",
16
+ "Framework :: Pytest",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Topic :: Internet :: WWW/HTTP",
27
+ "Topic :: Software Development :: Libraries",
28
+ "Topic :: Software Development :: Testing",
29
+ "Typing :: Typed",
30
+ ]
31
+ dependencies = ["Django>=5.2,<7"]
32
+
33
+ [project.entry-points.pytest11]
34
+ nplus1 = "django_nplus1.pytest_plugin"
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/oliverhaas/django-nplus1"
38
+ Documentation = "https://oliverhaas.github.io/django-nplus1/"
39
+ Repository = "https://github.com/oliverhaas/django-nplus1.git"
40
+ Changelog = "https://oliverhaas.github.io/django-nplus1/reference/changelog/"
41
+
42
+ [dependency-groups]
43
+ dev = [
44
+ "django-stubs==5.2.9",
45
+ "mypy==1.19.1",
46
+ "pre-commit==4.5.1",
47
+ "pytest-cov==7.0.0",
48
+ "pytest-django==4.12.0",
49
+ "pytest-mock==3.15.1",
50
+ "pytest==9.0.2",
51
+ "ruff==0.15.5",
52
+ "ty==0.0.21",
53
+ ]
54
+ docs = ["mkdocs==1.6.1", "mkdocs-material==9.7.3", "mike==2.1.3"]
55
+
56
+ [build-system]
57
+ requires = ["hatchling"]
58
+ build-backend = "hatchling.build"
59
+
60
+ [tool.hatch.build.targets.wheel]
61
+ packages = ["django_nplus1"]
62
+
63
+ [tool.hatch.build.targets.sdist]
64
+ include = ["django_nplus1", "LICENSE", "README.md"]
65
+
66
+ [tool.ruff]
67
+ target-version = "py313"
68
+ line-length = 120
69
+ fix = true
70
+
71
+ [tool.ruff.lint]
72
+ select = ["ALL"]
73
+ ignore = [
74
+ "ANN401", # Any type is acceptable for generic ORM internals
75
+ "ARG001", # Unused function args required by parser callback interface (args, kwargs, context, ret)
76
+ "ARG002", # Unused method args required by signal callback interface
77
+ "COM812", # Conflicts with ruff-format
78
+ "D", # Docstrings - will add incrementally
79
+ "E501", # Line length handled by config
80
+ "EM", # Exception strings inline are fine
81
+ "FBT", # Boolean params acceptable for config flags
82
+ "PERF203", # try/except in loop is fine for signal dispatch
83
+ "PLC0415", # Dynamic imports needed to avoid circular imports (detect.py -> signals.py)
84
+ "PLR0913", # Many args needed for signal parsers
85
+ "PLR2004", # Magic values like slice indices are acceptable
86
+ "PYI034", # __enter__ returning Self vs concrete type is preference
87
+ "SIM105", # try/except/pass is clearer than contextlib.suppress for single-line
88
+ "SLF001", # Private member access needed for ORM monkeypatching
89
+ "TRY003", # Long exception messages are helpful for debugging
90
+ ]
91
+
92
+ [tool.ruff.lint.flake8-annotations]
93
+ allow-star-arg-any = true
94
+ suppress-none-returning = true
95
+
96
+ [tool.ruff.lint.per-file-ignores]
97
+ "tests/**" = [
98
+ "ANN", # Type annotations not required in tests
99
+ "ARG", # Unused arguments common in fixtures
100
+ "B018", # Useless expressions are how we trigger lazy loads in tests
101
+ "DJ008", # Test models don't need __str__
102
+ "E402", # Import order in settings files
103
+ "F841", # Unused variables okay in tests
104
+ "PT006", # Parametrize tuple style is preference
105
+ "PT011", # Broad pytest.raises okay in some tests
106
+ "PT012", # Multi-statement pytest.raises needed for N+1 trigger patterns
107
+ "PT013", # pytest import style is preference
108
+ "PT018", # Compound assertions okay in tests
109
+ "RUF043", # Regex metacharacters in match= are intentional (dot in model.field)
110
+ "S101", # assert in tests
111
+ "S105", # Hardcoded passwords in test settings
112
+ "T201", # print in test views is intentional
113
+ ]
114
+
115
+ [tool.mypy]
116
+ python_version = "3.12"
117
+ plugins = ["mypy_django_plugin.main"]
118
+ pretty = true
119
+ show_error_codes = true
120
+ show_error_context = true
121
+ warn_redundant_casts = true
122
+ warn_unused_ignores = true
123
+ warn_unreachable = false
124
+
125
+ [tool.django-stubs]
126
+ django_settings_module = "tests.settings.base"
127
+
128
+ [[tool.mypy.overrides]]
129
+ module = "testapp.*"
130
+ ignore_missing_imports = true
131
+
132
+ [tool.ty.rules]
133
+ invalid-method-override = "ignore"
134
+
135
+ [tool.ty.environment]
136
+ python-version = "3.13"
137
+
138
+ [tool.ty.analysis]
139
+ respect-type-ignore-comments = false
140
+
141
+ [[tool.ty.overrides]]
142
+ include = ["django_nplus1/patch.py", "django_nplus1/middleware.py"]
143
+
144
+ [tool.ty.overrides.rules]
145
+ # patch.py monkeypatches Django internals; middleware.py accesses _meta on type
146
+ unresolved-attribute = "ignore"
147
+ invalid-assignment = "ignore"
148
+
149
+ [[tool.ty.overrides]]
150
+ include = ["tests/**"]
151
+
152
+ [tool.ty.overrides.rules]
153
+ unresolved-attribute = "ignore"
154
+ invalid-argument-type = "ignore"
155
+ invalid-assignment = "ignore"
156
+
157
+ [tool.pytest.ini_options]
158
+ DJANGO_SETTINGS_MODULE = "settings.base"
159
+ addopts = "--cov=django_nplus1 --cov-report=term-missing --cov-report=xml --no-cov-on-fail"
160
+ pythonpath = ["tests"]
161
+ testpaths = ["tests"]
162
+ xfail_strict = true
163
+
164
+ [tool.coverage.run]
165
+ omit = ["tests/*"]
166
+
167
+ [tool.coverage.report]
168
+ precision = 2
169
+ skip_covered = true