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.
Files changed (33) hide show
  1. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/.github/workflows/ci.yml +7 -7
  2. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/.github/workflows/publish.yml +2 -2
  3. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/CHANGELOG.md +48 -2
  4. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/PKG-INFO +81 -4
  5. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/README.md +75 -2
  6. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/pyproject.toml +10 -1
  7. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/__init__.py +1 -1
  8. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/plugin.py +50 -1
  9. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/recorder.py +53 -8
  10. pytest_querycount-0.4.0/src/pytest_querycount/writer.py +249 -0
  11. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/tests/conftest.py +133 -0
  12. pytest_querycount-0.4.0/tests/test_async.py +169 -0
  13. pytest_querycount-0.4.0/tests/test_write_budgets.py +253 -0
  14. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/.github/dependabot.yml +0 -0
  15. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/.gitignore +0 -0
  16. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/LICENSE +0 -0
  17. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/RELEASING.md +0 -0
  18. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/backends/__init__.py +0 -0
  19. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/backends/sqlalchemy.py +0 -0
  20. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/checks.py +0 -0
  21. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/errors.py +0 -0
  22. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/explain.py +0 -0
  23. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/normalize.py +0 -0
  24. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/py.typed +0 -0
  25. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/records.py +0 -0
  26. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/src/pytest_querycount/report.py +0 -0
  27. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/tests/test_fixture.py +0 -0
  28. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/tests/test_max_queries.py +0 -0
  29. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/tests/test_n_plus_one.py +0 -0
  30. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/tests/test_no_backend.py +0 -0
  31. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/tests/test_normalize.py +0 -0
  32. {pytest_querycount-0.3.0 → pytest_querycount-0.4.0}/tests/test_report.py +0 -0
  33. {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@v5
32
- - uses: actions/setup-python@v6
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@v5
52
- - uses: actions/setup-python@v6
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@v5
67
- - uses: actions/setup-python@v6
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
@@ -14,8 +14,8 @@ jobs:
14
14
  permissions:
15
15
  id-token: write
16
16
  steps:
17
- - uses: actions/checkout@v5
18
- - uses: actions/setup-python@v6
17
+ - uses: actions/checkout@v7
18
+ - uses: actions/setup-python@v7
19
19
  with:
20
20
  python-version: "3.13"
21
21
  - run: python -m pip install build
@@ -8,9 +8,55 @@ All notable changes to this project are documented here. The format follows
8
8
 
9
9
  ### Planned
10
10
 
11
- - Django and raw psycopg backends.
11
+ - Raw psycopg backend, for projects using SQL without an ORM.
12
12
  - `pytest-xdist` aware reporting.
13
- - A `--querycount-write-budgets` mode that inserts the markers for you.
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.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 for now. Django and raw psycopg are on the roadmap.
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 for now. Django and raw psycopg are on the roadmap.
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.3.0"
5
+ __version__ = "0.4.0"
6
6
 
7
7
  from pytest_querycount.errors import (
8
8
  DuplicateQueryError,
@@ -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 = 60
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
- frame: FrameType | None = sys._getframe(skip + 1)
95
+ start: FrameType | None = sys._getframe(skip + 1)
49
96
  except ValueError: # pragma: no cover - stack shallower than `skip`
50
97
  return None
51
- depth = 0
52
- while frame is not None and depth < _MAX_STACK_DEPTH:
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