diff --git a/pyproject.toml b/pyproject.toml index 0b3ffc6..2ffaf77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ extend-select = [ "B", # bugbear "FIX", # disallow FIXME/TODO comments "F", # pyflakes + "T20", # flake8-print ] [tool.pyright] diff --git a/src/queryspy/listeners.py b/src/queryspy/listeners.py index e43f5d2..fdb36f4 100644 --- a/src/queryspy/listeners.py +++ b/src/queryspy/listeners.py @@ -9,6 +9,8 @@ from django.conf import settings from django.db import models +from queryspy.util import get_caller + from .errors import NPlusOneError, QuerySpyError ModelAndField = tuple[Type[models.Model], str] @@ -60,6 +62,8 @@ def _alert(self, model: type[models.Model], field: str, message: str): if is_allowlisted: return + caller = get_caller() + message = f"{message} at {caller.filename}:{caller.lineno} in {caller.function}" if should_error: raise self.error_class(message) else: diff --git a/src/queryspy/util.py b/src/queryspy/util.py new file mode 100644 index 0000000..7403d3b --- /dev/null +++ b/src/queryspy/util.py @@ -0,0 +1,15 @@ +import inspect + +PATTERNS = ["site-packages", "queryspy/listeners.py", "queryspy/patch.py"] + + +def get_caller() -> inspect.FrameInfo: + """ + Returns the filename and line number of the current caller, + excluding any code in site-packages or queryspy. + """ + return next( + frame + for frame in inspect.stack()[1:] + if not any(pattern in frame.filename for pattern in PATTERNS) + ) diff --git a/tests/test_listeners.py b/tests/test_listeners.py index 2497e4f..2984465 100644 --- a/tests/test_listeners.py +++ b/tests/test_listeners.py @@ -1,4 +1,5 @@ import logging +import re import pytest from djangoproject.social.models import Post, User @@ -20,7 +21,26 @@ def test_can_log_errors(settings, caplog): with caplog.at_level(logging.WARNING): for user in User.objects.all(): _ = list(user.posts.all()) - assert "N+1 detected on User.posts" in caplog.text + assert ( + re.search( + r"N\+1 detected on User\.posts at .*\/test_listeners\.py:23 in test_can_log_errors", + caplog.text, + ) + is not None + ), f"{caplog.text} does not match regex" + + +@queryspy_context() +def test_errors_include_caller(): + [user_1, user_2] = UserFactory.create_batch(2) + PostFactory.create(author=user_1) + PostFactory.create(author=user_2) + with pytest.raises( + NPlusOneError, + match=r"N\+1 detected on User\.posts at .*\/test_listeners\.py:43 in test_errors_include_caller", + ): + for user in User.objects.all(): + _ = list(user.posts.all()) @queryspy_context()