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.
- pytest_queryguard/__init__.py +14 -0
- pytest_queryguard/api.py +34 -0
- pytest_queryguard/callsite.py +96 -0
- pytest_queryguard/collector.py +46 -0
- pytest_queryguard/detectors.py +104 -0
- pytest_queryguard/events.py +24 -0
- pytest_queryguard/listeners.py +118 -0
- pytest_queryguard/messages.py +81 -0
- pytest_queryguard/plugin.py +290 -0
- pytest_queryguard/py.typed +0 -0
- pytest_queryguard/warnings.py +9 -0
- pytest_queryguard-0.1.0.dist-info/METADATA +451 -0
- pytest_queryguard-0.1.0.dist-info/RECORD +16 -0
- pytest_queryguard-0.1.0.dist-info/WHEEL +4 -0
- pytest_queryguard-0.1.0.dist-info/entry_points.txt +2 -0
- pytest_queryguard-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
]
|
pytest_queryguard/api.py
ADDED
|
@@ -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
|
+
[](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,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.
|