pytest-queryguard 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from .api import QueryBudgetExceeded, max_queries
4
+ from .warnings import QueryGuardInternalWarning, QueryGuardWarning
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = [
9
+ "QueryBudgetExceeded",
10
+ "QueryGuardInternalWarning",
11
+ "QueryGuardWarning",
12
+ "__version__",
13
+ "max_queries",
14
+ ]
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+ from contextlib import AbstractContextManager, contextmanager
5
+
6
+ from .collector import QueryCollector, pop, push
7
+ from .listeners import install
8
+ from .messages import format_budget
9
+
10
+
11
+ class QueryBudgetExceeded(AssertionError):
12
+ """Raised when a block runs more SQL statements than its budget allows."""
13
+
14
+
15
+ @contextmanager
16
+ def _measure(limit: int) -> Iterator[QueryCollector]:
17
+ # The budget failure is raised here; pytest should show the user's with block.
18
+ __tracebackhide__ = True
19
+ install()
20
+ collector = QueryCollector()
21
+ push(collector)
22
+ try:
23
+ yield collector
24
+ finally:
25
+ pop(collector)
26
+ if len(collector.statements) > limit:
27
+ raise QueryBudgetExceeded(format_budget(limit, collector.statements))
28
+
29
+
30
+ def max_queries(limit: int) -> AbstractContextManager[QueryCollector]:
31
+ """Fail when the block runs more than ``limit`` SQL statements."""
32
+ if isinstance(limit, bool) or not isinstance(limit, int) or limit < 0:
33
+ raise ValueError(f"limit must be a non-negative integer, got {limit!r}")
34
+ return _measure(limit)
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ import sysconfig
6
+ from collections.abc import Iterator
7
+ from pathlib import Path
8
+ from types import FrameType
9
+
10
+ from .events import CallSite
11
+
12
+
13
+ def _package_dir(name: str) -> str | None:
14
+ module = sys.modules.get(name)
15
+ file = getattr(module, "__file__", None)
16
+ return str(Path(file).resolve().parent) + os.sep if file else None
17
+
18
+
19
+ def _compute_prefixes() -> tuple[str, ...]:
20
+ paths: list[str | None] = [
21
+ _package_dir("sqlalchemy"),
22
+ _package_dir("pytest"),
23
+ _package_dir("_pytest"),
24
+ _package_dir("pluggy"),
25
+ str(Path(__file__).resolve().parent) + os.sep,
26
+ ]
27
+ for key in ("stdlib", "platstdlib"):
28
+ path = sysconfig.get_paths().get(key)
29
+ if path:
30
+ paths.append(path.rstrip(os.sep) + os.sep)
31
+ paths.append(str(Path(path).resolve()) + os.sep)
32
+ return tuple(dict.fromkeys(os.path.normcase(p) for p in paths if p))
33
+
34
+
35
+ _prefixes: tuple[str, ...] | None = None
36
+
37
+
38
+ def _is_internal(filename: str) -> bool:
39
+ global _prefixes
40
+ if filename.startswith("<"):
41
+ return True
42
+ if _prefixes is None:
43
+ _prefixes = _compute_prefixes()
44
+ if os.path.normcase(filename).startswith(_prefixes):
45
+ return True
46
+ parts = Path(filename).parts
47
+ return "site-packages" in parts or "dist-packages" in parts
48
+
49
+
50
+ def _parent_greenlet_frames() -> Iterator[FrameType]:
51
+ """Frames of the greenlets above the current one, innermost first.
52
+
53
+ SQLAlchemy runs the sync code behind an async session in a child greenlet,
54
+ so the awaiting user code is only reachable through the parent greenlet.
55
+ """
56
+ # greenlet comes with SQLAlchemy's async support; only used if already loaded.
57
+ # Code running inside a SQLAlchemy greenlet has imported it already, while
58
+ # an import here, after pytester purges sys.modules between runs, could
59
+ # create a second greenlet module object next to the one SQLAlchemy uses.
60
+ module = sys.modules.get("greenlet")
61
+ if module is None:
62
+ return
63
+ try:
64
+ parent = module.getcurrent().parent
65
+ while parent is not None:
66
+ frame = parent.gr_frame
67
+ if frame is not None:
68
+ yield frame
69
+ parent = parent.parent
70
+ except Exception: # noqa: BLE001 # a plugin bug must never break the user suite
71
+ return
72
+
73
+
74
+ def _first_external(frame: FrameType | None) -> CallSite | None:
75
+ while frame is not None:
76
+ filename = frame.f_code.co_filename
77
+ if not _is_internal(filename):
78
+ return CallSite(filename, frame.f_lineno, frame.f_code.co_name)
79
+ frame = frame.f_back
80
+ return None
81
+
82
+
83
+ def find_callsite() -> CallSite | None:
84
+ """First stack frame outside SQLAlchemy, pytest, stdlib, site-packages and us.
85
+
86
+ When the stack ends inside a greenlet (async sessions), the search goes on
87
+ in the parent greenlets, where the awaiting user code lives.
88
+ """
89
+ site = _first_external(sys._getframe(1))
90
+ if site is not None:
91
+ return site
92
+ for frame in _parent_greenlet_frames():
93
+ site = _first_external(frame)
94
+ if site is not None:
95
+ return site
96
+ return None
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from .events import RelationshipLoadEvent, StatementEvent
6
+
7
+
8
+ @dataclass(slots=True, eq=False)
9
+ class QueryCollector:
10
+ statements: list[StatementEvent] = field(default_factory=list)
11
+ relationship_loads: list[RelationshipLoadEvent] = field(default_factory=list)
12
+
13
+
14
+ # Process-wide on purpose: the code under test may run in another thread.
15
+ _stack: list[QueryCollector] = []
16
+ _generation = 0
17
+
18
+
19
+ def generation() -> int:
20
+ """Changes on every push/pop; lets per-measurement state detect staleness."""
21
+ return _generation
22
+
23
+
24
+ def push(collector: QueryCollector) -> None:
25
+ global _generation
26
+ _generation += 1
27
+ _stack.append(collector)
28
+
29
+
30
+ def pop(collector: QueryCollector) -> None:
31
+ global _generation
32
+ _generation += 1
33
+ for i in range(len(_stack) - 1, -1, -1):
34
+ if _stack[i] is collector:
35
+ del _stack[i]
36
+ break
37
+
38
+
39
+ def active() -> tuple[QueryCollector, ...]:
40
+ return tuple(_stack)
41
+
42
+
43
+ def _reset_for_tests() -> None:
44
+ global _generation
45
+ _generation += 1
46
+ _stack.clear()
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections import Counter
5
+ from dataclasses import dataclass
6
+
7
+ from .collector import QueryCollector
8
+ from .events import CallSite
9
+
10
+ _STRING = re.compile(r"'(?:[^']|'')*'")
11
+ _NUMBER = re.compile(r"(?<![\w.])\d+(?:\.\d+)?(?![\w.])")
12
+ _PLACEHOLDER = r"""\?|%s|%\(\w+\)s|:\w+|\$\d+|\d+(?:\.\d+)?|'(?:[^']|'')*'"""
13
+ # PostgreSQL drivers may cast each bound value: $1::INTEGER, ?::VARCHAR(20)[].
14
+ _CAST = r"(?:\s*::\s*\w+(?:\s*\(\s*\d+(?:\s*,\s*\d+)*\s*\))?(?:\s*\[\])*)?"
15
+ _ITEM = rf"(?:{_PLACEHOLDER}){_CAST}"
16
+ _IN_LIST = re.compile(
17
+ rf"\bIN\s*\(\s*{_ITEM}(?:\s*,\s*{_ITEM})*\s*\)",
18
+ re.IGNORECASE,
19
+ )
20
+ _DOLLAR_PARAM = re.compile(r"\$\d+")
21
+ _WS = re.compile(r"\s+")
22
+ _WRITES = ("INSERT", "UPDATE", "DELETE")
23
+
24
+
25
+ def normalize(sql: str) -> str:
26
+ """Reduce a statement to a shape shared by all its executions."""
27
+ out = _WS.sub(" ", sql).strip()
28
+ # Lists first, so every placeholder style collapses to the same text.
29
+ out = _IN_LIST.sub("IN (...)", out)
30
+ out = _STRING.sub("?", out)
31
+ # Numbered parameters ($1, $2) become "?" so they compare equal.
32
+ out = _DOLLAR_PARAM.sub("?", out)
33
+ return _NUMBER.sub("?", out)
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class RelationshipFinding:
38
+ relationship: str
39
+ count: int
40
+ first: CallSite | None
41
+ sample_sql: str | None
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class RepeatFinding:
46
+ sql: str
47
+ count: int
48
+ first: CallSite | None
49
+
50
+
51
+ Finding = RelationshipFinding | RepeatFinding
52
+
53
+
54
+ def _relationship_findings(
55
+ collector: QueryCollector, threshold: int
56
+ ) -> list[RelationshipFinding]:
57
+ parents: dict[str, set[object]] = {}
58
+ counts: Counter[str] = Counter()
59
+ first: dict[str, CallSite | None] = {}
60
+ for load in collector.relationship_loads:
61
+ if load.parent_key is None:
62
+ continue
63
+ name = load.relationship
64
+ parents.setdefault(name, set()).add(load.parent_key)
65
+ counts[name] += 1
66
+ if first.get(name) is None:
67
+ first[name] = load.callsite
68
+ samples: dict[str, str] = {}
69
+ for stmt in collector.statements:
70
+ if stmt.relationship is not None:
71
+ samples.setdefault(stmt.relationship, stmt.sql)
72
+ found = [
73
+ RelationshipFinding(name, counts[name], first[name], samples.get(name))
74
+ for name, keys in parents.items()
75
+ if len(keys) >= threshold
76
+ ]
77
+ return sorted(found, key=lambda f: -f.count)
78
+
79
+
80
+ def _repeat_findings(collector: QueryCollector, threshold: int) -> list[RepeatFinding]:
81
+ counts: Counter[str] = Counter()
82
+ first: dict[str, CallSite | None] = {}
83
+ for stmt in collector.statements:
84
+ if stmt.relationship is not None:
85
+ continue
86
+ if stmt.sql.lstrip().upper().startswith(_WRITES):
87
+ continue
88
+ key = normalize(stmt.sql)
89
+ counts[key] += 1
90
+ if first.get(key) is None:
91
+ first[key] = stmt.callsite
92
+ found = [
93
+ RepeatFinding(sql, n, first[sql]) for sql, n in counts.items() if n >= threshold
94
+ ]
95
+ return sorted(found, key=lambda f: -f.count)
96
+
97
+
98
+ def detect(
99
+ collector: QueryCollector, *, relationship_threshold: int, repeat_threshold: int
100
+ ) -> list[Finding]:
101
+ findings: list[Finding] = []
102
+ findings.extend(_relationship_findings(collector, relationship_threshold))
103
+ findings.extend(_repeat_findings(collector, repeat_threshold))
104
+ return findings
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class CallSite:
8
+ filename: str
9
+ lineno: int
10
+ function: str
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class StatementEvent:
15
+ sql: str
16
+ callsite: CallSite | None
17
+ relationship: str | None
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class RelationshipLoadEvent:
22
+ relationship: str
23
+ parent_key: object
24
+ callsite: CallSite | None
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from typing import Any
5
+
6
+ from sqlalchemy import event
7
+ from sqlalchemy.engine import Engine
8
+ from sqlalchemy.orm import ORMExecuteState, Session
9
+
10
+ from . import collector
11
+ from .callsite import find_callsite
12
+ from .events import RelationshipLoadEvent, StatementEvent
13
+
14
+ _installed = False
15
+ _error: BaseException | None = None
16
+ _local = threading.local()
17
+
18
+
19
+ def internal_error() -> BaseException | None:
20
+ """The first exception swallowed by a listener, if any."""
21
+ return _error
22
+
23
+
24
+ def _set_pending(name: str | None) -> None:
25
+ _local.pending = None if name is None else (collector.generation(), name)
26
+
27
+
28
+ def _take_pending() -> str | None:
29
+ pending = getattr(_local, "pending", None)
30
+ _local.pending = None
31
+ if pending is not None and pending[0] == collector.generation():
32
+ return str(pending[1])
33
+ return None
34
+
35
+
36
+ def _reset_for_tests() -> None:
37
+ global _error
38
+ _error = None
39
+ _local.pending = None
40
+
41
+
42
+ def _on_orm_execute(state: ORMExecuteState) -> None:
43
+ if _error is not None:
44
+ return
45
+ try:
46
+ targets = collector.active()
47
+ if not targets:
48
+ return
49
+ if not state.is_relationship_load:
50
+ _set_pending(None)
51
+ return
52
+ name = str(state.loader_strategy_path[-1]) # type: ignore[index]
53
+ parent = state.lazy_loaded_from
54
+ key: object = None
55
+ if parent is not None:
56
+ key = parent.identity_key
57
+ if key is None:
58
+ key = id(parent)
59
+ _set_pending(name)
60
+ site = find_callsite()
61
+ load = RelationshipLoadEvent(name, key, site)
62
+ for target in targets:
63
+ target.relationship_loads.append(load)
64
+ except Exception as exc: # noqa: BLE001 # a plugin bug must never break the user suite
65
+ _fail(exc)
66
+
67
+
68
+ def _on_cursor_execute(
69
+ conn: Any,
70
+ cursor: Any,
71
+ statement: str,
72
+ parameters: Any,
73
+ context: Any,
74
+ executemany: bool,
75
+ ) -> None:
76
+ if _error is not None:
77
+ return
78
+ try:
79
+ targets = collector.active()
80
+ if not targets:
81
+ return
82
+ stmt = StatementEvent(statement, find_callsite(), _take_pending())
83
+ for target in targets:
84
+ target.statements.append(stmt)
85
+ except Exception as exc: # noqa: BLE001 # a plugin bug must never break the user suite
86
+ _fail(exc)
87
+
88
+
89
+ def _fail(exc: Exception) -> None:
90
+ global _error
91
+ if _error is None:
92
+ _error = exc
93
+
94
+
95
+ def record_error(exc: Exception) -> None:
96
+ """Disable measurement after a bug outside the listeners (first error wins)."""
97
+ _fail(exc)
98
+
99
+
100
+ def swap_error(new: BaseException | None) -> BaseException | None:
101
+ """Replace the recorded error and return the previous one.
102
+
103
+ The plugin clears it for each session and restores the outer session's
104
+ error afterwards, so in-process nested sessions stay independent.
105
+ """
106
+ global _error
107
+ old, _error = _error, new
108
+ return old
109
+
110
+
111
+ def install() -> None:
112
+ """Register the class-level listeners once per process."""
113
+ global _installed
114
+ if _installed:
115
+ return
116
+ event.listen(Session, "do_orm_execute", _on_orm_execute)
117
+ event.listen(Engine, "before_cursor_execute", _on_cursor_execute)
118
+ _installed = True
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from .detectors import Finding, RelationshipFinding
6
+ from .events import CallSite, StatementEvent
7
+
8
+ _SQL_LIMIT = 200
9
+ _REPEAT_FIX = (
10
+ "fix: fetch all rows in one query (WHERE id IN (...)) instead of in a loop"
11
+ )
12
+
13
+
14
+ def _display_path(filename: str, cwd: Path | None) -> str:
15
+ base = cwd if cwd is not None else Path.cwd()
16
+ path = Path(filename)
17
+ try:
18
+ return path.relative_to(base).as_posix()
19
+ except ValueError:
20
+ return filename
21
+
22
+
23
+ def _location(site: CallSite, cwd: Path | None) -> str:
24
+ return f"{_display_path(site.filename, cwd)}:{site.lineno} in {site.function}"
25
+
26
+
27
+ def _first_at(site: CallSite | None, cwd: Path | None) -> str:
28
+ where = "<unknown location>" if site is None else _location(site, cwd)
29
+ return f" first at {where}"
30
+
31
+
32
+ def _block(finding: Finding, cwd: Path | None) -> list[str]:
33
+ if isinstance(finding, RelationshipFinding):
34
+ parent = finding.relationship.split(".", 1)[0]
35
+ lines = [
36
+ f" {finding.relationship} was lazy-loaded {finding.count} times",
37
+ _first_at(finding.first, cwd),
38
+ ]
39
+ if finding.sample_sql is not None:
40
+ lines.append(f" sql: {_truncate(finding.sample_sql)}")
41
+ return [
42
+ *lines,
43
+ " fix: load it with the parent query, e.g.",
44
+ f" select({parent}).options(selectinload({finding.relationship}))",
45
+ ]
46
+ return [
47
+ f" the same statement ran {finding.count} times",
48
+ _first_at(finding.first, cwd),
49
+ f" {_truncate(finding.sql)}",
50
+ f" {_REPEAT_FIX}",
51
+ ]
52
+
53
+
54
+ def format_findings(findings: list[Finding], *, cwd: Path | None = None) -> str:
55
+ lines = ["queryguard: N+1 detected"]
56
+ for finding in findings:
57
+ lines.append("")
58
+ lines.extend(_block(finding, cwd))
59
+ return "\n".join(lines)
60
+
61
+
62
+ def _truncate(sql: str) -> str:
63
+ sql = " ".join(sql.split())
64
+ if len(sql) > _SQL_LIMIT:
65
+ return sql[:_SQL_LIMIT] + "..."
66
+ return sql
67
+
68
+
69
+ def format_budget(
70
+ limit: int, statements: list[StatementEvent], *, cwd: Path | None = None
71
+ ) -> str:
72
+ lines = [
73
+ f"queryguard: expected at most {limit} queries, got {len(statements)}",
74
+ "",
75
+ ]
76
+ for number, stmt in enumerate(statements, start=1):
77
+ line = f" {number}. {_truncate(stmt.sql)}"
78
+ if stmt.callsite is not None:
79
+ line += f" ({_location(stmt.callsite, cwd)})"
80
+ lines.append(line)
81
+ return "\n".join(lines)
@@ -0,0 +1,290 @@
1
+ from __future__ import annotations
2
+
3
+ import warnings as std_warnings
4
+ from collections.abc import Generator
5
+ from typing import Any
6
+
7
+ import pytest
8
+
9
+ from .collector import QueryCollector, pop, push
10
+ from .detectors import detect
11
+ from .listeners import install, internal_error, record_error, swap_error
12
+ from .messages import format_budget, format_findings
13
+ from .warnings import QueryGuardInternalWarning, QueryGuardWarning
14
+
15
+ _MODES = ("fail", "warn", "off")
16
+ _internal_reported = pytest.StashKey[bool]()
17
+ _outer_error = pytest.StashKey[BaseException | None]()
18
+ _settings = pytest.StashKey["_Settings"]()
19
+ _report = pytest.StashKey[list[tuple[str, int]]]()
20
+ _REPORT_SIZE = 10
21
+
22
+
23
+ class _Settings:
24
+ def __init__(self, mode: str, relationship: int, repeat: int) -> None:
25
+ self.mode = mode
26
+ self.relationship = relationship
27
+ self.repeat = repeat
28
+
29
+
30
+ # pytest 9 reads native [tool.pytest] TOML, where thresholds are real integers
31
+ # and a string-typed option would crash; older versions only know strings.
32
+ _NATIVE_INT = pytest.version_tuple >= (9,)
33
+ _INT_TYPE: dict[str, Any] = {"type": "int"} if _NATIVE_INT else {}
34
+
35
+
36
+ def pytest_addoption(parser: pytest.Parser) -> None:
37
+ group = parser.getgroup("queryguard")
38
+ group.addoption(
39
+ "--queryguard-mode",
40
+ dest="queryguard_mode",
41
+ default=None,
42
+ help="N+1 handling: fail, warn or off (overrides queryguard_mode).",
43
+ )
44
+ group.addoption(
45
+ "--queryguard-report",
46
+ dest="queryguard_report",
47
+ action="store_true",
48
+ default=False,
49
+ help="Print the tests with the most queries at the end of the session.",
50
+ )
51
+ parser.addini("queryguard_mode", "fail, warn or off", default="fail")
52
+ parser.addini(
53
+ "queryguard_relationship_threshold",
54
+ "Lazy loads of one relationship treated as N+1 (integer >= 2)",
55
+ default=2 if _NATIVE_INT else "2",
56
+ **_INT_TYPE,
57
+ )
58
+ parser.addini(
59
+ "queryguard_repeat_threshold",
60
+ "Executions of one statement treated as N+1 (integer >= 2)",
61
+ default=5 if _NATIVE_INT else "5",
62
+ **_INT_TYPE,
63
+ )
64
+
65
+
66
+ def _threshold(config: pytest.Config, name: str) -> int:
67
+ detail = ""
68
+ try:
69
+ raw: Any = config.getini(name)
70
+ except (TypeError, ValueError) as exc:
71
+ raw, detail = None, f": {exc}"
72
+ try:
73
+ value = 0 if isinstance(raw, bool) else int(raw)
74
+ except (TypeError, ValueError):
75
+ value = 0
76
+ if value < 2:
77
+ shown = detail or f", got {raw!r}"
78
+ raise pytest.UsageError(f"{name} must be an integer >= 2{shown}")
79
+ return value
80
+
81
+
82
+ def _read_settings(config: pytest.Config) -> _Settings:
83
+ cli_mode = config.getoption("queryguard_mode")
84
+ if cli_mode is not None:
85
+ mode, source = cli_mode, "--queryguard-mode"
86
+ else:
87
+ source = "queryguard_mode"
88
+ try:
89
+ mode = config.getini(source)
90
+ except (TypeError, ValueError) as exc:
91
+ # pytest 9 rejects a non-string value from native TOML on its own.
92
+ raise pytest.UsageError(
93
+ f"{source} must be one of {', '.join(_MODES)}: {exc}"
94
+ ) from None
95
+ if mode not in _MODES:
96
+ raise pytest.UsageError(
97
+ f"{source} must be one of {', '.join(_MODES)}, got {mode!r}"
98
+ )
99
+ return _Settings(
100
+ mode,
101
+ _threshold(config, "queryguard_relationship_threshold"),
102
+ _threshold(config, "queryguard_repeat_threshold"),
103
+ )
104
+
105
+
106
+ def pytest_configure(config: pytest.Config) -> None:
107
+ config.addinivalue_line(
108
+ "markers",
109
+ "max_queries(n): fail the test if it runs more than n SQL statements.",
110
+ )
111
+ config.addinivalue_line(
112
+ "markers",
113
+ "allow_nplusone: do not report N+1 query findings for this test.",
114
+ )
115
+ config.stash[_outer_error] = swap_error(None)
116
+ settings = _read_settings(config)
117
+ config.stash[_settings] = settings
118
+ if config.getoption("queryguard_report"):
119
+ config.stash[_report] = []
120
+ if settings.mode != "off":
121
+ install()
122
+
123
+
124
+ def pytest_unconfigure(config: pytest.Config) -> None:
125
+ swap_error(config.stash.get(_outer_error, None))
126
+
127
+
128
+ def _report_internal_error(config: pytest.Config) -> None:
129
+ error = internal_error()
130
+ if error is None or config.stash.get(_internal_reported, False):
131
+ return
132
+ config.stash[_internal_reported] = True
133
+ std_warnings.warn(
134
+ QueryGuardInternalWarning(
135
+ f"pytest-queryguard disabled itself after an internal error: {error!r}"
136
+ ),
137
+ stacklevel=1,
138
+ )
139
+
140
+
141
+ def _analyse(
142
+ item: pytest.Item, collector: QueryCollector, settings: _Settings
143
+ ) -> str | None:
144
+ """The report for the finished test, or None; never raises."""
145
+ if internal_error() is not None or item.get_closest_marker("allow_nplusone"):
146
+ return None
147
+ try:
148
+ findings = detect(
149
+ collector,
150
+ relationship_threshold=settings.relationship,
151
+ repeat_threshold=settings.repeat,
152
+ )
153
+ if not findings:
154
+ return None
155
+ return format_findings(findings, cwd=item.config.rootpath)
156
+ except Exception as exc: # noqa: BLE001 # a plugin bug must never break the user suite
157
+ record_error(exc)
158
+ return None
159
+
160
+
161
+ def _budget(item: pytest.Item) -> int | None:
162
+ marker = item.get_closest_marker("max_queries")
163
+ if marker is None:
164
+ return None
165
+ if len(marker.args) > 1 or set(marker.kwargs) - {"n"}:
166
+ pytest.fail(
167
+ f"max_queries on {item.nodeid} takes a single argument, the limit",
168
+ pytrace=False,
169
+ )
170
+ if marker.args and "n" in marker.kwargs:
171
+ pytest.fail(
172
+ f"max_queries on {item.nodeid} got the limit twice, positionally and as n=",
173
+ pytrace=False,
174
+ )
175
+ limit = marker.args[0] if marker.args else marker.kwargs.get("n")
176
+ if isinstance(limit, bool) or not isinstance(limit, int) or limit < 0:
177
+ pytest.fail(
178
+ f"max_queries on {item.nodeid} needs a non-negative integer "
179
+ f"as its limit, got {limit!r}",
180
+ pytrace=False,
181
+ )
182
+ return limit
183
+
184
+
185
+ def _budget_report(
186
+ item: pytest.Item, collector: QueryCollector, limit: int
187
+ ) -> str | None:
188
+ """The budget failure text, or None; a bug here never fails the test."""
189
+ try:
190
+ if len(collector.statements) <= limit:
191
+ return None
192
+ return format_budget(limit, collector.statements, cwd=item.config.rootpath)
193
+ except Exception as exc: # noqa: BLE001 # a plugin bug must never break the user suite
194
+ record_error(exc)
195
+ return None
196
+
197
+
198
+ def _record(item: pytest.Item, collector: QueryCollector, settings: _Settings) -> None:
199
+ """Remember the test's statement count for the session report."""
200
+ counts = item.config.stash.get(_report, None)
201
+ # After an internal error the listeners stop recording, so a count would be wrong.
202
+ if counts is None or settings.mode == "off" or internal_error() is not None:
203
+ return
204
+ counts.append((item.nodeid, len(collector.statements)))
205
+
206
+
207
+ def _write_report(
208
+ terminalreporter: pytest.TerminalReporter, settings: _Settings
209
+ ) -> None:
210
+ terminalreporter.write_sep("=", "queryguard report")
211
+ # dsession is registered only on the xdist controller when it distributes
212
+ # tests; the counts live in the workers, so the report would be empty.
213
+ if terminalreporter.config.pluginmanager.has_plugin("dsession"):
214
+ terminalreporter.write_line(
215
+ "queryguard report: not available with pytest-xdist"
216
+ )
217
+ return
218
+ if settings.mode == "off":
219
+ terminalreporter.write_line(
220
+ "queryguard report: measurement is off (queryguard_mode = off)"
221
+ )
222
+ return
223
+ if internal_error() is not None:
224
+ terminalreporter.write_line(
225
+ "queryguard report: incomplete, measurement stopped after an internal error"
226
+ )
227
+ counts = terminalreporter.config.stash.get(_report, [])
228
+ if not counts:
229
+ terminalreporter.write_line("no tests were measured")
230
+ return
231
+ # sorted() is stable, so tests with equal counts keep the order they ran in.
232
+ heaviest = sorted(counts, key=lambda entry: entry[1], reverse=True)
233
+ for nodeid, count in heaviest[:_REPORT_SIZE]:
234
+ terminalreporter.write_line(f"{count:>5} {nodeid}")
235
+
236
+
237
+ def pytest_terminal_summary(terminalreporter: pytest.TerminalReporter) -> None:
238
+ config = terminalreporter.config
239
+ settings = config.stash.get(_settings, None)
240
+ if settings is None or not config.getoption("queryguard_report"):
241
+ return
242
+ try:
243
+ _write_report(terminalreporter, settings)
244
+ except Exception as exc: # noqa: BLE001 # a plugin bug must never break the user suite
245
+ record_error(exc)
246
+ _report_internal_error(config)
247
+
248
+
249
+ @pytest.hookimpl(wrapper=True)
250
+ def pytest_runtest_call(item: pytest.Item) -> Generator[None, Any, Any]:
251
+ settings = item.config.stash.get(_settings, None)
252
+ if settings is None:
253
+ # pytest_configure did not complete (for example it raised a UsageError
254
+ # for a bad option), so there is nothing to measure with.
255
+ return (yield)
256
+ limit = _budget(item)
257
+ if settings.mode == "off" and limit is None:
258
+ return (yield)
259
+ if limit is not None:
260
+ install() # idempotent; budgets also work when the mode is off
261
+ collector = QueryCollector()
262
+ push(collector)
263
+ try:
264
+ result = yield
265
+ finally:
266
+ pop(collector)
267
+ _report_internal_error(item.config)
268
+ _record(item, collector, settings) # failing tests are reported too
269
+ parts: list[str] = []
270
+ if limit is not None:
271
+ budget = _budget_report(item, collector, limit)
272
+ if budget is not None:
273
+ parts.append(budget)
274
+ message = None
275
+ if settings.mode != "off":
276
+ message = _analyse(item, collector, settings)
277
+ _report_internal_error(item.config)
278
+ if message is not None and (parts or settings.mode == "fail"):
279
+ parts.append(message) # one failure: budget first, N+1 after it
280
+ elif message is not None:
281
+ path, line, _ = item.reportinfo()
282
+ std_warnings.warn_explicit(
283
+ QueryGuardWarning(message),
284
+ QueryGuardWarning,
285
+ str(path),
286
+ (line or 0) + 1,
287
+ )
288
+ if parts:
289
+ pytest.fail("\n\n".join(parts), pytrace=False)
290
+ return result
File without changes
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class QueryGuardWarning(UserWarning):
5
+ """An N+1 query pattern found while running in ``warn`` mode."""
6
+
7
+
8
+ class QueryGuardInternalWarning(UserWarning):
9
+ """A bug inside pytest-queryguard; the plugin stops measuring."""
@@ -0,0 +1,451 @@
1
+ Metadata-Version: 2.5
2
+ Name: pytest-queryguard
3
+ Version: 0.1.0
4
+ Summary: Detect N+1 queries and enforce query budgets for SQLAlchemy 2 in pytest.
5
+ Project-URL: Homepage, https://github.com/MaciejZiel/pytest-queryguard
6
+ Project-URL: Repository, https://github.com/MaciejZiel/pytest-queryguard
7
+ Project-URL: Changelog, https://github.com/MaciejZiel/pytest-queryguard/blob/main/CHANGELOG.md
8
+ Project-URL: Issues, https://github.com/MaciejZiel/pytest-queryguard/issues
9
+ Author: Maciej Zieliński
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: fastapi,n+1,pytest,sqlalchemy,testing
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Framework :: Pytest
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Software Development :: Testing
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.10
27
+ Requires-Dist: pytest>=8
28
+ Requires-Dist: sqlalchemy>=2.0
29
+ Description-Content-Type: text/markdown
30
+
31
+ # pytest-queryguard
32
+
33
+ [![CI](https://github.com/MaciejZiel/pytest-queryguard/actions/workflows/ci.yml/badge.svg)](https://github.com/MaciejZiel/pytest-queryguard/actions/workflows/ci.yml)
34
+
35
+ A pytest plugin that fails tests with N+1 queries and enforces query budgets
36
+ for SQLAlchemy 2, in sync, async and FastAPI code.
37
+
38
+ ## Why
39
+
40
+ ORM code that looks correct can send one query per row instead of one query
41
+ per page:
42
+
43
+ ```python
44
+ orders = session.scalars(select(Order)).all() # 1 query
45
+ for order in orders:
46
+ print(order.customer.name) # +1 query per order
47
+ ```
48
+
49
+ The tests pass. A test database holds a handful of rows, so 5 extra queries
50
+ cost nothing. In production the same endpoint loads 500 orders, sends 501
51
+ queries and gets slow. Nothing in the test run warned you.
52
+
53
+ pytest-queryguard watches the SQL each test sends. When it sees the N+1
54
+ pattern, it fails the test and points to the line that caused it, even if
55
+ the test database has only a few rows.
56
+
57
+ ## Before and after
58
+
59
+ The code below is a small example project. All output in this section is
60
+ copied from a real run.
61
+
62
+ `app/orders.py`:
63
+
64
+ ```python
65
+ from sqlalchemy import select
66
+ from sqlalchemy.orm import Session
67
+
68
+ from app.models import Order
69
+
70
+
71
+ def list_orders(session: Session) -> list[dict[str, object]]:
72
+ orders = session.scalars(select(Order)).all()
73
+ return [{"id": order.id, "customer": order.customer.name} for order in orders]
74
+ ```
75
+
76
+ `app/customers.py`:
77
+
78
+ ```python
79
+ from sqlalchemy.orm import Session
80
+
81
+ from app.models import Customer
82
+
83
+
84
+ def fetch_customers(session: Session, ids: list[int]) -> list[Customer]:
85
+ customers = []
86
+ for customer_id in ids:
87
+ customers.append(session.get(Customer, customer_id))
88
+ return customers
89
+
90
+
91
+ def rename_customers(session: Session, names: dict[int, str]) -> None:
92
+ for customer_id, name in names.items():
93
+ session.get(Customer, customer_id).name = name
94
+ session.flush()
95
+ ```
96
+
97
+ `tests/test_orders.py` (the `session` fixture in `tests/conftest.py`
98
+ creates 50 customers with one order each; `tests/__init__.py` exists, so
99
+ pytest puts the project root on `sys.path` and `app` can be imported):
100
+
101
+ ```python
102
+ import pytest
103
+
104
+ from app.customers import fetch_customers, rename_customers
105
+ from app.orders import list_orders
106
+
107
+
108
+ def test_list_orders(session):
109
+ assert len(list_orders(session)) == 50
110
+
111
+
112
+ def test_fetch_customers(session):
113
+ assert len(fetch_customers(session, list(range(1, 51)))) == 50
114
+
115
+
116
+ @pytest.mark.max_queries(2)
117
+ def test_rename_customers(session):
118
+ rename_customers(session, {1: "Ada", 2: "Grace"})
119
+ ```
120
+
121
+ All three tests pass without the plugin. With it installed,
122
+ `pytest --queryguard-report` gives the output below. It comes from Python
123
+ 3.13 with SQLAlchemy 2.1; Python 3.12 prints the same. Older versions print
124
+ slightly different SQL or function names, for example `in <listcomp>` on
125
+ Python 3.10 and 3.11, or column aliases in the SQL on SQLAlchemy 2.0.
126
+
127
+ ```
128
+ tests/test_orders.py FFF [100%]
129
+
130
+ =================================== FAILURES ===================================
131
+ _______________________________ test_list_orders _______________________________
132
+ queryguard: N+1 detected
133
+
134
+ Order.customer was lazy-loaded 50 times
135
+ first at app/orders.py:9 in list_orders
136
+ sql: SELECT customer.id, customer.name FROM customer WHERE customer.id = ?
137
+ fix: load it with the parent query, e.g.
138
+ select(Order).options(selectinload(Order.customer))
139
+ _____________________________ test_fetch_customers _____________________________
140
+ queryguard: N+1 detected
141
+
142
+ the same statement ran 50 times
143
+ first at app/customers.py:9 in fetch_customers
144
+ SELECT customer.id, customer.name FROM customer WHERE customer.id = ?
145
+ fix: fetch all rows in one query (WHERE id IN (...)) instead of in a loop
146
+ ____________________________ test_rename_customers _____________________________
147
+ queryguard: expected at most 2 queries, got 4
148
+
149
+ 1. SELECT customer.id, customer.name FROM customer WHERE customer.id = ? (app/customers.py:15 in rename_customers)
150
+ 2. UPDATE customer SET name=? WHERE customer.id = ? (app/customers.py:16 in rename_customers)
151
+ 3. SELECT customer.id, customer.name FROM customer WHERE customer.id = ? (app/customers.py:15 in rename_customers)
152
+ 4. UPDATE customer SET name=? WHERE customer.id = ? (app/customers.py:16 in rename_customers)
153
+ ============================== queryguard report ===============================
154
+ 51 tests/test_orders.py::test_list_orders
155
+ 50 tests/test_orders.py::test_fetch_customers
156
+ 4 tests/test_orders.py::test_rename_customers
157
+ =========================== short test summary info ============================
158
+ FAILED tests/test_orders.py::test_list_orders - Failed: queryguard: N+1 detected
159
+ FAILED tests/test_orders.py::test_fetch_customers - Failed: queryguard: N+1 d...
160
+ FAILED tests/test_orders.py::test_rename_customers - Failed: queryguard: expe...
161
+ ============================== 3 failed in 0.07s ===============================
162
+ ```
163
+
164
+ The fixed code loads related rows with the parent query and fetches many
165
+ rows with one `IN` query.
166
+
167
+ `app/orders.py`, fixed:
168
+
169
+ ```python
170
+ from sqlalchemy import select
171
+ from sqlalchemy.orm import Session, selectinload
172
+
173
+ from app.models import Order
174
+
175
+
176
+ def list_orders(session: Session) -> list[dict[str, object]]:
177
+ orders = session.scalars(select(Order).options(selectinload(Order.customer))).all()
178
+ return [{"id": order.id, "customer": order.customer.name} for order in orders]
179
+ ```
180
+
181
+ `app/customers.py`, fixed:
182
+
183
+ ```python
184
+ from collections.abc import Sequence
185
+
186
+ from sqlalchemy import select
187
+ from sqlalchemy.orm import Session
188
+
189
+ from app.models import Customer
190
+
191
+
192
+ def fetch_customers(session: Session, ids: list[int]) -> Sequence[Customer]:
193
+ return session.scalars(select(Customer).where(Customer.id.in_(ids))).all()
194
+
195
+
196
+ def rename_customers(session: Session, names: dict[int, str]) -> None:
197
+ customers = session.scalars(select(Customer).where(Customer.id.in_(names)))
198
+ for customer in customers:
199
+ customer.name = names[customer.id]
200
+ session.flush()
201
+ ```
202
+
203
+ The same tests, unchanged:
204
+
205
+ ```
206
+ tests/test_orders.py ... [100%]
207
+
208
+ ============================== queryguard report ===============================
209
+ 2 tests/test_orders.py::test_list_orders
210
+ 2 tests/test_orders.py::test_rename_customers
211
+ 1 tests/test_orders.py::test_fetch_customers
212
+ ============================== 3 passed in 0.04s ===============================
213
+ ```
214
+
215
+ ## Install
216
+
217
+ ```bash
218
+ pip install pytest-queryguard
219
+ ```
220
+
221
+ It needs Python 3.10 or newer, pytest 8 or newer and SQLAlchemy 2.0 or
222
+ newer. The plugin registers itself with pytest. There is nothing to import
223
+ or configure.
224
+
225
+ ## Usage
226
+
227
+ ### Automatic N+1 detection
228
+
229
+ Every test is checked. A test fails when, inside the test function:
230
+
231
+ - one relationship (for example `Order.customer`) is lazy-loaded for 2 or
232
+ more different parent objects, or
233
+ - one `SELECT`, with its parameters and literals removed, runs 5 or more
234
+ times.
235
+
236
+ Both thresholds can be changed (see [Configuration](#configuration)).
237
+
238
+ ### Query budgets with the `max_queries` marker
239
+
240
+ ```python
241
+ import pytest
242
+
243
+
244
+ @pytest.mark.max_queries(3)
245
+ def test_order_page(client):
246
+ client.get("/orders/1")
247
+
248
+
249
+ @pytest.mark.max_queries(n=3) # the same, with a keyword
250
+ def test_order_page_again(client):
251
+ client.get("/orders/1")
252
+ ```
253
+
254
+ The test fails when its body runs more SQL statements than the limit. The
255
+ failure lists every statement with the line that issued it. Budgets count
256
+ all statements, writes included, and they always fail, whatever
257
+ `queryguard_mode` is set to. If the test already fails on its own, an
258
+ exceeded budget is not reported, because the original failure wins.
259
+
260
+ ### Query budgets with the `max_queries()` context manager
261
+
262
+ ```python
263
+ from pytest_queryguard import max_queries
264
+
265
+
266
+ def test_checkout(service, cart):
267
+ with max_queries(2):
268
+ service.checkout(cart)
269
+ ```
270
+
271
+ Only the statements inside the `with` block count. Above the limit it raises
272
+ `QueryBudgetExceeded`, a subclass of `AssertionError`. The context manager
273
+ does not depend on pytest, so it also works in a script or a benchmark. It
274
+ checks the budget only; N+1 detection is done by the plugin around the
275
+ whole test.
276
+
277
+ ### Opting out
278
+
279
+ ```python
280
+ @pytest.mark.allow_nplusone
281
+ def test_legacy_report(session): ...
282
+ ```
283
+
284
+ The test is not checked for N+1. A `max_queries` marker on the same test is
285
+ still enforced.
286
+
287
+ ### Async and FastAPI
288
+
289
+ Nothing changes for async code. `AsyncSession` and `create_async_engine` run
290
+ on a sync engine underneath, which the plugin already watches. On SQLAlchemy
291
+ 2.1 and newer, async support needs `pip install "sqlalchemy[asyncio]"`,
292
+ which provides greenlet. The failure
293
+ points to the `await` in your code, not to SQLAlchemy internals:
294
+
295
+ ```python
296
+ # app/orders.py; the models use SQLAlchemy's AsyncAttrs mixin.
297
+ async def customer_names(session: AsyncSession) -> list[str]:
298
+ orders = (await session.scalars(select(Order))).all()
299
+ return [(await order.awaitable_attrs.customer).name for order in orders]
300
+ ```
301
+
302
+ ```python
303
+ # tests/test_async.py; main() creates the engine and data, then calls
304
+ # customer_names(session).
305
+ def test_customer_names():
306
+ asyncio.run(main())
307
+ ```
308
+
309
+ This fails with `Order.customer was lazy-loaded 3 times`, first at the line
310
+ with the `await`. pytest cannot run `async def` tests by itself. With
311
+ pytest-asyncio or anyio you can write the test as `async def` instead of
312
+ calling `asyncio.run`.
313
+
314
+ FastAPI's `TestClient` runs the application in another thread. Queries from
315
+ any thread are counted, so an N+1 inside an endpoint fails the test that
316
+ called it:
317
+
318
+ ```python
319
+ def test_orders_endpoint(client):
320
+ assert client.get("/orders").status_code == 200
321
+ ```
322
+
323
+ ### Session report
324
+
325
+ `pytest --queryguard-report` prints, at the end of the run, the 10 tests
326
+ that ran the most queries, failing tests included. See the output in
327
+ [Before and after](#before-and-after).
328
+
329
+ ## Configuration
330
+
331
+ | Option | Values | Default | Meaning |
332
+ |---|---|---|---|
333
+ | `queryguard_mode` | `fail`, `warn`, `off` | `fail` | `fail` fails a test with N+1. `warn` reports it as a `QueryGuardWarning` and lets the test pass. `off` turns detection off; tests without a budget are not measured. |
334
+ | `queryguard_relationship_threshold` | integer ≥ 2 | `2` | Number of different parent objects for which one relationship must be lazy-loaded to count as N+1. |
335
+ | `queryguard_repeat_threshold` | integer ≥ 2 | `5` | Number of times one normalized `SELECT` must run to count as N+1. |
336
+
337
+ Any invalid value stops pytest with a usage error.
338
+
339
+ In `pyproject.toml`:
340
+
341
+ ```toml
342
+ [tool.pytest.ini_options]
343
+ queryguard_mode = "warn"
344
+ queryguard_relationship_threshold = 3
345
+ queryguard_repeat_threshold = 10
346
+ ```
347
+
348
+ With pytest 9 you can use the native TOML table instead:
349
+
350
+ ```toml
351
+ [tool.pytest]
352
+ queryguard_mode = "warn"
353
+ queryguard_relationship_threshold = 3
354
+ queryguard_repeat_threshold = 10
355
+ ```
356
+
357
+ `pytest.ini` and `setup.cfg` work as usual.
358
+
359
+ The warning classes, `pytest_queryguard.QueryGuardWarning` for N+1 in
360
+ `warn` mode and `pytest_queryguard.QueryGuardInternalWarning` for an
361
+ internal error, work with pytest's usual warning filters. For example, to
362
+ turn N+1 warnings back into failures for one job:
363
+
364
+ ```toml
365
+ [tool.pytest.ini_options]
366
+ queryguard_mode = "warn"
367
+ filterwarnings = ["error::pytest_queryguard.QueryGuardWarning"]
368
+ ```
369
+
370
+ Command-line options:
371
+
372
+ | Option | Meaning |
373
+ |---|---|
374
+ | `--queryguard-mode=fail\|warn\|off` | Overrides `queryguard_mode` for this run. |
375
+ | `--queryguard-report` | Prints the 10 tests with the most queries at the end of the session. |
376
+
377
+ ## How it works
378
+
379
+ 1. When pytest starts (unless `queryguard_mode = off`; budgets still install
380
+ the listeners when a test needs them), the plugin adds two class-level
381
+ SQLAlchemy listeners:
382
+ `do_orm_execute` on `Session` sees relationship lazy loads, and
383
+ `before_cursor_execute` on `Engine` sees every SQL statement.
384
+ 2. Measurement covers only the test function body, so queries in fixture
385
+ setup and teardown are not counted.
386
+ 3. Each statement is stored with the first stack frame outside SQLAlchemy,
387
+ pytest, the standard library, `site-packages` and the plugin itself. For
388
+ async sessions the search continues in the parent greenlets, where the
389
+ awaiting code runs.
390
+ 4. After the test, the recorded events are checked against the budget and
391
+ the two N+1 rules. A bug inside the plugin disables it with one warning;
392
+ it never fails your tests.
393
+
394
+ ## Limitations
395
+
396
+ - SQLAlchemy only. Raw DB-API connections and other ORMs are not seen.
397
+ - Everything in `site-packages` counts as library code. If your application
398
+ is installed non-editable (for example `pip install .` in a Docker CI
399
+ image), its frames are skipped too, and call sites point to the test file
400
+ instead of the application line.
401
+ - One measurement is active per process at a time, so tests in one process
402
+ must run sequentially. Under pytest-xdist each worker measures its own
403
+ tests, so detection and budgets work. The report needs a single process,
404
+ so under pytest-xdist it prints
405
+ `queryguard report: not available with pytest-xdist`.
406
+ - Tests that use `pytester` to run pytest in-process are also measured by the
407
+ outer test, which sees the inner run's queries. Mark such tests with
408
+ `allow_nplusone` or use `pytester.runpytest_subprocess()`.
409
+ - unittest `setUp` and `tearDown` run inside the test call, so their queries
410
+ are counted, unlike pytest fixtures.
411
+ - A non-strict `xfail` test that has an N+1 is reported as xfailed rather
412
+ than XPASS, because the N+1 failure looks like the expected failure.
413
+ - `INSERT`, `UPDATE` and `DELETE` are never reported as N+1. At cursor level
414
+ a unit-of-work flush cannot be told apart from an application write, so
415
+ all writes are left out. Use a budget to catch write loops.
416
+
417
+ ## Comparison with nplusone
418
+
419
+ [nplusone](https://github.com/jmcarp/nplusone) solved the same problem
420
+ earlier and also covers Django and Peewee. Its last release, 1.0.0, came
421
+ out in 2018, and it does not support SQLAlchemy 2. pytest-queryguard targets
422
+ SQLAlchemy 2 only, including async sessions, and adds query budgets and a
423
+ fix hint in the failure message. It does not detect unused eager loads,
424
+ which nplusone does.
425
+
426
+ ## Roadmap
427
+
428
+ - 0.2: missing index detection on PostgreSQL 16 and newer, by running each
429
+ statement through `EXPLAIN (GENERIC_PLAN)` with `enable_seqscan=off`.
430
+ - Later: Django ORM support.
431
+
432
+ ## Development
433
+
434
+ The project uses [uv](https://docs.astral.sh/uv/).
435
+
436
+ ```bash
437
+ uv sync
438
+ uv run coverage run -m pytest
439
+ uv run coverage report
440
+ uv run ruff check .
441
+ uv run ruff format --check .
442
+ uv run mypy src
443
+ ```
444
+
445
+ Coverage is measured with `coverage run` rather than `pytest --cov`, because
446
+ `--cov` starts after pytest has imported the plugin and misses its
447
+ import-time lines.
448
+
449
+ ## License
450
+
451
+ MIT, see the LICENSE file.
@@ -0,0 +1,16 @@
1
+ pytest_queryguard/__init__.py,sha256=7yzv4shBD-9lRo2AfDrIqEQjI72B7X194M1YlyjYOE4,314
2
+ pytest_queryguard/api.py,sha256=nw4XNTDGGg7IAOEv5WX4C5R0o_AQclD1tDX9QUKcGh8,1143
3
+ pytest_queryguard/callsite.py,sha256=uClQ4Elogn882OwE-SkcZ3dzcrXVWS-ARYoA6wHLynM,3182
4
+ pytest_queryguard/collector.py,sha256=O44eayC8BJzTW8fPbD4nmFNg9KgonM8qyYFv3fgjlMc,1102
5
+ pytest_queryguard/detectors.py,sha256=g0qqb63jgcNTvzZENn7OC4idkHPWA5UFuLDpuuYCNe8,3379
6
+ pytest_queryguard/events.py,sha256=9FJsT2MxD_25DgVO0QRxA3Bakd0VmJiXLUg35h9ehTE,450
7
+ pytest_queryguard/listeners.py,sha256=y6rEFd_ZZPVXf7WxKbnIOkmzzucL0zJlRo0YbDEQfK0,3259
8
+ pytest_queryguard/messages.py,sha256=730nWP83-3ruM7UEjod3qIgeBjGjgSxJPYLxl91Hnow,2514
9
+ pytest_queryguard/plugin.py,sha256=Xq9lUevUAsAmz81CWElWlycCkD58iR8Jr704odNvGxE,10333
10
+ pytest_queryguard/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ pytest_queryguard/warnings.py,sha256=eKFg4bgUygIfNdX4pR4zsWqFFCvGojbb1_1nGbZM95I,262
12
+ pytest_queryguard-0.1.0.dist-info/METADATA,sha256=DA7QHsFdjFHOmQ2P4yv8uIwF9ivk9f0AbLHcFzfDHYg,16074
13
+ pytest_queryguard-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
14
+ pytest_queryguard-0.1.0.dist-info/entry_points.txt,sha256=jjOwpKjkGqp0LohN0BgEaHpnSmZLIZbuG-mN5GHiPFU,49
15
+ pytest_queryguard-0.1.0.dist-info/licenses/LICENSE,sha256=7oE-K45xMCv_h5fb5eRLXTaVfnXwmd8CF03ijwDkGLU,1074
16
+ pytest_queryguard-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ queryguard = pytest_queryguard.plugin
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maciej Zieliński
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.