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.
@@ -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,7 @@
1
+ """The single source of truth for this package's version."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__: str = "0.1.0"
6
+
7
+ __all__ = ["__version__"]
@@ -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
+ [![CI](https://github.com/Artui/django-query-contract/workflows/tests/badge.svg)](https://github.com/Artui/django-query-contract/actions/workflows/tests.yml)
42
+ [![PyPI](https://img.shields.io/pypi/v/django-query-contract.svg)](https://pypi.org/project/django-query-contract/)
43
+ [![Python versions](https://img.shields.io/pypi/pyversions/django-query-contract.svg)](https://pypi.org/project/django-query-contract/)
44
+ [![Django versions](https://img.shields.io/pypi/djversions/django-query-contract.svg)](https://pypi.org/project/django-query-contract/)
45
+ [![Docs](https://img.shields.io/badge/docs-artui.github.io-blue.svg)](https://artui.github.io/django-query-contract/)
46
+ [![Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Artui/django-query-contract/gh-pages/coverage.json)](https://github.com/Artui/django-query-contract/actions/workflows/tests.yml)
47
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
48
+ [![License](https://img.shields.io/pypi/l/django-query-contract.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ django_query_contract = django_query_contract.plugin
@@ -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.