pytest-querycount 0.3.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,64 @@
1
+ """The end-of-session table.
2
+
3
+ This is the part people leave switched on. A budget tells you when you crossed a
4
+ line you drew; the report tells you where the lines should go.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+
12
+ @dataclass
13
+ class TestStats:
14
+ nodeid: str
15
+ count: int
16
+ duration: float
17
+ worst_duplicate: int
18
+ worst_sql: str | None = None
19
+
20
+
21
+ def build(stats: dict[str, TestStats], top: int = 10) -> list[str]:
22
+ """Render the summary as terminal lines. Empty when nothing was recorded."""
23
+ recorded = [entry for entry in stats.values() if entry.count]
24
+ if not recorded:
25
+ return []
26
+
27
+ recorded.sort(key=lambda entry: (entry.count, entry.duration), reverse=True)
28
+ shown = recorded[:top]
29
+
30
+ id_width = max(len(entry.nodeid) for entry in shown)
31
+ id_width = min(max(id_width, 4), 70)
32
+
33
+ lines = [
34
+ f"{'queries':>7} {'time':>9} {'dupes':>5} test",
35
+ f"{'-' * 7} {'-' * 9} {'-' * 5} {'-' * id_width}",
36
+ ]
37
+ for entry in shown:
38
+ dupes = str(entry.worst_duplicate) if entry.worst_duplicate > 1 else "-"
39
+ flag = "!" if entry.worst_duplicate > 1 else " "
40
+ lines.append(
41
+ f"{entry.count:>7} {entry.duration * 1000:>7.1f}ms "
42
+ f"{dupes:>4}{flag} {_trim(entry.nodeid, id_width)}"
43
+ )
44
+
45
+ total_queries = sum(entry.count for entry in recorded)
46
+ total_time = sum(entry.duration for entry in recorded)
47
+ hidden = len(recorded) - len(shown)
48
+ footer = f"{total_queries} queries in {total_time * 1000:.1f}ms across {len(recorded)} tests"
49
+ if hidden:
50
+ footer += f" ({hidden} more not shown, raise --querycount-top)"
51
+ lines += ["", footer]
52
+
53
+ flagged = [entry for entry in shown if entry.worst_duplicate > 1]
54
+ if flagged:
55
+ lines.append(
56
+ "! marks a repeated query shape -- add @pytest.mark.no_n_plus_one to see the detail."
57
+ )
58
+ return lines
59
+
60
+
61
+ def _trim(text: str, width: int) -> str:
62
+ if len(text) <= width:
63
+ return text
64
+ return "…" + text[-(width - 1) :]
@@ -0,0 +1,336 @@
1
+ Metadata-Version: 2.5
2
+ Name: pytest-querycount
3
+ Version: 0.3.0
4
+ Summary: Fail your tests when they run too many SQL queries. Catch N+1 problems in CI, not in production.
5
+ Project-URL: Homepage, https://github.com/ldavidsm/pytest-querycount
6
+ Project-URL: Issues, https://github.com/ldavidsm/pytest-querycount/issues
7
+ Project-URL: Changelog, https://github.com/ldavidsm/pytest-querycount/blob/main/CHANGELOG.md
8
+ Author: Luis David Senra
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Luis David Senra
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: database,n+1,orm,performance,pytest,query-count,sql,sqlalchemy,testing
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Framework :: Pytest
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Operating System :: OS Independent
37
+ Classifier: Programming Language :: Python :: 3
38
+ Classifier: Programming Language :: Python :: 3.10
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Programming Language :: Python :: 3.13
42
+ Classifier: Topic :: Database
43
+ Classifier: Topic :: Software Development :: Testing
44
+ Classifier: Typing :: Typed
45
+ Requires-Python: >=3.10
46
+ Requires-Dist: pytest>=8.0
47
+ Provides-Extra: dev
48
+ Requires-Dist: mypy>=1.8; extra == 'dev'
49
+ Requires-Dist: psycopg[binary]>=3.1; extra == 'dev'
50
+ Requires-Dist: pytest>=8.0; extra == 'dev'
51
+ Requires-Dist: ruff>=0.4; extra == 'dev'
52
+ Requires-Dist: sqlalchemy>=2.0; extra == 'dev'
53
+ Provides-Extra: postgresql
54
+ Requires-Dist: psycopg[binary]>=3.1; extra == 'postgresql'
55
+ Requires-Dist: sqlalchemy>=2.0; extra == 'postgresql'
56
+ Provides-Extra: sqlalchemy
57
+ Requires-Dist: sqlalchemy>=2.0; extra == 'sqlalchemy'
58
+ Description-Content-Type: text/markdown
59
+
60
+ # pytest-querycount
61
+
62
+ [![CI](https://github.com/ldavidsm/pytest-querycount/actions/workflows/ci.yml/badge.svg)](https://github.com/ldavidsm/pytest-querycount/actions/workflows/ci.yml)
63
+ [![PyPI](https://img.shields.io/pypi/v/pytest-querycount.svg)](https://pypi.org/project/pytest-querycount/)
64
+ [![Python versions](https://img.shields.io/pypi/pyversions/pytest-querycount.svg)](https://pypi.org/project/pytest-querycount/)
65
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
66
+
67
+ Fail your tests when they run too many SQL queries.
68
+
69
+ An endpoint that quietly grows from 2 queries to 200 does not break any test. It
70
+ just gets slower, until one day it is slow enough to notice. This plugin turns
71
+ that drift into a red test.
72
+
73
+ ```python
74
+ @pytest.mark.max_queries(2)
75
+ def test_billing_summary(db):
76
+ assert len(serialise(db)) == 12
77
+ ```
78
+
79
+ Add a lazy-loaded relationship to that serialiser and the test fails on the next
80
+ CI run, naming the repeated query and the line of your code that caused it.
81
+
82
+ ## Install
83
+
84
+ ```bash
85
+ pip install "pytest-querycount[sqlalchemy]"
86
+ ```
87
+
88
+ Requires Python 3.10+, pytest 8+, and SQLAlchemy 2.x. There is nothing to
89
+ configure: the plugin instruments every engine your suite creates, sync or async.
90
+
91
+ ## What it gives you
92
+
93
+ ### A query budget
94
+
95
+ ```python
96
+ @pytest.mark.max_queries(2)
97
+ def test_billing_summary(db):
98
+ assert len(serialise(db)) == 12
99
+ ```
100
+
101
+ When the serialiser loops, you get this -- real output, not an illustration:
102
+
103
+ ```
104
+ _____________________________ test_billing_summary _____________________________
105
+
106
+ E TooManyQueriesError: test_billing_summary ran 13 queries, budget is 2 (11 over).
107
+
108
+ Repeated query shapes -- most likely where the extra queries come from:
109
+ 12x SELECT invoices.id, invoices.customer_id, invoices.cents FROM invoices WHERE ? = invoices.custome…
110
+ from test_billing.py:10 (12x)
111
+
112
+ 13 queries in 0.1ms
113
+ 1. [ 0.02ms] SELECT customers.id, customers.email FROM customers
114
+ from test_billing.py:11
115
+ 2-13. [ 0.06ms] 12x SELECT invoices.id, invoices.customer_id, invoices.cents FROM invoices WHERE …
116
+ from test_billing.py:10
117
+ ```
118
+
119
+ Three things to notice, because they are the reason this is a package and not a
120
+ snippet:
121
+
122
+ - **The diagnosis comes first.** `12x` on one query shape, before any listing.
123
+ In a CI log you get the culprit in the first three lines.
124
+ - **`test_billing.py:10` is your code**, not a line inside SQLAlchemy. The
125
+ plugin walks up the stack past the ORM to find the line that actually caused
126
+ the query.
127
+ - **The twelve identical queries print as one line.** Twelve copies of the same
128
+ SELECT tell you nothing that `12x` does not, and they would push the useful
129
+ lines off the screen.
130
+
131
+ ### N+1 detection without picking a number
132
+
133
+ Sometimes you do not want to choose a budget, you just want to assert that
134
+ nothing is looping.
135
+
136
+ ```python
137
+ @pytest.mark.no_n_plus_one
138
+ def test_billing_summary(db):
139
+ assert len(serialise(db)) == 12
140
+ ```
141
+
142
+ This groups queries by *shape* -- `WHERE id = 42` and `WHERE id = 43` are the
143
+ same shape -- and fails when one shape repeats. By default it looks only at
144
+ SELECTs, since repeated `SAVEPOINT`s are just how transactions work.
145
+
146
+ ```python
147
+ @pytest.mark.no_n_plus_one(threshold=5) # tolerate up to 4 repeats
148
+ @pytest.mark.no_n_plus_one(kinds=("select", "insert"))
149
+ @pytest.mark.no_n_plus_one(kinds=None) # consider every statement
150
+ ```
151
+
152
+ ### Missing indexes (PostgreSQL)
153
+
154
+ ```python
155
+ @pytest.mark.no_seq_scan
156
+ def test_customer_search(db):
157
+ assert len(find_by_city(db, "city3")) == 1
158
+ ```
159
+
160
+ ```
161
+ _____________________________ test_customer_search _____________________________
162
+
163
+ E SeqScanError: test_customer_search ran 1 query that no index could serve.
164
+
165
+ PostgreSQL still chose a sequential scan with enable_seqscan disabled, which
166
+ means no index covers the filtered columns.
167
+
168
+ Seq Scan on "demo_customers" Filter: ((city)::text = 'city3'::text)
169
+ try: CREATE INDEX ON demo_customers (city);
170
+ from test_search.py:7
171
+ SELECT demo_customers.id, ... FROM demo_customers WHERE demo_customers.…
172
+ ```
173
+
174
+ That table holds **eight rows**, and that matters more than it looks.
175
+
176
+ The obvious way to write this check does not work. Failing on the presence of a
177
+ `Seq Scan` is useless, because on a test database of twenty rows PostgreSQL picks
178
+ a sequential scan *even when a perfect index exists* -- reading twenty rows is
179
+ cheaper than descending a B-tree. So the plan alone cannot distinguish a missing
180
+ index from a small table. And thresholding on table size, the usual next idea,
181
+ means the check never fires on test data at all. Either way you get a check that
182
+ lies to you.
183
+
184
+ So this asks a different question: not "did it scan?" but "**could** it have used
185
+ an index?". The plan is taken with `enable_seqscan` disabled, which makes the
186
+ planner treat sequential scans as enormously expensive. If it still picks one, no
187
+ index can serve that filter -- and that answer does not depend on data volume.
188
+ The test suite for this feature runs against eight rows on purpose, to keep that
189
+ claim honest.
190
+
191
+ Excluding a table you read whole deliberately:
192
+
193
+ ```python
194
+ @pytest.mark.no_seq_scan(ignore=("countries", "settings"))
195
+ ```
196
+
197
+ Three things this is careful about:
198
+
199
+ - The `EXPLAIN` runs on a raw DBAPI cursor, invisible to SQLAlchemy's events, so
200
+ it is never counted among your test's queries and cannot recurse.
201
+ - It is wrapped in a savepoint, which both scopes the `enable_seqscan` change and
202
+ absorbs a failed `EXPLAIN` that would otherwise abort your test's transaction.
203
+ - An unfiltered `Seq Scan` is never reported. Reading a whole table is sometimes
204
+ exactly what you meant.
205
+
206
+ Needs `pip install "pytest-querycount[postgresql]"`. On SQLite or MySQL the check
207
+ raises rather than passing, because a check that cannot fail is not a check.
208
+
209
+ ### A fixture, for when a marker is too coarse
210
+
211
+ A marker covers the whole test. When you only care about one block:
212
+
213
+ ```python
214
+ def test_detail(db, querycount):
215
+ with querycount(max_queries=2, no_seq_scan=True) as queries:
216
+ serialise(db)
217
+
218
+ assert queries.count == 2
219
+ assert queries.duplicates() == []
220
+ print(queries.report())
221
+ ```
222
+
223
+ Call it with no arguments to observe without asserting -- that is how you find
224
+ out what the budget should be before committing to one. The object from the
225
+ `with` is the recorder, and it keeps its records after the block ends.
226
+
227
+ ### The report you leave switched on
228
+
229
+ ```bash
230
+ pytest --querycount-report
231
+ ```
232
+
233
+ ```
234
+ ============================== querycount summary ==============================
235
+ queries time dupes test
236
+ ------- --------- ----- -------------------------------------------
237
+ 13 0.1ms 12! test_billing.py::test_billing_summary
238
+ 2 0.0ms - test_billing.py::test_billing_summary_fixed
239
+
240
+ 15 queries in 0.1ms across 2 tests
241
+ ! marks a repeated query shape -- add @pytest.mark.no_n_plus_one to see the detail.
242
+ ```
243
+
244
+ A budget tells you when you crossed a line you drew. This tells you where the
245
+ lines should go, and it is the honest way to adopt the plugin on an existing
246
+ suite: run it once, look at the rows with a `!`, write budgets for those.
247
+
248
+ ## Options
249
+
250
+ | Flag | Effect |
251
+ |---|---|
252
+ | `--querycount-report` | Print the summary table |
253
+ | `--querycount-top=N` | How many tests the table lists (default 10) |
254
+ | `--querycount-max=N` | Apply a budget of N to every test without an explicit one |
255
+ | `--querycount-no-seq-scan` | Apply the missing-index check to every test |
256
+
257
+ `--querycount-max` is how you ratchet: set it just above your current worst
258
+ test, then lower it as you fix things.
259
+
260
+ In `pyproject.toml`:
261
+
262
+ ```toml
263
+ [tool.pytest.ini_options]
264
+ querycount_max = "20"
265
+ querycount_duplicate_threshold = "3"
266
+ ```
267
+
268
+ ## Design decisions worth knowing
269
+
270
+ - **Only the call phase is measured.** Fixture setup and teardown run outside it,
271
+ so creating a schema and seeding it never consume a budget.
272
+ - **A failing test is never second-guessed.** If your assertion fails, you see
273
+ that failure, not a complaint about query counts on top of it.
274
+ - **`executemany` counts as one query**, because it is one round trip. That is
275
+ the point of it, and the fix for a loop of inserts.
276
+ - **No backend is an error, not a pass.** If nothing is instrumented, every
277
+ count is zero and every budget is trivially satisfied. The plugin raises
278
+ instead, because a budget that cannot fail is worse than no budget.
279
+
280
+ ## Why not just count them yourself
281
+
282
+ You can, in about twenty lines of `event.listen`. What you will not have is the
283
+ fingerprinting that tells `WHERE id = 42` and `WHERE id = 43` apart from each
284
+ other but together against `WHERE email = ?`; the walk up the stack to find the
285
+ line in *your* code rather than in SQLAlchemy's; the collapsing of `IN (?,?,?)`
286
+ so that batch size does not change the shape; or the failure message that names
287
+ the culprit before it shows the evidence. That is what this package is.
288
+
289
+ ## Prior art
290
+
291
+ [`nplusone`](https://pypi.org/project/nplusone/) did the detection half of this
292
+ for Django and SQLAlchemy, but its last release was in **May 2018** and it does
293
+ not support SQLAlchemy 2.x. Django users have `assertNumQueries` built in, which
294
+ covers budgets but not shapes. This plugin is aimed at the SQLAlchemy side --
295
+ FastAPI, Flask, Litestar -- where nothing is currently maintained.
296
+
297
+ ## Limitations
298
+
299
+ - SQLAlchemy 2.x only for now. Django and raw psycopg are on the roadmap.
300
+ - Under `pytest-xdist` the summary table is per worker, so it will be partial.
301
+ Budgets and N+1 detection are unaffected.
302
+ - `no_seq_scan` is PostgreSQL only, and costs one `EXPLAIN` per SELECT while
303
+ enabled. Budgets and N+1 detection work on any SQLAlchemy backend.
304
+ - The suggested `CREATE INDEX` is a starting point, not advice. Which columns to
305
+ index, in what order, and whether the index earns its write cost need the whole
306
+ query pattern, not one plan node. A filter over a function call
307
+ (`lower(email) = ...`) yields no suggestion at all rather than a wrong one.
308
+
309
+ ## Contributing
310
+
311
+ ```bash
312
+ python -m venv .venv && . .venv/bin/activate
313
+ pip install -e ".[dev]"
314
+
315
+ # The plan tests need PostgreSQL; without one they skip.
316
+ docker run -d --name qc-pg -e POSTGRES_PASSWORD=querycount \
317
+ -e POSTGRES_DB=querycount -p 55432:5432 postgres:17-alpine
318
+
319
+ pytest && ruff check src tests && mypy
320
+ ```
321
+
322
+ Set `QUERYCOUNT_REQUIRE_PG=1` to turn those skips into failures, as CI does, and
323
+ `QUERYCOUNT_TEST_PG_URL` to point elsewhere.
324
+
325
+ The suite runs pytest inside pytest via `pytester`: it writes a throwaway test,
326
+ runs it in a subprocess, and asserts on the outcome. That is the only honest way
327
+ to test a plugin whose job is to make tests fail.
328
+
329
+ ## Releasing
330
+
331
+ See [RELEASING.md](RELEASING.md). Publication goes through PyPI Trusted
332
+ Publishing, so there is no API token in this repository or its secrets.
333
+
334
+ ## Licence
335
+
336
+ MIT
@@ -0,0 +1,17 @@
1
+ pytest_querycount/__init__.py,sha256=mE-xK3UiI4aPN5oMNlAnMtfUmctyN3aXVSwzdLMifmk,730
2
+ pytest_querycount/checks.py,sha256=RAP3PypqTXxgzEx0re04MOa51W4Xi4FN4QsFuo-B2zQ,5903
3
+ pytest_querycount/errors.py,sha256=-xC9HpEEm_z1on1AqEIlZBTk69Qy3q2FhXtp-jbG_RM,1099
4
+ pytest_querycount/explain.py,sha256=nqsnlbqeRpUsEVGuhS3xZa-VzjtygZ1reNgis8k4apc,6017
5
+ pytest_querycount/normalize.py,sha256=xUenZDOMDdgCmuhishuAAIu5gp82PhOj-cu3kO5mZDM,3667
6
+ pytest_querycount/plugin.py,sha256=H85cxtuk7JUOYkpo8AcbWom4qWjR4TZ74kjYykcVrAI,9185
7
+ pytest_querycount/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ pytest_querycount/recorder.py,sha256=Hq18W2UMeY5rgx5kTTXC8a_G366p5DoouQ6LDswlN9A,7645
9
+ pytest_querycount/records.py,sha256=XVLYjXA5CbqA4cRa2gGGFbIHJfi6KsHHLlM3v5UOQ_Y,2126
10
+ pytest_querycount/report.py,sha256=J_dX6ArnVF_nysxK0MIHIYoD2zwWyvPEHyMqgZxu8ZY,2055
11
+ pytest_querycount/backends/__init__.py,sha256=bpzplqoZw_-5QZkwBdB24uYB1fbTPZhKhVFhIRv7ar0,1831
12
+ pytest_querycount/backends/sqlalchemy.py,sha256=mbXwb7DFBNUQv_fkr3oBYqkfyiPk8Po4YCnczh0pE4I,7167
13
+ pytest_querycount-0.3.0.dist-info/METADATA,sha256=73I2wxovgiPYbpsO_0upqfb7rEhGGDwoj3K4rxXAtbg,13781
14
+ pytest_querycount-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
15
+ pytest_querycount-0.3.0.dist-info/entry_points.txt,sha256=oex8Hidov-J-QUtO2H-9jWjk88Yf98ctT1KGJDvf9tM,49
16
+ pytest_querycount-0.3.0.dist-info/licenses/LICENSE,sha256=x67ZEM8AygvcybK9jS9iViUqb1igphougfREcPlJsYM,1073
17
+ pytest_querycount-0.3.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
+ querycount = pytest_querycount.plugin
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luis David Senra
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.