django-query-contract 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.
- django_query_contract/__init__.py +23 -0
- django_query_contract/capture_stack.py +64 -0
- django_query_contract/format_capture_report.py +128 -0
- django_query_contract/log_ceiling.py +73 -0
- django_query_contract/normalise_sql.py +90 -0
- django_query_contract/plugin.py +129 -0
- django_query_contract/py.typed +0 -0
- django_query_contract/query_capture.py +211 -0
- django_query_contract/query_log_ceiling_warning.py +19 -0
- django_query_contract/query_record.py +97 -0
- django_query_contract/stack_frame.py +29 -0
- django_query_contract/version.py +7 -0
- django_query_contract-0.1.0.dist-info/METADATA +169 -0
- django_query_contract-0.1.0.dist-info/RECORD +17 -0
- django_query_contract-0.1.0.dist-info/WHEEL +4 -0
- django_query_contract-0.1.0.dist-info/entry_points.txt +2 -0
- django_query_contract-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""A query-capture engine for Django, and a pytest plugin over it."""
|
|
2
|
+
|
|
3
|
+
from django_query_contract.capture_stack import capture_stack
|
|
4
|
+
from django_query_contract.format_capture_report import format_capture_report
|
|
5
|
+
from django_query_contract.log_ceiling import LogCeiling
|
|
6
|
+
from django_query_contract.normalise_sql import normalise_sql
|
|
7
|
+
from django_query_contract.query_capture import QueryCapture
|
|
8
|
+
from django_query_contract.query_log_ceiling_warning import QueryLogCeilingWarning
|
|
9
|
+
from django_query_contract.query_record import QueryRecord
|
|
10
|
+
from django_query_contract.stack_frame import StackFrame
|
|
11
|
+
from django_query_contract.version import __version__
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"LogCeiling",
|
|
15
|
+
"QueryCapture",
|
|
16
|
+
"QueryLogCeilingWarning",
|
|
17
|
+
"QueryRecord",
|
|
18
|
+
"StackFrame",
|
|
19
|
+
"__version__",
|
|
20
|
+
"capture_stack",
|
|
21
|
+
"format_capture_report",
|
|
22
|
+
"normalise_sql",
|
|
23
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Walk the live call stack into plain frame records."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from django_query_contract.stack_frame import StackFrame
|
|
9
|
+
|
|
10
|
+
# The directory this package occupies. Frames inside it are dropped, so a caller
|
|
11
|
+
# never sees the capture machinery between itself and the query -- including the
|
|
12
|
+
# frame of this function. Computed once at import: it cannot change, and doing it
|
|
13
|
+
# per query would cost a path resolution on every execution.
|
|
14
|
+
_PACKAGE_ROOT = os.path.dirname(os.path.abspath(__file__)) + os.sep
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def capture_stack(depth: int) -> tuple[tuple[StackFrame, ...], bool]:
|
|
18
|
+
"""Return the innermost ``depth`` frames outside this package, and whether more existed.
|
|
19
|
+
|
|
20
|
+
Ordered outermost-first, the way a traceback reads. The *innermost* frames
|
|
21
|
+
are the ones kept when the stack is deeper than ``depth``, because those are
|
|
22
|
+
where the information is: between a cursor execution and the test function
|
|
23
|
+
sit twenty-odd Django frames, and the call site this package exists to name
|
|
24
|
+
is just outside them. Truncating from the other end would drop it.
|
|
25
|
+
|
|
26
|
+
The boolean is the second half of that bargain. A truncated stack can hide
|
|
27
|
+
the call site entirely, and this package's whole argument is that a
|
|
28
|
+
measurement which quietly stops being true is worse than one that refuses --
|
|
29
|
+
so truncation is reported rather than absorbed.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
The frames and a flag that is ``True`` when at least one more frame was
|
|
33
|
+
available beyond ``depth``.
|
|
34
|
+
"""
|
|
35
|
+
frame = inspect.currentframe()
|
|
36
|
+
if frame is None:
|
|
37
|
+
# Python implementations are not required to support frame
|
|
38
|
+
# introspection, and an interpreter that declines returns None here
|
|
39
|
+
# rather than raising. Every other face of this capture still works with
|
|
40
|
+
# an empty stack, so this degrades to "no call site" instead of failing
|
|
41
|
+
# a suite that was only asking for a query count.
|
|
42
|
+
return (), False
|
|
43
|
+
|
|
44
|
+
collected: list[StackFrame] = []
|
|
45
|
+
truncated = False
|
|
46
|
+
current = frame.f_back
|
|
47
|
+
while current is not None:
|
|
48
|
+
code = current.f_code
|
|
49
|
+
filename = code.co_filename
|
|
50
|
+
if not filename.startswith(_PACKAGE_ROOT):
|
|
51
|
+
if len(collected) == depth:
|
|
52
|
+
truncated = True
|
|
53
|
+
break
|
|
54
|
+
collected.append(
|
|
55
|
+
StackFrame(
|
|
56
|
+
filename=filename,
|
|
57
|
+
lineno=current.f_lineno,
|
|
58
|
+
function=code.co_name,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
current = current.f_back
|
|
62
|
+
|
|
63
|
+
collected.reverse()
|
|
64
|
+
return tuple(collected), truncated
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Turn a capture into the paragraph a reader needs under a failed assertion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from django_query_contract.query_capture import QueryCapture
|
|
8
|
+
from django_query_contract.query_record import QueryRecord
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def format_capture_report(
|
|
12
|
+
capture: QueryCapture,
|
|
13
|
+
*,
|
|
14
|
+
max_shapes: int = 5,
|
|
15
|
+
max_sql: int = 160,
|
|
16
|
+
) -> str:
|
|
17
|
+
"""Describe what a capture saw, ordered by what repeated most.
|
|
18
|
+
|
|
19
|
+
This is the diagnosis half of the bargain with ``django_assert_num_queries``:
|
|
20
|
+
that fixture builds its message inline and calls ``pytest.fail()``, so there
|
|
21
|
+
is no hook inside it and no reason to want one. The user keeps writing the
|
|
22
|
+
assertion they already write, and a failure gains this underneath it.
|
|
23
|
+
|
|
24
|
+
It reports repetition and does not name it. Deciding that a repeated
|
|
25
|
+
statement *is* an N+1 needs the call stack as well as the fingerprint, and
|
|
26
|
+
that judgement is a later milestone -- shipping it as a hint here would be
|
|
27
|
+
the heuristic four dead detectors died of.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
capture: A closed capture.
|
|
31
|
+
max_shapes: How many repeated shapes to list before summarising the rest.
|
|
32
|
+
max_sql: Where to cut a long statement. The record keeps the whole thing.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The report, without a trailing newline. Empty when there is nothing to
|
|
36
|
+
say -- no statements and no ceiling crossed. There is deliberately no
|
|
37
|
+
early return for that case: an explicit guard here was provably dead,
|
|
38
|
+
because both halves below already produce nothing from nothing.
|
|
39
|
+
"""
|
|
40
|
+
lines = _ceiling_lines(capture)
|
|
41
|
+
if capture.records:
|
|
42
|
+
if lines:
|
|
43
|
+
lines.append("")
|
|
44
|
+
lines.extend(_shape_lines(capture, max_shapes=max_shapes, max_sql=max_sql))
|
|
45
|
+
return "\n".join(lines)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _ceiling_lines(capture: QueryCapture) -> list[str]:
|
|
49
|
+
"""The honest half: say when a count taken from Django's log is wrong."""
|
|
50
|
+
lines: list[str] = []
|
|
51
|
+
for ceiling in capture.exceeded_ceilings:
|
|
52
|
+
lines.append(
|
|
53
|
+
f"Query log ceiling exceeded on '{ceiling.alias}': "
|
|
54
|
+
f"{ceiling.executions} statements executed, but connection.queries_log holds at "
|
|
55
|
+
f"most {ceiling.limit} and already held {ceiling.log_length_at_enter}, so a "
|
|
56
|
+
f"CaptureQueriesContext over this block reports {ceiling.visible}."
|
|
57
|
+
)
|
|
58
|
+
lines.append(
|
|
59
|
+
" Above that ceiling assertNumQueries and django_assert_num_queries under-report, "
|
|
60
|
+
"and with a full log they report zero for a block that ran queries."
|
|
61
|
+
)
|
|
62
|
+
return lines
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _shape_lines(capture: QueryCapture, *, max_shapes: int, max_sql: int) -> list[str]:
|
|
66
|
+
"""The repetition half: which shapes ran more than once, and from where."""
|
|
67
|
+
by_alias: dict[str, int] = {}
|
|
68
|
+
for record in capture.records:
|
|
69
|
+
by_alias[record.alias] = by_alias.get(record.alias, 0) + 1
|
|
70
|
+
counted = ", ".join(f"{count} on '{alias}'" for alias, count in by_alias.items())
|
|
71
|
+
lines = [f"{len(capture.records)} statements captured: {counted}."]
|
|
72
|
+
|
|
73
|
+
groups = sorted(
|
|
74
|
+
capture.by_fingerprint().items(),
|
|
75
|
+
# Descending by repeat count, then by first appearance so the order is
|
|
76
|
+
# total and the report does not reshuffle between two identical runs.
|
|
77
|
+
key=lambda item: (-len(item[1]), item[1][0].index),
|
|
78
|
+
)
|
|
79
|
+
repeated = [group for group in groups if len(group[1]) > 1]
|
|
80
|
+
once = len(groups) - len(repeated)
|
|
81
|
+
|
|
82
|
+
if repeated:
|
|
83
|
+
lines.append("")
|
|
84
|
+
lines.append("Repeated statement shapes:")
|
|
85
|
+
for fingerprint, records in repeated[:max_shapes]:
|
|
86
|
+
lines.extend(_group_lines(fingerprint, records, max_sql=max_sql))
|
|
87
|
+
if len(repeated) > max_shapes:
|
|
88
|
+
lines.append(f" and {len(repeated) - max_shapes} more repeated shapes.")
|
|
89
|
+
if once:
|
|
90
|
+
lines.append(f" {once} shape(s) ran once.")
|
|
91
|
+
return lines
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _group_lines(fingerprint: str, records: tuple[QueryRecord, ...], *, max_sql: int) -> list[str]:
|
|
95
|
+
"""One repeated shape: how often, where from, and which queries."""
|
|
96
|
+
indices = ", ".join(f"#{record.index}" for record in records[:8])
|
|
97
|
+
if len(records) > 8:
|
|
98
|
+
indices += ", ..."
|
|
99
|
+
lines = [f" {len(records)} x {indices}", f" {_shorten(fingerprint, max_sql)}"]
|
|
100
|
+
# One call site per distinct site rather than one per query: a genuine N+1
|
|
101
|
+
# has one, and a shape reached from several places is worth seeing as such.
|
|
102
|
+
sites = {str(record.call_site) for record in records if record.call_site is not None}
|
|
103
|
+
if sites:
|
|
104
|
+
for site in sorted(sites):
|
|
105
|
+
lines.append(f" from {_relative(site)}")
|
|
106
|
+
else:
|
|
107
|
+
lines.append(" from no frame outside Django (stack empty or truncated)")
|
|
108
|
+
return lines
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _shorten(text: str, limit: int) -> str:
|
|
112
|
+
"""Cut a statement to ``limit`` characters, saying that it was cut."""
|
|
113
|
+
if len(text) <= limit:
|
|
114
|
+
return text
|
|
115
|
+
return text[:limit] + " ... (truncated)"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _relative(site: str) -> str:
|
|
119
|
+
"""Shorten a call site to a path relative to the working directory when it is under it.
|
|
120
|
+
|
|
121
|
+
Only when it is under it: ``os.path.relpath`` will happily walk out of the
|
|
122
|
+
tree with a row of ``..`` segments, which is longer than the absolute path
|
|
123
|
+
and harder to read.
|
|
124
|
+
"""
|
|
125
|
+
root = os.getcwd() + os.sep
|
|
126
|
+
if site.startswith(root):
|
|
127
|
+
return site[len(root) :]
|
|
128
|
+
return site
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""How much of a block Django's own query log could still see."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class LogCeiling:
|
|
10
|
+
"""The arithmetic behind a query count that stops being true.
|
|
11
|
+
|
|
12
|
+
``django.test.utils.CaptureQueriesContext`` -- which is what
|
|
13
|
+
``assertNumQueries`` and pytest-django's ``django_assert_num_queries`` both
|
|
14
|
+
count with -- records ``len(connection.queries_log)`` on entry and on exit
|
|
15
|
+
and returns the slice between them. But ``queries_log`` is a
|
|
16
|
+
``deque(maxlen=connection.queries_limit)``, 9000 by default, and once it
|
|
17
|
+
rotates those two absolute indices no longer point at what they did.
|
|
18
|
+
|
|
19
|
+
Measured against Django 6.1, with the count that context manager reports:
|
|
20
|
+
|
|
21
|
+
| Already in the log | Queries in the block | Reported |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| 0 | 8999 | 8999 |
|
|
24
|
+
| 0 | 9001 | 9000 |
|
|
25
|
+
| 8990 | 100 | 10 |
|
|
26
|
+
| 9000 | 5 | 0 |
|
|
27
|
+
|
|
28
|
+
The last row is the one that matters: five real queries, a reported count of
|
|
29
|
+
zero, and ``django_assert_max_num_queries(1)`` passing. Django does emit a
|
|
30
|
+
``UserWarning`` when the log is full, so it is not perfectly silent -- but a
|
|
31
|
+
warning in the summary beside a green test is not what a reader takes from a
|
|
32
|
+
passing assertion, and the regime this happens in, thousands of queries in
|
|
33
|
+
one block, is precisely the N+1-at-scale case worth catching.
|
|
34
|
+
|
|
35
|
+
The capture in this package counts executions through
|
|
36
|
+
``connection.execute_wrapper``, which has no bound at all. This class is how
|
|
37
|
+
it says so instead of quietly being right where the other is wrong.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
alias: str
|
|
41
|
+
"""The Django connection alias these numbers describe."""
|
|
42
|
+
|
|
43
|
+
limit: int | None
|
|
44
|
+
"""``connection.queries_limit``. ``None`` means the log is unbounded and there is no ceiling."""
|
|
45
|
+
|
|
46
|
+
log_length_at_enter: int
|
|
47
|
+
"""``len(connection.queries_log)`` when the capture opened, itself capped at ``limit``."""
|
|
48
|
+
|
|
49
|
+
executions: int
|
|
50
|
+
"""Statements this capture counted. Unbounded, and therefore the true number."""
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def headroom(self) -> int | None:
|
|
54
|
+
"""How many more entries the log could hold when the capture opened.
|
|
55
|
+
|
|
56
|
+
``None`` when the log is unbounded.
|
|
57
|
+
"""
|
|
58
|
+
if self.limit is None:
|
|
59
|
+
return None
|
|
60
|
+
return max(self.limit - self.log_length_at_enter, 0)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def visible(self) -> int:
|
|
64
|
+
"""What a ``CaptureQueriesContext`` opened at the same moment would report."""
|
|
65
|
+
headroom = self.headroom
|
|
66
|
+
if headroom is None:
|
|
67
|
+
return self.executions
|
|
68
|
+
return min(self.executions, headroom)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def exceeded(self) -> bool:
|
|
72
|
+
"""``True`` when a count taken from Django's query log would be too low."""
|
|
73
|
+
return self.visible != self.executions
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Reduce a statement to the shape it shares with its repeats."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
|
|
8
|
+
# Every rule below is here because a real Django execution needs it. They were
|
|
9
|
+
# read off the wire -- an ``execute_wrapper`` over an ORM session on SQLite --
|
|
10
|
+
# rather than imagined, and each one is named by the statement that produced it.
|
|
11
|
+
|
|
12
|
+
# ``SAVEPOINT "s8384995712_x1"`` / ``RELEASE SAVEPOINT ...`` / ``ROLLBACK TO
|
|
13
|
+
# SAVEPOINT ...``. The identifier carries the thread id and a per-connection
|
|
14
|
+
# counter, so it is different for every savepoint and different again on the
|
|
15
|
+
# next run. It is not a bound parameter -- there are no parameters on these
|
|
16
|
+
# statements at all -- so a normaliser that only strips placeholders leaves
|
|
17
|
+
# every nested ``atomic()`` block looking like a unique query shape.
|
|
18
|
+
_SAVEPOINT = re.compile(r"(?i)(\bSAVEPOINT\s+)(\"[^\"]*\"|`[^`]*`|\[[^\]]*\]|[^\s;]+)")
|
|
19
|
+
|
|
20
|
+
# Single-quoted literals, with SQL's doubled-quote escape. Django's own SQL is
|
|
21
|
+
# fully parameterised, so these come from the escape hatches: ``.raw()``,
|
|
22
|
+
# ``RawSQL``, ``.extra()``, a hand-written ``cursor.execute``, and migrations.
|
|
23
|
+
_STRING_LITERAL = re.compile(r"'(?:[^']|'')*'")
|
|
24
|
+
|
|
25
|
+
# Numeric literals in value position. The lookarounds are what keep this from
|
|
26
|
+
# mangling identifiers: a digit preceded or followed by a word character or a
|
|
27
|
+
# quote belongs to a name (``app_address2``, ``"s123_x1"``, ``col1``), while a
|
|
28
|
+
# free-standing number is a value. Django inlines ``LIMIT`` and ``OFFSET``
|
|
29
|
+
# rather than binding them, which is how a paginating loop -- as real an N+1 as
|
|
30
|
+
# any other -- would otherwise present as a different shape on every page.
|
|
31
|
+
_NUMERIC_LITERAL = re.compile(r"(?<![\w\"'`\].])\d+(?:\.\d+)?(?![\w\"'`\[.])")
|
|
32
|
+
|
|
33
|
+
# A parenthesised run of placeholders collapses to one. ``IN (%s, %s, %s)`` is
|
|
34
|
+
# what ``prefetch_related`` and ``pk__in`` emit, and its width is the size of the
|
|
35
|
+
# data, not the shape of the query. The same rule handles a multi-row
|
|
36
|
+
# ``INSERT ... VALUES (%s, %s), (%s, %s)`` from ``bulk_create``, which would
|
|
37
|
+
# otherwise fingerprint differently for every batch size.
|
|
38
|
+
_PLACEHOLDER_RUN = re.compile(r"\(\s*%s(?:\s*,\s*%s)+\s*\)")
|
|
39
|
+
_PLACEHOLDER_GROUPS = re.compile(r"\(%s\)(?:\s*,\s*\(%s\))+")
|
|
40
|
+
|
|
41
|
+
_WHITESPACE = re.compile(r"\s+")
|
|
42
|
+
|
|
43
|
+
_PLACEHOLDER = "%s"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Measured on an ORM query of average length: the five passes below cost about
|
|
47
|
+
# 5.9 microseconds, which was more than the call-stack walk and the single
|
|
48
|
+
# largest part of capture. A cache is the obvious answer precisely because of
|
|
49
|
+
# what this package is for -- a suite that repeats a statement is the case it
|
|
50
|
+
# exists to find, so the hit rate is highest exactly where the cost is. Bounded
|
|
51
|
+
# rather than unbounded because a wide ``IN`` list produces one distinct
|
|
52
|
+
# statement per width, and an unbounded cache would grow with the data.
|
|
53
|
+
@lru_cache(maxsize=2048)
|
|
54
|
+
def normalise_sql(sql: str) -> str:
|
|
55
|
+
"""Return the fingerprint of ``sql``: the part that repeats across executions.
|
|
56
|
+
|
|
57
|
+
Two executions with the same fingerprint ran the same statement with
|
|
58
|
+
different data. Paired with the call stack, that is the definition of an
|
|
59
|
+
N+1 -- which is why this is a small list of named, reversible-in-the-head
|
|
60
|
+
rules rather than a hash. Every one of them can be pointed at, argued with
|
|
61
|
+
and tested, and the record keeps the original SQL beside the fingerprint so
|
|
62
|
+
a report never has to be believed on the strength of the normaliser alone.
|
|
63
|
+
|
|
64
|
+
A real SQL parser was considered and does not fit, for three independent
|
|
65
|
+
reasons. The SQL that reaches ``execute_wrapper`` carries Django's ``%s``
|
|
66
|
+
placeholders -- the backend's own paramstyle is applied below the wrapper --
|
|
67
|
+
and ``%s`` is a syntax error to a PostgreSQL grammar, so a parser cannot
|
|
68
|
+
read the input at all without the parameters being substituted back in,
|
|
69
|
+
which is the opposite of what a fingerprint is for. It would also be
|
|
70
|
+
PostgreSQL-only in a package whose count and growth assertions are meant to
|
|
71
|
+
work on any backend. And it costs roughly fifteen times a regex pass, per
|
|
72
|
+
query, in a suite that may run hundreds of thousands.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
sql: The statement as handed to ``cursor.execute``.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The normalised statement, with whitespace collapsed.
|
|
79
|
+
"""
|
|
80
|
+
# String literals go first so that a statement which merely *mentions* a
|
|
81
|
+
# savepoint inside quotes is reduced to a placeholder before the savepoint
|
|
82
|
+
# rule can reach into it and leave an unbalanced quote behind.
|
|
83
|
+
normalised = _STRING_LITERAL.sub(_PLACEHOLDER, sql)
|
|
84
|
+
normalised = _SAVEPOINT.sub(r"\1<savepoint>", normalised)
|
|
85
|
+
normalised = _NUMERIC_LITERAL.sub(_PLACEHOLDER, normalised)
|
|
86
|
+
# Runs first, groups second: the group rule matches ``(%s)`` items, which is
|
|
87
|
+
# what a multi-column run becomes once the run rule has collapsed it.
|
|
88
|
+
normalised = _PLACEHOLDER_RUN.sub("(%s)", normalised)
|
|
89
|
+
normalised = _PLACEHOLDER_GROUPS.sub("(%s)", normalised)
|
|
90
|
+
return _WHITESPACE.sub(" ", normalised).strip()
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""The pytest face of the capture: a diagnosis, and never an assertion.
|
|
2
|
+
|
|
3
|
+
This module is the one place in the package that is not a single exported
|
|
4
|
+
symbol, because pytest finds hooks by name in a module and there is nowhere else
|
|
5
|
+
to put them. It stays thin for that reason: every hook here decides *when* to
|
|
6
|
+
capture and *when* to speak, and hands the actual work to a module that can be
|
|
7
|
+
tested without a test runner in the loop.
|
|
8
|
+
|
|
9
|
+
The design decision it implements is that this package ships **no count
|
|
10
|
+
assertion**. ``django_assert_num_queries`` is the assertion -- it is typed, it
|
|
11
|
+
already handles ``connection=`` and ``using=`` and a custom failure note, and it
|
|
12
|
+
yields the captured queries. Wrapping it in a fixture of our own would
|
|
13
|
+
re-implement all of that to add a paragraph. So the seam is: their assertion,
|
|
14
|
+
our diagnosis, joined by a report section rather than by a wrapper.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import warnings
|
|
20
|
+
from collections.abc import Generator
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import pytest
|
|
24
|
+
from django.conf import settings
|
|
25
|
+
from pluggy import Result
|
|
26
|
+
|
|
27
|
+
from django_query_contract.format_capture_report import format_capture_report
|
|
28
|
+
from django_query_contract.query_capture import QueryCapture
|
|
29
|
+
from django_query_contract.query_log_ceiling_warning import QueryLogCeilingWarning
|
|
30
|
+
|
|
31
|
+
_CAPTURE_KEY = pytest.StashKey[QueryCapture]()
|
|
32
|
+
|
|
33
|
+
# What pytest prints above the block, as ``---- django-query-contract ----``.
|
|
34
|
+
_SECTION_NAME = "django-query-contract"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
38
|
+
"""Register the on/off switch and the stack-depth knob."""
|
|
39
|
+
parser.addini(
|
|
40
|
+
"query_contract",
|
|
41
|
+
"Capture queries around every test, so a failed query-count assertion "
|
|
42
|
+
"can be diagnosed and a block above Django's query-log ceiling can be reported.",
|
|
43
|
+
type="bool",
|
|
44
|
+
default=True,
|
|
45
|
+
)
|
|
46
|
+
# A string rather than pytest's ``int`` type, which only exists from 8.4.
|
|
47
|
+
parser.addini(
|
|
48
|
+
"query_contract_stack_depth",
|
|
49
|
+
"How many call-stack frames to keep per captured query.",
|
|
50
|
+
default="25",
|
|
51
|
+
)
|
|
52
|
+
parser.getgroup("django-query-contract").addoption(
|
|
53
|
+
"--no-query-contract",
|
|
54
|
+
action="store_true",
|
|
55
|
+
default=False,
|
|
56
|
+
help="Turn capture off for this run without editing the ini file.",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
61
|
+
def pytest_runtest_call(item: pytest.Item) -> Generator[None, Result[Any], None]:
|
|
62
|
+
"""Capture around the test body, and say so when the block outran the log.
|
|
63
|
+
|
|
64
|
+
Around the *call* phase only. The assertion this diagnoses is written inside
|
|
65
|
+
the test, and widening capture to setup would fold every fixture's queries
|
|
66
|
+
into the diagnosis of a block that did not run them.
|
|
67
|
+
|
|
68
|
+
The ceiling is reported as a warning rather than a report section because
|
|
69
|
+
the case that matters is a test that **passed**: past the ceiling
|
|
70
|
+
``django_assert_max_num_queries`` can be handed a zero for a block that ran
|
|
71
|
+
five queries, and a section on a passing test is only printed when somebody
|
|
72
|
+
already suspected something.
|
|
73
|
+
"""
|
|
74
|
+
if not _enabled(item.config) or not settings.configured:
|
|
75
|
+
# Installed in a suite with no Django settings, or switched off. Both
|
|
76
|
+
# are ordinary, and neither is this plugin's business to complain about.
|
|
77
|
+
yield
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
capture = QueryCapture(stack_depth=_stack_depth(item.config))
|
|
81
|
+
item.stash[_CAPTURE_KEY] = capture
|
|
82
|
+
with capture:
|
|
83
|
+
yield
|
|
84
|
+
for ceiling in capture.exceeded_ceilings:
|
|
85
|
+
warnings.warn(
|
|
86
|
+
f"{item.nodeid}: {ceiling.executions} statements ran on connection "
|
|
87
|
+
f"'{ceiling.alias}', but connection.queries_log holds at most {ceiling.limit} "
|
|
88
|
+
f"and already held {ceiling.log_length_at_enter}. A query count taken from it "
|
|
89
|
+
f"-- which is what assertNumQueries and django_assert_num_queries both do -- "
|
|
90
|
+
f"reports {ceiling.visible} for this block.",
|
|
91
|
+
QueryLogCeilingWarning,
|
|
92
|
+
stacklevel=1,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
97
|
+
def pytest_runtest_makereport(
|
|
98
|
+
item: pytest.Item, call: pytest.CallInfo[None]
|
|
99
|
+
) -> Generator[None, Result[Any], None]:
|
|
100
|
+
"""Attach the diagnosis under a failed test.
|
|
101
|
+
|
|
102
|
+
``_assert_num_queries`` builds its message inline and calls ``pytest.fail()``,
|
|
103
|
+
so there is no hook inside it to enrich. A report section reaches the same
|
|
104
|
+
place -- underneath the stock failure -- without owning the failure, which
|
|
105
|
+
means the user's assertion keeps working unchanged whether this package is
|
|
106
|
+
installed or not.
|
|
107
|
+
"""
|
|
108
|
+
outcome = yield
|
|
109
|
+
report = outcome.get_result()
|
|
110
|
+
if report.when != "call" or not report.failed:
|
|
111
|
+
return
|
|
112
|
+
capture = item.stash.get(_CAPTURE_KEY, None)
|
|
113
|
+
if capture is None:
|
|
114
|
+
return
|
|
115
|
+
text = format_capture_report(capture)
|
|
116
|
+
if text:
|
|
117
|
+
report.sections.append((_SECTION_NAME, text))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _enabled(config: pytest.Config) -> bool:
|
|
121
|
+
"""Whether to capture at all: the ini says normally, the flag says today."""
|
|
122
|
+
if config.getoption("--no-query-contract"):
|
|
123
|
+
return False
|
|
124
|
+
return bool(config.getini("query_contract"))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _stack_depth(config: pytest.Config) -> int:
|
|
128
|
+
"""The configured frame budget per query."""
|
|
129
|
+
return int(config.getini("query_contract_stack_depth"))
|
|
File without changes
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""The capture engine: every execution, with its fingerprint and its call site."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Iterator
|
|
6
|
+
from contextlib import ExitStack
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from django.db import connections
|
|
10
|
+
|
|
11
|
+
from django_query_contract.capture_stack import capture_stack
|
|
12
|
+
from django_query_contract.log_ceiling import LogCeiling
|
|
13
|
+
from django_query_contract.normalise_sql import normalise_sql
|
|
14
|
+
from django_query_contract.query_record import QueryRecord
|
|
15
|
+
|
|
16
|
+
# Measured rather than guessed: six frames separate ``cursor.execute`` from the
|
|
17
|
+
# line that iterated a queryset, so twenty-five leaves nineteen for whatever
|
|
18
|
+
# application code sits above it -- a view, a service, a serializer -- and still
|
|
19
|
+
# reaches the call site. The frames beyond that, under pytest, are the runner's
|
|
20
|
+
# own and identical for every query in the suite. Depth is the one part of
|
|
21
|
+
# capture whose cost scales, at roughly half a microsecond per frame, so the
|
|
22
|
+
# default is the smallest number that keeps what a reader can act on.
|
|
23
|
+
_DEFAULT_STACK_DEPTH = 25
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class QueryCapture:
|
|
27
|
+
"""Record every statement executed inside a block, on one or more connections.
|
|
28
|
+
|
|
29
|
+
Capture rides on ``connection.execute_wrapper()``, which is Django's
|
|
30
|
+
documented hook and is what makes this compose rather than compete.
|
|
31
|
+
``django_assert_num_queries`` counts through ``CaptureQueriesContext``, which
|
|
32
|
+
works by flipping ``force_debug_cursor`` and reading ``queries_log``; a
|
|
33
|
+
wrapper is a separate list on the connection and touches neither. So this
|
|
34
|
+
nests inside or around that assertion without changing what it sees --
|
|
35
|
+
a fact about the two mechanisms rather than a hope about them.
|
|
36
|
+
|
|
37
|
+
It also counts every execution, where ``queries_log`` is a bounded deque.
|
|
38
|
+
See ``LogCeiling`` for what that costs the other path and how this reports it.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
with QueryCapture() as capture:
|
|
42
|
+
list(Author.objects.all())
|
|
43
|
+
|
|
44
|
+
for fingerprint, records in capture.by_fingerprint().items():
|
|
45
|
+
print(len(records), fingerprint, records[0].call_site)
|
|
46
|
+
```
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
using: str | Iterable[str] | None = None,
|
|
53
|
+
stack_depth: int = _DEFAULT_STACK_DEPTH,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""
|
|
56
|
+
Args:
|
|
57
|
+
using: A connection alias, several aliases, or ``None`` for every
|
|
58
|
+
configured one. Every alias by default because the assertion
|
|
59
|
+
this diagnoses takes a ``using=`` of its own, and a diagnosis
|
|
60
|
+
that covered only ``default`` would go quiet on exactly the
|
|
61
|
+
multi-database test that most needs it.
|
|
62
|
+
stack_depth: How many frames to keep per query. Lower it if a suite
|
|
63
|
+
feels the cost; the walk is the only part of capture that is not
|
|
64
|
+
a handful of string operations.
|
|
65
|
+
"""
|
|
66
|
+
if using is None:
|
|
67
|
+
self._using: tuple[str, ...] | None = None
|
|
68
|
+
elif isinstance(using, str):
|
|
69
|
+
self._using = (using,)
|
|
70
|
+
else:
|
|
71
|
+
self._using = tuple(using)
|
|
72
|
+
self._stack_depth = stack_depth
|
|
73
|
+
self._records: list[QueryRecord] = []
|
|
74
|
+
self._ceilings: tuple[LogCeiling, ...] = ()
|
|
75
|
+
self._entered: tuple[tuple[str, int, int | None], ...] = ()
|
|
76
|
+
self._wrappers = ExitStack()
|
|
77
|
+
|
|
78
|
+
def __enter__(self) -> QueryCapture:
|
|
79
|
+
# Reset rather than accumulate, so re-entering one instance measures one
|
|
80
|
+
# block. A capture that silently summed two blocks would be the same
|
|
81
|
+
# class of quiet wrongness this package exists to expose.
|
|
82
|
+
self._records = []
|
|
83
|
+
self._ceilings = ()
|
|
84
|
+
aliases = self._using if self._using is not None else tuple(connections)
|
|
85
|
+
entered: list[tuple[str, int, int | None]] = []
|
|
86
|
+
self._wrappers = ExitStack()
|
|
87
|
+
for alias in aliases:
|
|
88
|
+
connection = connections[alias]
|
|
89
|
+
entered.append((alias, len(connection.queries_log), connection.queries_limit))
|
|
90
|
+
self._wrappers.enter_context(connection.execute_wrapper(self._record))
|
|
91
|
+
self._entered = tuple(entered)
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
|
95
|
+
self._wrappers.close()
|
|
96
|
+
self._ceilings = tuple(
|
|
97
|
+
LogCeiling(
|
|
98
|
+
alias=alias,
|
|
99
|
+
limit=limit,
|
|
100
|
+
log_length_at_enter=log_length,
|
|
101
|
+
executions=sum(1 for record in self._records if record.alias == alias),
|
|
102
|
+
)
|
|
103
|
+
for alias, log_length, limit in self._entered
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def _record(self, execute: Any, sql: Any, params: Any, many: bool, context: Any) -> Any:
|
|
107
|
+
"""The ``execute_wrapper`` callable itself.
|
|
108
|
+
|
|
109
|
+
Recording happens before the statement runs and the result is returned
|
|
110
|
+
untouched, so a query that raises is still counted. A failing statement
|
|
111
|
+
is a statement that was executed, and a diagnosis that dropped it would
|
|
112
|
+
under-report the block that most needs explaining.
|
|
113
|
+
"""
|
|
114
|
+
connection = context["connection"]
|
|
115
|
+
stack, truncated = capture_stack(self._stack_depth)
|
|
116
|
+
try:
|
|
117
|
+
param_count = None if params is None else len(params)
|
|
118
|
+
except TypeError:
|
|
119
|
+
# Django accepts an iterator of parameters and handles the same
|
|
120
|
+
# TypeError in its own debug logging. Sizing it here would consume
|
|
121
|
+
# it and the query would run with nothing bound.
|
|
122
|
+
param_count = None
|
|
123
|
+
self._records.append(
|
|
124
|
+
QueryRecord(
|
|
125
|
+
index=len(self._records),
|
|
126
|
+
sql=sql,
|
|
127
|
+
fingerprint=normalise_sql(sql),
|
|
128
|
+
alias=connection.alias,
|
|
129
|
+
vendor=connection.vendor,
|
|
130
|
+
many=many,
|
|
131
|
+
param_count=param_count,
|
|
132
|
+
stack=stack,
|
|
133
|
+
stack_truncated=truncated,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
return execute(sql, params, many, context)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def records(self) -> tuple[QueryRecord, ...]:
|
|
140
|
+
"""Every execution, in order. A snapshot, so it is safe to read mid-block."""
|
|
141
|
+
return tuple(self._records)
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def ceilings(self) -> tuple[LogCeiling, ...]:
|
|
145
|
+
"""One per captured connection, populated on exit."""
|
|
146
|
+
return self._ceilings
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def exceeded_ceilings(self) -> tuple[LogCeiling, ...]:
|
|
150
|
+
"""The connections whose block ran past what Django's query log can hold."""
|
|
151
|
+
return tuple(ceiling for ceiling in self._ceilings if ceiling.exceeded)
|
|
152
|
+
|
|
153
|
+
def by_fingerprint(self) -> dict[str, tuple[QueryRecord, ...]]:
|
|
154
|
+
"""Group the records by fingerprint, in the order each was first seen.
|
|
155
|
+
|
|
156
|
+
The grouping every face of this capture starts from. A group of more
|
|
157
|
+
than one is a repeated statement; whether it is an N+1 also depends on
|
|
158
|
+
the call stack, which the records carry.
|
|
159
|
+
"""
|
|
160
|
+
grouped: dict[str, list[QueryRecord]] = {}
|
|
161
|
+
for record in self._records:
|
|
162
|
+
grouped.setdefault(record.fingerprint, []).append(record)
|
|
163
|
+
return {fingerprint: tuple(records) for fingerprint, records in grouped.items()}
|
|
164
|
+
|
|
165
|
+
def __len__(self) -> int:
|
|
166
|
+
return len(self._records)
|
|
167
|
+
|
|
168
|
+
def __iter__(self) -> Iterator[QueryRecord]:
|
|
169
|
+
return iter(tuple(self._records))
|
|
170
|
+
|
|
171
|
+
def __getitem__(self, index: int) -> QueryRecord:
|
|
172
|
+
return self._records[index]
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def from_capture_context(cls, context: Any) -> QueryCapture:
|
|
176
|
+
"""Build a capture from a ``django.test.utils.CaptureQueriesContext``.
|
|
177
|
+
|
|
178
|
+
This is the object ``django_assert_num_queries`` yields, so a caller
|
|
179
|
+
already holding one can be served without rewriting the test. What comes
|
|
180
|
+
back is honestly degraded, and the gaps are the argument for capturing
|
|
181
|
+
separately rather than a limitation to work around:
|
|
182
|
+
|
|
183
|
+
- **No call stacks.** That context records ``{"sql", "time"}`` per query
|
|
184
|
+
and nothing else, so there is no frame to recover. ``call_site`` is
|
|
185
|
+
``None`` on every record.
|
|
186
|
+
- **No ceiling.** ``ceilings`` is empty, because a count taken from a
|
|
187
|
+
rotated deque cannot report how much it lost -- the dropped entries
|
|
188
|
+
are gone, and their number with them. This is the one thing that
|
|
189
|
+
cannot be reconstructed after the fact at any price.
|
|
190
|
+
- **No parameter counts**, and ``many`` is ``False`` throughout: the SQL
|
|
191
|
+
of an ``executemany`` arrives there already rewritten to
|
|
192
|
+
``"N times: ..."``.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
context: Anything with ``captured_queries`` and ``connection``.
|
|
196
|
+
"""
|
|
197
|
+
capture = cls()
|
|
198
|
+
connection = context.connection
|
|
199
|
+
capture._records = [
|
|
200
|
+
QueryRecord(
|
|
201
|
+
index=index,
|
|
202
|
+
sql=query["sql"],
|
|
203
|
+
fingerprint=normalise_sql(query["sql"]),
|
|
204
|
+
alias=connection.alias,
|
|
205
|
+
vendor=connection.vendor,
|
|
206
|
+
many=False,
|
|
207
|
+
param_count=None,
|
|
208
|
+
)
|
|
209
|
+
for index, query in enumerate(context.captured_queries)
|
|
210
|
+
]
|
|
211
|
+
return capture
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The warning raised when a block outruns Django's query log."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class QueryLogCeilingWarning(UserWarning):
|
|
7
|
+
"""A block executed more statements than ``connection.queries_log`` can hold.
|
|
8
|
+
|
|
9
|
+
A warning rather than a failure, because this package asserts nothing: the
|
|
10
|
+
assertion belongs to ``django_assert_num_queries`` and this only says when
|
|
11
|
+
its arithmetic stopped being reliable. It is a warning rather than a report
|
|
12
|
+
section because the dangerous case is a test that **passed** -- five real
|
|
13
|
+
queries counted as zero -- and a section on a passing test is printed only
|
|
14
|
+
when someone asks for it.
|
|
15
|
+
|
|
16
|
+
Django emits a ``UserWarning`` of its own when the log is full, but it says
|
|
17
|
+
only that the log truncated; it does not say what the count should have been
|
|
18
|
+
or that an assertion just read a wrong number off it.
|
|
19
|
+
"""
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""One database execution, as this package remembers it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
import django
|
|
9
|
+
|
|
10
|
+
from django_query_contract.stack_frame import StackFrame
|
|
11
|
+
|
|
12
|
+
# Where Django itself lives. ``call_site`` walks outwards until it leaves this
|
|
13
|
+
# directory, which is what turns twenty frames of queryset machinery into the
|
|
14
|
+
# one line a reader can act on. Resolved once at import rather than per query.
|
|
15
|
+
_DJANGO_ROOT = os.path.dirname(os.path.abspath(django.__file__)) + os.sep
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class QueryRecord:
|
|
20
|
+
"""A single statement, its fingerprint and the stack that emitted it.
|
|
21
|
+
|
|
22
|
+
This is the artifact the whole package is built around, and it is public and
|
|
23
|
+
documented from the first release on purpose: four separate faces read it --
|
|
24
|
+
the pytest plugin, a CI report, call-site attribution and a runtime budget
|
|
25
|
+
middleware -- and three of them are not written yet. A record kept private
|
|
26
|
+
until they are would grow a private accessor per face instead of a shape.
|
|
27
|
+
|
|
28
|
+
The contract in ``0.x`` is additive: fields may be added, never removed and
|
|
29
|
+
never given a new meaning. It is frozen at ``1.0``.
|
|
30
|
+
|
|
31
|
+
Two things are deliberately absent.
|
|
32
|
+
|
|
33
|
+
**No parameters.** Retaining them would pin every bound value for the length
|
|
34
|
+
of a capture -- a ``bulk_create`` of ten thousand rows arrives here as one
|
|
35
|
+
execution and ten thousand values -- and the runtime middleware face would
|
|
36
|
+
then be holding customer data in memory to answer a question about query
|
|
37
|
+
counts. ``param_count`` is what the diagnosis actually needs ("the ``IN``
|
|
38
|
+
list had five hundred entries"), and the plan face runs ``EXPLAIN`` at
|
|
39
|
+
execution time, where the parameters are still in hand.
|
|
40
|
+
|
|
41
|
+
**No duration.** This package's argument is that a performance assertion
|
|
42
|
+
mentioning a number of milliseconds is a flaky test with extra steps, and a
|
|
43
|
+
field invites the assertion. Timing a query is a profiler's job;
|
|
44
|
+
``django-silk`` does it well and is deliberately out of scope.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
index: int
|
|
48
|
+
"""Position within the capture, counting from zero. Names a query in a report."""
|
|
49
|
+
|
|
50
|
+
sql: str
|
|
51
|
+
"""The statement exactly as handed to the cursor, placeholders and all."""
|
|
52
|
+
|
|
53
|
+
fingerprint: str
|
|
54
|
+
"""``sql`` reduced to the part that repeats. See ``normalise_sql``."""
|
|
55
|
+
|
|
56
|
+
alias: str
|
|
57
|
+
"""The Django connection alias this ran on."""
|
|
58
|
+
|
|
59
|
+
vendor: str
|
|
60
|
+
"""The backend's ``vendor`` string: ``sqlite``, ``postgresql``, ``mysql``, ``oracle``."""
|
|
61
|
+
|
|
62
|
+
many: bool
|
|
63
|
+
"""``True`` when this arrived through ``executemany`` rather than ``execute``."""
|
|
64
|
+
|
|
65
|
+
param_count: int | None
|
|
66
|
+
"""How many bindings were passed -- rows, for ``executemany``.
|
|
67
|
+
|
|
68
|
+
``None`` when the parameters were not sized: ``None`` itself, as every
|
|
69
|
+
transaction control statement passes, or an iterator, which Django permits
|
|
70
|
+
and which cannot be measured without consuming it.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
stack: tuple[StackFrame, ...] = field(default=())
|
|
74
|
+
"""The call stack, outermost first. Empty when reconstructed from a source that had none."""
|
|
75
|
+
|
|
76
|
+
stack_truncated: bool = False
|
|
77
|
+
"""``True`` when the stack was deeper than the capture's limit and outer frames were dropped."""
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def call_site(self) -> StackFrame | None:
|
|
81
|
+
"""The innermost frame outside Django: the line that asked for this query.
|
|
82
|
+
|
|
83
|
+
``None`` when the stack holds no such frame -- an empty stack, or one
|
|
84
|
+
truncated before it reached the caller. That is reported rather than
|
|
85
|
+
approximated with the innermost frame available, because "the query came
|
|
86
|
+
from ``django/db/models/query.py``" is true of every query and tells a
|
|
87
|
+
reader nothing.
|
|
88
|
+
|
|
89
|
+
Django is the only package skipped. Anything else in the stack -- a REST
|
|
90
|
+
framework, a factory library, a service layer -- did emit the query, and
|
|
91
|
+
deciding that some libraries are more interesting than others is the
|
|
92
|
+
kind of tuning this package exists without.
|
|
93
|
+
"""
|
|
94
|
+
for frame in reversed(self.stack):
|
|
95
|
+
if not frame.filename.startswith(_DJANGO_ROOT):
|
|
96
|
+
return frame
|
|
97
|
+
return None
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""One frame of the call stack that emitted a query."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class StackFrame:
|
|
10
|
+
"""Where a query came from, at one level of the call stack.
|
|
11
|
+
|
|
12
|
+
Deliberately three plain strings and an integer rather than a reference to a
|
|
13
|
+
live frame object: a captured stack outlives the call it describes, and
|
|
14
|
+
holding frames would keep every local variable of every ORM call alive for
|
|
15
|
+
the length of a test. It would also make the record unpickleable, which the
|
|
16
|
+
CI-report face needs it not to be.
|
|
17
|
+
|
|
18
|
+
The source line is not read either. ``traceback.extract_stack`` opens and
|
|
19
|
+
caches the file for every frame it formats, and this runs once per query in
|
|
20
|
+
a suite that may execute hundreds of thousands of them.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
filename: str
|
|
24
|
+
lineno: int
|
|
25
|
+
function: str
|
|
26
|
+
|
|
27
|
+
def __str__(self) -> str:
|
|
28
|
+
"""``path/to/file.py:31 in test_books`` -- one frame, one line."""
|
|
29
|
+
return f"{self.filename}:{self.lineno} in {self.function}"
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: django-query-contract
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A query-capture engine for Django, and a pytest plugin over it: normalised SQL fingerprints, call sites, and an honest report above Django's query-log ceiling.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Artui/django-query-contract
|
|
6
|
+
Project-URL: Repository, https://github.com/Artui/django-query-contract
|
|
7
|
+
Project-URL: Issues, https://github.com/Artui/django-query-contract/issues
|
|
8
|
+
Author-email: Artur Veres <artur8118@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: django,n-plus-one,performance,pytest,query-count,testing
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Web Environment
|
|
14
|
+
Classifier: Framework :: Django
|
|
15
|
+
Classifier: Framework :: Django :: 4.2
|
|
16
|
+
Classifier: Framework :: Django :: 5.0
|
|
17
|
+
Classifier: Framework :: Django :: 5.1
|
|
18
|
+
Classifier: Framework :: Django :: 5.2
|
|
19
|
+
Classifier: Framework :: Django :: 6.0
|
|
20
|
+
Classifier: Framework :: Django :: 6.1
|
|
21
|
+
Classifier: Framework :: Pytest
|
|
22
|
+
Classifier: Intended Audience :: Developers
|
|
23
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
24
|
+
Classifier: Operating System :: OS Independent
|
|
25
|
+
Classifier: Programming Language :: Python
|
|
26
|
+
Classifier: Programming Language :: Python :: 3
|
|
27
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
28
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
29
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
30
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
32
|
+
Classifier: Topic :: Database
|
|
33
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
34
|
+
Classifier: Topic :: Software Development :: Testing
|
|
35
|
+
Requires-Python: >=3.10
|
|
36
|
+
Requires-Dist: django>=4.2
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# django-query-contract
|
|
40
|
+
|
|
41
|
+
[](https://github.com/Artui/django-query-contract/actions/workflows/tests.yml)
|
|
42
|
+
[](https://pypi.org/project/django-query-contract/)
|
|
43
|
+
[](https://pypi.org/project/django-query-contract/)
|
|
44
|
+
[](https://pypi.org/project/django-query-contract/)
|
|
45
|
+
[](https://artui.github.io/django-query-contract/)
|
|
46
|
+
[](https://github.com/Artui/django-query-contract/actions/workflows/tests.yml)
|
|
47
|
+
[](https://github.com/astral-sh/ruff)
|
|
48
|
+
[](LICENSE)
|
|
49
|
+
|
|
50
|
+
A query-capture engine for Django, and a pytest plugin over it.
|
|
51
|
+
|
|
52
|
+
Every statement is recorded with a normalised SQL fingerprint and the call stack
|
|
53
|
+
that emitted it, through `connection.execute_wrapper()`. When a query-count
|
|
54
|
+
assertion fails, the failure gains a paragraph saying which shape repeated and
|
|
55
|
+
where it came from. When a block runs past the ceiling on Django's own query
|
|
56
|
+
log, that is reported instead of papered over.
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install django-query-contract
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The pytest plugin loads itself. There is nothing to add to `INSTALLED_APPS`, and
|
|
65
|
+
no fixture to request.
|
|
66
|
+
|
|
67
|
+
## It ships no count assertion, on purpose
|
|
68
|
+
|
|
69
|
+
`django_assert_num_queries` is the assertion. It is typed, it handles
|
|
70
|
+
`connection=` / `using=` and a custom failure note, and it yields the captured
|
|
71
|
+
queries. Keep writing it:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
def test_listing_is_flat(django_assert_num_queries, authors):
|
|
75
|
+
with django_assert_num_queries(2):
|
|
76
|
+
render_author_list()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
When it fails, the failure now carries a diagnosis underneath it:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
------------------------------ django-query-contract ------------------------------
|
|
83
|
+
13 statements captured: 13 on 'default'.
|
|
84
|
+
|
|
85
|
+
Repeated statement shapes:
|
|
86
|
+
11 x #1, #2, #3, #4, #5, #6, #7, #8, ...
|
|
87
|
+
SELECT "shop_book"."id", "shop_book"."author_id", "shop_book"."title" FROM "shop_book" WHERE "shop_book"."author_id" = %s
|
|
88
|
+
from shop/views.py:16 in render_author_list
|
|
89
|
+
2 shape(s) ran once.
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## The ceiling nobody mentions
|
|
93
|
+
|
|
94
|
+
`assertNumQueries` and `django_assert_num_queries` both count through
|
|
95
|
+
`CaptureQueriesContext`, which slices `connection.queries` between two absolute
|
|
96
|
+
indices. That log is a `deque(maxlen=connection.queries_limit)` -- 9000 by
|
|
97
|
+
default -- so once it rotates the indices no longer point at what they did.
|
|
98
|
+
Measured against Django 6.1:
|
|
99
|
+
|
|
100
|
+
| Already in the log | Queries in the block | Reported |
|
|
101
|
+
| --- | --- | --- |
|
|
102
|
+
| 0 | 8999 | 8999 |
|
|
103
|
+
| 0 | 9001 | 9000 |
|
|
104
|
+
| 8990 | 100 | 10 |
|
|
105
|
+
| 9000 | 5 | **0** |
|
|
106
|
+
|
|
107
|
+
The last row is a passing `django_assert_max_num_queries(1)` around five real
|
|
108
|
+
queries, and the regime it happens in -- thousands of statements in one block --
|
|
109
|
+
is exactly the N+1-at-scale case worth catching.
|
|
110
|
+
|
|
111
|
+
Capture here rides on `execute_wrapper`, which has no bound, so this package
|
|
112
|
+
raises a `QueryLogCeilingWarning` naming the test, the real count and the number
|
|
113
|
+
the assertion was handed.
|
|
114
|
+
|
|
115
|
+
## Reading the capture directly
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from django_query_contract import QueryCapture
|
|
119
|
+
|
|
120
|
+
with QueryCapture() as capture:
|
|
121
|
+
render_author_list()
|
|
122
|
+
|
|
123
|
+
for fingerprint, records in capture.by_fingerprint().items():
|
|
124
|
+
if len(records) > 1:
|
|
125
|
+
print(len(records), records[0].call_site, fingerprint)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
A `QueryRecord` carries the statement, its fingerprint, the connection alias and
|
|
129
|
+
vendor, the parameter count and the call stack. It carries **no parameters** --
|
|
130
|
+
a `bulk_create` is one execution and ten thousand values, and a runtime reader
|
|
131
|
+
of this capture has no business holding them -- and **no duration**, because a
|
|
132
|
+
performance assertion that mentions milliseconds is a flaky test with extra
|
|
133
|
+
steps.
|
|
134
|
+
|
|
135
|
+
`QueryCapture.from_capture_context(...)` builds one from the
|
|
136
|
+
`CaptureQueriesContext` that `django_assert_num_queries` yields. It is honestly
|
|
137
|
+
degraded: no call stacks, no parameter counts, and no ceiling, because a count
|
|
138
|
+
taken from a rotated deque cannot report what it lost.
|
|
139
|
+
|
|
140
|
+
## Where the capture stops
|
|
141
|
+
|
|
142
|
+
`execute_wrapper` wraps Django's cursor wrapper, so it sees `execute` and
|
|
143
|
+
`executemany` and nothing else. A statement issued on the raw driver connection
|
|
144
|
+
is invisible, and so is a driver API that is neither -- psycopg 3's
|
|
145
|
+
`cursor.copy()`, for instance. Django's own query log has the same blind spot,
|
|
146
|
+
so the two agree, and there is a test that pins it rather than a note that
|
|
147
|
+
assumes it.
|
|
148
|
+
|
|
149
|
+
## Turning it off
|
|
150
|
+
|
|
151
|
+
```ini
|
|
152
|
+
[pytest]
|
|
153
|
+
query_contract = false
|
|
154
|
+
query_contract_stack_depth = 25
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
or `--no-query-contract` for one run.
|
|
158
|
+
|
|
159
|
+
## Status
|
|
160
|
+
|
|
161
|
+
Early. The capture engine and the pytest diagnosis. N+1 by
|
|
162
|
+
(call stack, fingerprint), the growth assertion, call-site attribution and plan
|
|
163
|
+
capture come next.
|
|
164
|
+
|
|
165
|
+
Full documentation: <https://artui.github.io/django-query-contract/>
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
django_query_contract/__init__.py,sha256=SYSRrQ-Vc1LfVCzhHgnzXixxWiWpls0gT5Gk74rgw4s,854
|
|
2
|
+
django_query_contract/capture_stack.py,sha256=K4Nz23Z_WOsfmcs1_shfvXs54KO08eKLxDLFz49SEh4,2618
|
|
3
|
+
django_query_contract/format_capture_report.py,sha256=BrzP2mdlg6MBpQjNd6MJj9DeSWuvp-7XnORe10Ops6c,5381
|
|
4
|
+
django_query_contract/log_ceiling.py,sha256=vUhVKdiQwzCYob7K00CoQePjrsRScPzdFfC8OO0Swcs,2834
|
|
5
|
+
django_query_contract/normalise_sql.py,sha256=8QRxdrphLqu6IjpYSNQ3aoNipzd_C4guoga9vVP28Hs,5009
|
|
6
|
+
django_query_contract/plugin.py,sha256=yWrgxX45hL_cS5LO9tt8JNdJYxXTF5cg4cu2o46L5NQ,5281
|
|
7
|
+
django_query_contract/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
django_query_contract/query_capture.py,sha256=4KZ2IzquCyKHg4RWNMW0oRDynGEi2PTw7WJkw59CmR8,9107
|
|
9
|
+
django_query_contract/query_log_ceiling_warning.py,sha256=Mqfp0OqbsrgyHoR66ans-B2JU5kg3UEiZbGcuNjPAP4,883
|
|
10
|
+
django_query_contract/query_record.py,sha256=m-XYekmuZawk0pegH7URRQ_xnFGwKv99cjSDC2ud_N4,4186
|
|
11
|
+
django_query_contract/stack_frame.py,sha256=BOvktfJlvzY9Lg2u8rpg2Pe_PHn8ji889NfXwQs_LMU,1056
|
|
12
|
+
django_query_contract/version.py,sha256=IGt2-6r71nTeUfPEK_x1lNeMEBrz0uYGAfUvfX0lTVs,152
|
|
13
|
+
django_query_contract-0.1.0.dist-info/METADATA,sha256=zKn_v9y5tqEX8tH0HS_-rqbV7RteIpuDvCkbTn8ptow,6997
|
|
14
|
+
django_query_contract-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
15
|
+
django_query_contract-0.1.0.dist-info/entry_points.txt,sha256=HNBzVPUMK5O_qcJ8aJVke5EaP8EzozGXTFlLuluP3vA,64
|
|
16
|
+
django_query_contract-0.1.0.dist-info/licenses/LICENSE,sha256=0rCDnUPa87Fm67SxYMrdqEdLV6UiMENH6NCPLZ_KT5M,1068
|
|
17
|
+
django_query_contract-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Artur Veres
|
|
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.
|