pytest-querycount 0.3.0__tar.gz → 0.4.0__tar.gz
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_querycount-0.3.0 → pytest_querycount-0.4.0}/.github/workflows/ci.yml +7 -7
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/.github/workflows/publish.yml +2 -2
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/CHANGELOG.md +48 -2
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/PKG-INFO +81 -4
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/README.md +75 -2
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/pyproject.toml +10 -1
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/__init__.py +1 -1
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/plugin.py +50 -1
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/recorder.py +53 -8
- pytest_querycount-0.4.0/src/pytest_querycount/writer.py +249 -0
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/tests/conftest.py +133 -0
- pytest_querycount-0.4.0/tests/test_async.py +169 -0
- pytest_querycount-0.4.0/tests/test_write_budgets.py +253 -0
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/.github/dependabot.yml +0 -0
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/.gitignore +0 -0
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/LICENSE +0 -0
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/RELEASING.md +0 -0
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/backends/__init__.py +0 -0
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/backends/sqlalchemy.py +0 -0
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/checks.py +0 -0
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/errors.py +0 -0
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/explain.py +0 -0
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/normalize.py +0 -0
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/py.typed +0 -0
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/records.py +0 -0
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/report.py +0 -0
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/tests/test_fixture.py +0 -0
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/tests/test_max_queries.py +0 -0
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/tests/test_n_plus_one.py +0 -0
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/tests/test_no_backend.py +0 -0
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/tests/test_normalize.py +0 -0
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/tests/test_report.py +0 -0
- {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/tests/test_seq_scan.py +0 -0
|
@@ -28,15 +28,15 @@ jobs:
|
|
|
28
28
|
--health-timeout 5s
|
|
29
29
|
--health-retries 10
|
|
30
30
|
steps:
|
|
31
|
-
- uses: actions/checkout@
|
|
32
|
-
- uses: actions/setup-python@
|
|
31
|
+
- uses: actions/checkout@v7
|
|
32
|
+
- uses: actions/setup-python@v7
|
|
33
33
|
with:
|
|
34
34
|
python-version: ${{ matrix.python-version }}
|
|
35
35
|
- name: Install
|
|
36
36
|
run: |
|
|
37
37
|
python -m pip install --upgrade pip
|
|
38
38
|
python -m pip install -e ".[dev]"
|
|
39
|
-
python -m pip install "sqlalchemy==${{ matrix.sqlalchemy }}"
|
|
39
|
+
python -m pip install "sqlalchemy[asyncio]==${{ matrix.sqlalchemy }}"
|
|
40
40
|
- name: Test
|
|
41
41
|
# Without REQUIRE_PG the plan tests would skip on a broken service and
|
|
42
42
|
# the build would still be green, which is the worst outcome.
|
|
@@ -48,8 +48,8 @@ jobs:
|
|
|
48
48
|
quality:
|
|
49
49
|
runs-on: ubuntu-latest
|
|
50
50
|
steps:
|
|
51
|
-
- uses: actions/checkout@
|
|
52
|
-
- uses: actions/setup-python@
|
|
51
|
+
- uses: actions/checkout@v7
|
|
52
|
+
- uses: actions/setup-python@v7
|
|
53
53
|
with:
|
|
54
54
|
python-version: "3.13"
|
|
55
55
|
- run: python -m pip install -e ".[dev]"
|
|
@@ -63,8 +63,8 @@ jobs:
|
|
|
63
63
|
package:
|
|
64
64
|
runs-on: ubuntu-latest
|
|
65
65
|
steps:
|
|
66
|
-
- uses: actions/checkout@
|
|
67
|
-
- uses: actions/setup-python@
|
|
66
|
+
- uses: actions/checkout@v7
|
|
67
|
+
- uses: actions/setup-python@v7
|
|
68
68
|
with:
|
|
69
69
|
python-version: "3.13"
|
|
70
70
|
- run: python -m pip install build twine
|
|
@@ -8,9 +8,55 @@ All notable changes to this project are documented here. The format follows
|
|
|
8
8
|
|
|
9
9
|
### Planned
|
|
10
10
|
|
|
11
|
-
-
|
|
11
|
+
- Raw psycopg backend, for projects using SQL without an ORM.
|
|
12
12
|
- `pytest-xdist` aware reporting.
|
|
13
|
-
-
|
|
13
|
+
- More plan checks: sorts spilling to disk, nested loops over large sets.
|
|
14
|
+
|
|
15
|
+
## [0.4.0] - 2026-09-27
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- `--querycount-write-budgets` writes `@pytest.mark.max_queries` markers into
|
|
20
|
+
your test files using the counts observed in that run, then exits without
|
|
21
|
+
enforcing them. This is the answer to adopting a budget on a suite that
|
|
22
|
+
already has five hundred tests: nobody was ever going to read five hundred
|
|
23
|
+
failures and type five hundred numbers by hand.
|
|
24
|
+
|
|
25
|
+
Source editing goes through `ast`, not regular expressions, because "the line
|
|
26
|
+
above the def" is not something a regular expression finds reliably once
|
|
27
|
+
decorators, classes, async definitions and multi-line signatures are involved.
|
|
28
|
+
It matches on qualified names, so two `test_create` methods in different
|
|
29
|
+
classes are never confused; it leaves a hand-written budget alone; it skips
|
|
30
|
+
tests that failed, whose query count is whatever they reached before blowing
|
|
31
|
+
up; and it refuses to touch a file it cannot parse.
|
|
32
|
+
|
|
33
|
+
- `asyncio` extra, `pytest-querycount[asyncio]`, which brings in
|
|
34
|
+
`sqlalchemy[asyncio]` and therefore greenlet.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- **Caller attribution was silently lost under asyncio.** The budget still
|
|
39
|
+
fired, but every failure came without the one piece of information that makes
|
|
40
|
+
it actionable -- no "from your_file.py:39" line at all.
|
|
41
|
+
|
|
42
|
+
SQLAlchemy runs its synchronous internals inside a greenlet spawned per
|
|
43
|
+
operation, and that greenlet's stack begins at SQLAlchemy's own entry point,
|
|
44
|
+
so following `f_back` reached only library frames. The awaiting frames live on
|
|
45
|
+
the *parent* greenlet's stack, reachable through `gr_frame`, and the walk now
|
|
46
|
+
crosses that boundary. Since the audience for this plugin is FastAPI and
|
|
47
|
+
Litestar, where async is the default, this was the most important gap it had.
|
|
48
|
+
|
|
49
|
+
### Verified rather than assumed
|
|
50
|
+
|
|
51
|
+
The README claimed async engines were instrumented "sync or async" on the
|
|
52
|
+
strength of reasoning alone. They are, and there are now tests for it -- along
|
|
53
|
+
with tests proving that the three async tests fail without the greenlet fix, so
|
|
54
|
+
they guard something real. The plan check works under async PostgreSQL too: the
|
|
55
|
+
EXPLAIN on a raw DBAPI cursor survives being issued from inside the greenlet,
|
|
56
|
+
and the savepoint still restores `enable_seqscan` and keeps the transaction
|
|
57
|
+
usable there.
|
|
58
|
+
|
|
59
|
+
102 tests, up from 75.
|
|
14
60
|
|
|
15
61
|
## [0.3.0] - 2026-09-26
|
|
16
62
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pytest-querycount
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Fail your tests when they run too many SQL queries. Catch N+1 problems in CI, not in production.
|
|
5
5
|
Project-URL: Homepage, https://github.com/ldavidsm/pytest-querycount
|
|
6
6
|
Project-URL: Issues, https://github.com/ldavidsm/pytest-querycount/issues
|
|
@@ -44,12 +44,16 @@ Classifier: Topic :: Software Development :: Testing
|
|
|
44
44
|
Classifier: Typing :: Typed
|
|
45
45
|
Requires-Python: >=3.10
|
|
46
46
|
Requires-Dist: pytest>=8.0
|
|
47
|
+
Provides-Extra: asyncio
|
|
48
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'asyncio'
|
|
47
49
|
Provides-Extra: dev
|
|
50
|
+
Requires-Dist: aiosqlite>=0.19; extra == 'dev'
|
|
48
51
|
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
49
52
|
Requires-Dist: psycopg[binary]>=3.1; extra == 'dev'
|
|
53
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
50
54
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
51
55
|
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
52
|
-
Requires-Dist: sqlalchemy>=2.0; extra == 'dev'
|
|
56
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'dev'
|
|
53
57
|
Provides-Extra: postgresql
|
|
54
58
|
Requires-Dist: psycopg[binary]>=3.1; extra == 'postgresql'
|
|
55
59
|
Requires-Dist: sqlalchemy>=2.0; extra == 'postgresql'
|
|
@@ -82,7 +86,9 @@ CI run, naming the repeated query and the line of your code that caused it.
|
|
|
82
86
|
## Install
|
|
83
87
|
|
|
84
88
|
```bash
|
|
85
|
-
pip install "pytest-querycount[sqlalchemy]"
|
|
89
|
+
pip install "pytest-querycount[sqlalchemy]" # budgets and N+1 detection
|
|
90
|
+
pip install "pytest-querycount[postgresql]" # and the missing-index check
|
|
91
|
+
pip install "pytest-querycount[asyncio]" # for async engines
|
|
86
92
|
```
|
|
87
93
|
|
|
88
94
|
Requires Python 3.10+, pytest 8+, and SQLAlchemy 2.x. There is nothing to
|
|
@@ -206,6 +212,50 @@ Three things this is careful about:
|
|
|
206
212
|
Needs `pip install "pytest-querycount[postgresql]"`. On SQLite or MySQL the check
|
|
207
213
|
raises rather than passing, because a check that cannot fail is not a check.
|
|
208
214
|
|
|
215
|
+
### Adopting this on a suite that already exists
|
|
216
|
+
|
|
217
|
+
The honest problem with query budgets is the first day. Nobody is going to read
|
|
218
|
+
five hundred failures and type five hundred numbers, so in practice a plugin like
|
|
219
|
+
this gets installed, switched on once, and switched off again.
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
pytest --querycount-write-budgets
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
That runs the suite and writes the markers for you, each holding the count that
|
|
226
|
+
test actually ran:
|
|
227
|
+
|
|
228
|
+
```diff
|
|
229
|
+
+@pytest.mark.max_queries(5)
|
|
230
|
+
def test_list_shops(db):
|
|
231
|
+
for shop in db.scalars(select(Shop)):
|
|
232
|
+
len(shop.items)
|
|
233
|
+
|
|
234
|
+
+@pytest.mark.max_queries(2)
|
|
235
|
+
def test_list_shops_eagerly(db):
|
|
236
|
+
...
|
|
237
|
+
|
|
238
|
+
class TestItems:
|
|
239
|
+
+ @pytest.mark.max_queries(1)
|
|
240
|
+
def test_count(self, db):
|
|
241
|
+
...
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
It does not enforce anything on that run -- enforcing a budget while deciding
|
|
245
|
+
what it should be is contradictory. Then you commit the diff and every later run
|
|
246
|
+
holds the suite to it.
|
|
247
|
+
|
|
248
|
+
The numbers are exact on purpose. A budget with slack in it is not a ratchet: the
|
|
249
|
+
point is that the next query added to that code path turns a test red. Where a
|
|
250
|
+
number looks wrong, that is a finding, not a problem with the tool -- widen it by
|
|
251
|
+
hand and leave a comment saying why.
|
|
252
|
+
|
|
253
|
+
What it will not do: touch a test that already has a budget, write one for a test
|
|
254
|
+
that failed (its count is whatever it reached before blowing up), or touch a file
|
|
255
|
+
it cannot parse. The editing goes through Python's `ast`, so decorators, classes,
|
|
256
|
+
`async def` and multi-line signatures all land correctly, and two `test_create`
|
|
257
|
+
methods in different classes are never confused for each other.
|
|
258
|
+
|
|
209
259
|
### A fixture, for when a marker is too coarse
|
|
210
260
|
|
|
211
261
|
A marker covers the whole test. When you only care about one block:
|
|
@@ -224,6 +274,29 @@ Call it with no arguments to observe without asserting -- that is how you find
|
|
|
224
274
|
out what the budget should be before committing to one. The object from the
|
|
225
275
|
`with` is the recorder, and it keeps its records after the block ends.
|
|
226
276
|
|
|
277
|
+
### asyncio
|
|
278
|
+
|
|
279
|
+
Async engines are instrumented with no extra configuration, and failures still
|
|
280
|
+
name the line of your code that emitted the query.
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
@pytest.mark.max_queries(2)
|
|
284
|
+
async def test_authors(session):
|
|
285
|
+
await list_authors(session)
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
That second part took work, and is worth knowing about if you are comparing
|
|
289
|
+
tools. SQLAlchemy runs its synchronous internals inside a greenlet spawned per
|
|
290
|
+
operation, so the stack at the moment a query is emitted begins at SQLAlchemy's
|
|
291
|
+
own entry point: your awaiting frames are on a different greenlet's stack, and an
|
|
292
|
+
ordinary stack walk never reaches them. Until 0.4.0 this plugin's async failures
|
|
293
|
+
arrived with no origin at all. It now crosses the greenlet boundary.
|
|
294
|
+
|
|
295
|
+
The plan check works under async PostgreSQL too, including the savepoint that
|
|
296
|
+
keeps `enable_seqscan` and your transaction intact.
|
|
297
|
+
|
|
298
|
+
Needs `pip install "pytest-querycount[asyncio]"`, which brings greenlet with it.
|
|
299
|
+
|
|
227
300
|
### The report you leave switched on
|
|
228
301
|
|
|
229
302
|
```bash
|
|
@@ -253,6 +326,7 @@ suite: run it once, look at the rows with a `!`, write budgets for those.
|
|
|
253
326
|
| `--querycount-top=N` | How many tests the table lists (default 10) |
|
|
254
327
|
| `--querycount-max=N` | Apply a budget of N to every test without an explicit one |
|
|
255
328
|
| `--querycount-no-seq-scan` | Apply the missing-index check to every test |
|
|
329
|
+
| `--querycount-write-budgets` | Write the markers for you, then exit without enforcing. **Modifies your files.** |
|
|
256
330
|
|
|
257
331
|
`--querycount-max` is how you ratchet: set it just above your current worst
|
|
258
332
|
test, then lower it as you fix things.
|
|
@@ -296,11 +370,14 @@ FastAPI, Flask, Litestar -- where nothing is currently maintained.
|
|
|
296
370
|
|
|
297
371
|
## Limitations
|
|
298
372
|
|
|
299
|
-
- SQLAlchemy 2.x only
|
|
373
|
+
- SQLAlchemy 2.x only, sync and async. Raw psycopg is on the roadmap; Django is
|
|
374
|
+
not, since `assertNumQueries` already covers the budget half there.
|
|
300
375
|
- Under `pytest-xdist` the summary table is per worker, so it will be partial.
|
|
301
376
|
Budgets and N+1 detection are unaffected.
|
|
302
377
|
- `no_seq_scan` is PostgreSQL only, and costs one `EXPLAIN` per SELECT while
|
|
303
378
|
enabled. Budgets and N+1 detection work on any SQLAlchemy backend.
|
|
379
|
+
- `--querycount-write-budgets` edits your files in place. Run it on a clean
|
|
380
|
+
working tree so the diff is the only thing you have to review.
|
|
304
381
|
- The suggested `CREATE INDEX` is a starting point, not advice. Which columns to
|
|
305
382
|
index, in what order, and whether the index earns its write cost need the whole
|
|
306
383
|
query pattern, not one plan node. A filter over a function call
|
|
@@ -23,7 +23,9 @@ CI run, naming the repeated query and the line of your code that caused it.
|
|
|
23
23
|
## Install
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
pip install "pytest-querycount[sqlalchemy]"
|
|
26
|
+
pip install "pytest-querycount[sqlalchemy]" # budgets and N+1 detection
|
|
27
|
+
pip install "pytest-querycount[postgresql]" # and the missing-index check
|
|
28
|
+
pip install "pytest-querycount[asyncio]" # for async engines
|
|
27
29
|
```
|
|
28
30
|
|
|
29
31
|
Requires Python 3.10+, pytest 8+, and SQLAlchemy 2.x. There is nothing to
|
|
@@ -147,6 +149,50 @@ Three things this is careful about:
|
|
|
147
149
|
Needs `pip install "pytest-querycount[postgresql]"`. On SQLite or MySQL the check
|
|
148
150
|
raises rather than passing, because a check that cannot fail is not a check.
|
|
149
151
|
|
|
152
|
+
### Adopting this on a suite that already exists
|
|
153
|
+
|
|
154
|
+
The honest problem with query budgets is the first day. Nobody is going to read
|
|
155
|
+
five hundred failures and type five hundred numbers, so in practice a plugin like
|
|
156
|
+
this gets installed, switched on once, and switched off again.
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
pytest --querycount-write-budgets
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
That runs the suite and writes the markers for you, each holding the count that
|
|
163
|
+
test actually ran:
|
|
164
|
+
|
|
165
|
+
```diff
|
|
166
|
+
+@pytest.mark.max_queries(5)
|
|
167
|
+
def test_list_shops(db):
|
|
168
|
+
for shop in db.scalars(select(Shop)):
|
|
169
|
+
len(shop.items)
|
|
170
|
+
|
|
171
|
+
+@pytest.mark.max_queries(2)
|
|
172
|
+
def test_list_shops_eagerly(db):
|
|
173
|
+
...
|
|
174
|
+
|
|
175
|
+
class TestItems:
|
|
176
|
+
+ @pytest.mark.max_queries(1)
|
|
177
|
+
def test_count(self, db):
|
|
178
|
+
...
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
It does not enforce anything on that run -- enforcing a budget while deciding
|
|
182
|
+
what it should be is contradictory. Then you commit the diff and every later run
|
|
183
|
+
holds the suite to it.
|
|
184
|
+
|
|
185
|
+
The numbers are exact on purpose. A budget with slack in it is not a ratchet: the
|
|
186
|
+
point is that the next query added to that code path turns a test red. Where a
|
|
187
|
+
number looks wrong, that is a finding, not a problem with the tool -- widen it by
|
|
188
|
+
hand and leave a comment saying why.
|
|
189
|
+
|
|
190
|
+
What it will not do: touch a test that already has a budget, write one for a test
|
|
191
|
+
that failed (its count is whatever it reached before blowing up), or touch a file
|
|
192
|
+
it cannot parse. The editing goes through Python's `ast`, so decorators, classes,
|
|
193
|
+
`async def` and multi-line signatures all land correctly, and two `test_create`
|
|
194
|
+
methods in different classes are never confused for each other.
|
|
195
|
+
|
|
150
196
|
### A fixture, for when a marker is too coarse
|
|
151
197
|
|
|
152
198
|
A marker covers the whole test. When you only care about one block:
|
|
@@ -165,6 +211,29 @@ Call it with no arguments to observe without asserting -- that is how you find
|
|
|
165
211
|
out what the budget should be before committing to one. The object from the
|
|
166
212
|
`with` is the recorder, and it keeps its records after the block ends.
|
|
167
213
|
|
|
214
|
+
### asyncio
|
|
215
|
+
|
|
216
|
+
Async engines are instrumented with no extra configuration, and failures still
|
|
217
|
+
name the line of your code that emitted the query.
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
@pytest.mark.max_queries(2)
|
|
221
|
+
async def test_authors(session):
|
|
222
|
+
await list_authors(session)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
That second part took work, and is worth knowing about if you are comparing
|
|
226
|
+
tools. SQLAlchemy runs its synchronous internals inside a greenlet spawned per
|
|
227
|
+
operation, so the stack at the moment a query is emitted begins at SQLAlchemy's
|
|
228
|
+
own entry point: your awaiting frames are on a different greenlet's stack, and an
|
|
229
|
+
ordinary stack walk never reaches them. Until 0.4.0 this plugin's async failures
|
|
230
|
+
arrived with no origin at all. It now crosses the greenlet boundary.
|
|
231
|
+
|
|
232
|
+
The plan check works under async PostgreSQL too, including the savepoint that
|
|
233
|
+
keeps `enable_seqscan` and your transaction intact.
|
|
234
|
+
|
|
235
|
+
Needs `pip install "pytest-querycount[asyncio]"`, which brings greenlet with it.
|
|
236
|
+
|
|
168
237
|
### The report you leave switched on
|
|
169
238
|
|
|
170
239
|
```bash
|
|
@@ -194,6 +263,7 @@ suite: run it once, look at the rows with a `!`, write budgets for those.
|
|
|
194
263
|
| `--querycount-top=N` | How many tests the table lists (default 10) |
|
|
195
264
|
| `--querycount-max=N` | Apply a budget of N to every test without an explicit one |
|
|
196
265
|
| `--querycount-no-seq-scan` | Apply the missing-index check to every test |
|
|
266
|
+
| `--querycount-write-budgets` | Write the markers for you, then exit without enforcing. **Modifies your files.** |
|
|
197
267
|
|
|
198
268
|
`--querycount-max` is how you ratchet: set it just above your current worst
|
|
199
269
|
test, then lower it as you fix things.
|
|
@@ -237,11 +307,14 @@ FastAPI, Flask, Litestar -- where nothing is currently maintained.
|
|
|
237
307
|
|
|
238
308
|
## Limitations
|
|
239
309
|
|
|
240
|
-
- SQLAlchemy 2.x only
|
|
310
|
+
- SQLAlchemy 2.x only, sync and async. Raw psycopg is on the roadmap; Django is
|
|
311
|
+
not, since `assertNumQueries` already covers the budget half there.
|
|
241
312
|
- Under `pytest-xdist` the summary table is per worker, so it will be partial.
|
|
242
313
|
Budgets and N+1 detection are unaffected.
|
|
243
314
|
- `no_seq_scan` is PostgreSQL only, and costs one `EXPLAIN` per SELECT while
|
|
244
315
|
enabled. Budgets and N+1 detection work on any SQLAlchemy backend.
|
|
316
|
+
- `--querycount-write-budgets` edits your files in place. Run it on a clean
|
|
317
|
+
working tree so the diff is the only thing you have to review.
|
|
245
318
|
- The suggested `CREATE INDEX` is a starting point, not advice. Which columns to
|
|
246
319
|
index, in what order, and whether the index earns its write cost need the whole
|
|
247
320
|
query pattern, not one plan node. A filter over a function call
|
|
@@ -33,11 +33,14 @@ dependencies = ["pytest>=8.0"]
|
|
|
33
33
|
|
|
34
34
|
[project.optional-dependencies]
|
|
35
35
|
sqlalchemy = ["sqlalchemy>=2.0"]
|
|
36
|
+
asyncio = ["sqlalchemy[asyncio]>=2.0"]
|
|
36
37
|
postgresql = ["sqlalchemy>=2.0", "psycopg[binary]>=3.1"]
|
|
37
38
|
dev = [
|
|
38
|
-
"sqlalchemy>=2.0",
|
|
39
|
+
"sqlalchemy[asyncio]>=2.0",
|
|
39
40
|
"psycopg[binary]>=3.1",
|
|
41
|
+
"aiosqlite>=0.19",
|
|
40
42
|
"pytest>=8.0",
|
|
43
|
+
"pytest-asyncio>=0.23",
|
|
41
44
|
"mypy>=1.8",
|
|
42
45
|
"ruff>=0.4",
|
|
43
46
|
]
|
|
@@ -70,3 +73,9 @@ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
|
70
73
|
[tool.mypy]
|
|
71
74
|
strict = true
|
|
72
75
|
files = ["src/pytest_querycount"]
|
|
76
|
+
|
|
77
|
+
# greenlet is an optional runtime dependency, arriving only with
|
|
78
|
+
# sqlalchemy[asyncio], and we touch three attributes of it dynamically.
|
|
79
|
+
[[tool.mypy.overrides]]
|
|
80
|
+
module = ["greenlet"]
|
|
81
|
+
ignore_missing_imports = true
|
|
@@ -3,17 +3,21 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from collections.abc import Generator, Sequence
|
|
6
|
+
from pathlib import Path
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
8
9
|
import pytest
|
|
9
10
|
|
|
10
|
-
from pytest_querycount import backends, checks, report
|
|
11
|
+
from pytest_querycount import backends, checks, report, writer
|
|
11
12
|
from pytest_querycount.checks import DEFAULT_DUPLICATE_THRESHOLD, DEFAULT_KINDS, Budget
|
|
12
13
|
from pytest_querycount.recorder import Recorder
|
|
13
14
|
|
|
14
15
|
RECORDER_KEY = pytest.StashKey[Recorder]()
|
|
15
16
|
_STATS: dict[str, report.TestStats] = {}
|
|
16
17
|
|
|
18
|
+
# path -> {qualname: observed count}, gathered for --querycount-write-budgets.
|
|
19
|
+
_OBSERVED: dict[Path, dict[str, int]] = {}
|
|
20
|
+
|
|
17
21
|
|
|
18
22
|
# -- configuration ---------------------------------------------------------
|
|
19
23
|
|
|
@@ -40,6 +44,16 @@ def pytest_addoption(parser: pytest.Parser) -> None:
|
|
|
40
44
|
metavar="N",
|
|
41
45
|
help="Apply a query budget of N to every test that has no explicit one.",
|
|
42
46
|
)
|
|
47
|
+
group.addoption(
|
|
48
|
+
"--querycount-write-budgets",
|
|
49
|
+
action="store_true",
|
|
50
|
+
default=False,
|
|
51
|
+
help=(
|
|
52
|
+
"Write @pytest.mark.max_queries markers into your test files using "
|
|
53
|
+
"the counts observed in this run, then exit without enforcing them. "
|
|
54
|
+
"MODIFIES YOUR FILES -- review the diff afterwards."
|
|
55
|
+
),
|
|
56
|
+
)
|
|
43
57
|
group.addoption(
|
|
44
58
|
"--querycount-no-seq-scan",
|
|
45
59
|
action="store_true",
|
|
@@ -80,6 +94,7 @@ def pytest_configure(config: pytest.Config) -> None:
|
|
|
80
94
|
"sequentially because no index could serve its filter. PostgreSQL only.",
|
|
81
95
|
)
|
|
82
96
|
_STATS.clear()
|
|
97
|
+
_OBSERVED.clear()
|
|
83
98
|
backends.install_all()
|
|
84
99
|
|
|
85
100
|
|
|
@@ -126,6 +141,11 @@ def pytest_runtest_call(item: pytest.Item) -> Generator[None, object, object]:
|
|
|
126
141
|
backends.pop(recorder)
|
|
127
142
|
|
|
128
143
|
_record_stats(item, recorder)
|
|
144
|
+
|
|
145
|
+
if item.config.getoption("querycount_write_budgets"):
|
|
146
|
+
_observe(item, recorder)
|
|
147
|
+
return result
|
|
148
|
+
|
|
129
149
|
checks.enforce(budget, recorder, label=item.name)
|
|
130
150
|
return result
|
|
131
151
|
|
|
@@ -143,6 +163,24 @@ def _record_stats(item: pytest.Item, recorder: Recorder) -> None:
|
|
|
143
163
|
)
|
|
144
164
|
|
|
145
165
|
|
|
166
|
+
def _observe(item: pytest.Item, recorder: Recorder) -> None:
|
|
167
|
+
"""Remember what a passing test ran, so a marker can be written for it.
|
|
168
|
+
|
|
169
|
+
Only passing tests: a test that failed part way through ran an arbitrary
|
|
170
|
+
number of queries, and freezing that number as a budget would be nonsense.
|
|
171
|
+
"""
|
|
172
|
+
if not recorder.count:
|
|
173
|
+
return
|
|
174
|
+
function = getattr(item, "function", None)
|
|
175
|
+
qualname = getattr(function, "__qualname__", None)
|
|
176
|
+
if qualname is None:
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
per_file = _OBSERVED.setdefault(Path(str(item.path)), {})
|
|
180
|
+
# Parametrised cases share one function, so the widest run wins.
|
|
181
|
+
per_file[qualname] = max(per_file.get(qualname, 0), recorder.count)
|
|
182
|
+
|
|
183
|
+
|
|
146
184
|
def _budget_for(item: pytest.Item) -> Budget:
|
|
147
185
|
"""Resolve the budget: marker first, then --querycount-max, then ini."""
|
|
148
186
|
budget = Budget(
|
|
@@ -274,6 +312,10 @@ def pytest_terminal_summary(
|
|
|
274
312
|
exitstatus: int,
|
|
275
313
|
config: pytest.Config,
|
|
276
314
|
) -> None:
|
|
315
|
+
if config.getoption("querycount_write_budgets"):
|
|
316
|
+
_write_budgets(terminalreporter)
|
|
317
|
+
return
|
|
318
|
+
|
|
277
319
|
if not config.getoption("querycount_report"):
|
|
278
320
|
return
|
|
279
321
|
lines = report.build(_STATS, top=int(config.getoption("querycount_top")))
|
|
@@ -282,3 +324,10 @@ def pytest_terminal_summary(
|
|
|
282
324
|
terminalreporter.write_sep("=", "querycount summary")
|
|
283
325
|
for line in lines:
|
|
284
326
|
terminalreporter.write_line(line)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _write_budgets(terminalreporter: Any) -> None:
|
|
330
|
+
results = [writer.rewrite_file(path, counts) for path, counts in sorted(_OBSERVED.items())]
|
|
331
|
+
terminalreporter.write_sep("=", "querycount: budgets written")
|
|
332
|
+
for line in writer.summarise(results):
|
|
333
|
+
terminalreporter.write_line(line)
|
|
@@ -5,8 +5,8 @@ from __future__ import annotations
|
|
|
5
5
|
import os
|
|
6
6
|
import sys
|
|
7
7
|
from collections import Counter
|
|
8
|
-
from collections.abc import Iterable, Sequence
|
|
9
|
-
from typing import TYPE_CHECKING
|
|
8
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
10
|
|
|
11
11
|
from pytest_querycount.records import Duplicate, QueryRecord
|
|
12
12
|
|
|
@@ -26,7 +26,7 @@ _INTERNAL_PATHS = (
|
|
|
26
26
|
"/threading.py",
|
|
27
27
|
)
|
|
28
28
|
|
|
29
|
-
_MAX_STACK_DEPTH =
|
|
29
|
+
_MAX_STACK_DEPTH = 120
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
def _shorten(path: str) -> str:
|
|
@@ -42,19 +42,64 @@ def _shorten(path: str) -> str:
|
|
|
42
42
|
return path if relative.startswith("..") else relative
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
def _greenlet_module() -> Any:
|
|
46
|
+
"""The greenlet module, or None when it is not installed.
|
|
47
|
+
|
|
48
|
+
Imported lazily: greenlet only arrives with ``sqlalchemy[asyncio]``, and a
|
|
49
|
+
synchronous project should not be made to care that it exists.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
import greenlet
|
|
53
|
+
except ImportError: # pragma: no cover - synchronous install
|
|
54
|
+
return None
|
|
55
|
+
return greenlet
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _frames(start: FrameType | None) -> Iterator[FrameType]:
|
|
59
|
+
"""Frames from ``start`` outwards, crossing greenlet boundaries.
|
|
60
|
+
|
|
61
|
+
Under SQLAlchemy's asyncio support the query is emitted inside a greenlet
|
|
62
|
+
spawned for it, and that greenlet's stack begins at SQLAlchemy's own entry
|
|
63
|
+
point -- so following ``f_back`` alone never reaches the caller's code. The
|
|
64
|
+
awaiting frames live on the *parent* greenlet's stack, which is suspended and
|
|
65
|
+
reachable through ``gr_frame``.
|
|
66
|
+
|
|
67
|
+
Walking that chain is what lets an async test still be told which of its
|
|
68
|
+
lines emitted the query, rather than being told nothing.
|
|
69
|
+
"""
|
|
70
|
+
greenlet = _greenlet_module()
|
|
71
|
+
current = greenlet.getcurrent() if greenlet is not None else None
|
|
72
|
+
|
|
73
|
+
frame = start
|
|
74
|
+
budget = _MAX_STACK_DEPTH
|
|
75
|
+
while budget > 0:
|
|
76
|
+
if frame is not None:
|
|
77
|
+
yield frame
|
|
78
|
+
frame = frame.f_back
|
|
79
|
+
budget -= 1
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
# This greenlet's stack is exhausted. Continue in whoever is waiting on
|
|
83
|
+
# it. The chain ends at the main greenlet, whose parent is None.
|
|
84
|
+
if current is None:
|
|
85
|
+
return
|
|
86
|
+
current = getattr(current, "parent", None)
|
|
87
|
+
if current is None:
|
|
88
|
+
return
|
|
89
|
+
frame = getattr(current, "gr_frame", None)
|
|
90
|
+
|
|
91
|
+
|
|
45
92
|
def caller_location(skip: int = 1) -> str | None:
|
|
46
93
|
"""``path:lineno`` of the closest frame that is not library machinery."""
|
|
47
94
|
try:
|
|
48
|
-
|
|
95
|
+
start: FrameType | None = sys._getframe(skip + 1)
|
|
49
96
|
except ValueError: # pragma: no cover - stack shallower than `skip`
|
|
50
97
|
return None
|
|
51
|
-
|
|
52
|
-
|
|
98
|
+
|
|
99
|
+
for frame in _frames(start):
|
|
53
100
|
filename = frame.f_code.co_filename.replace(os.sep, "/")
|
|
54
101
|
if not any(part in filename for part in _INTERNAL_PATHS):
|
|
55
102
|
return f"{_shorten(frame.f_code.co_filename)}:{frame.f_lineno}"
|
|
56
|
-
frame = frame.f_back
|
|
57
|
-
depth += 1
|
|
58
103
|
return None
|
|
59
104
|
|
|
60
105
|
|