pytest-orm-boundaries 0.4.0__tar.gz → 0.6.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_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.6.0}/PKG-INFO +55 -24
- {pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.6.0}/README.md +52 -23
- {pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.6.0}/pyproject.toml +4 -1
- pytest_orm_boundaries-0.6.0/src/pytest_orm_boundaries/allows.py +26 -0
- {pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/config.py +32 -15
- pytest_orm_boundaries-0.6.0/src/pytest_orm_boundaries/crossings.py +139 -0
- pytest_orm_boundaries-0.6.0/src/pytest_orm_boundaries/guard.py +179 -0
- {pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/ignores.py +1 -1
- {pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/plugin.py +35 -11
- pytest_orm_boundaries-0.6.0/src/pytest_orm_boundaries/prefetch_resolution.py +72 -0
- {pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/report.py +19 -19
- pytest_orm_boundaries-0.4.0/src/pytest_orm_boundaries/guard.py +0 -193
- {pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.6.0}/LICENSE +0 -0
- {pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/__init__.py +0 -0
- {pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/callstack.py +0 -0
- {pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/model_resolution.py +0 -0
- {pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/sql_parsing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-orm-boundaries
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Pytest plugin that fails tests when ORM queries cross DDD aggregate boundaries (Django supported today).
|
|
5
5
|
Keywords: django,pytest,plugin,orm,ddd,aggregate,boundaries,architecture
|
|
6
6
|
Author: Evgeniia Chibisova
|
|
@@ -18,20 +18,22 @@ Classifier: Topic :: Software Development :: Quality Assurance
|
|
|
18
18
|
Classifier: Topic :: Software Development :: Testing
|
|
19
19
|
Requires-Dist: pytest>=8
|
|
20
20
|
Requires-Dist: sqlglot>=30,<31
|
|
21
|
+
Requires-Dist: django>=4.2 ; extra == 'django'
|
|
21
22
|
Requires-Python: >=3.11
|
|
22
23
|
Project-URL: Homepage, https://github.com/evchibisova/pytest-orm-boundaries
|
|
23
24
|
Project-URL: Repository, https://github.com/evchibisova/pytest-orm-boundaries
|
|
24
25
|
Project-URL: Issues, https://github.com/evchibisova/pytest-orm-boundaries/issues
|
|
25
26
|
Project-URL: Changelog, https://github.com/evchibisova/pytest-orm-boundaries/blob/main/CHANGELOG.md
|
|
27
|
+
Provides-Extra: django
|
|
26
28
|
Description-Content-Type: text/markdown
|
|
27
29
|
|
|
28
30
|
# pytest-orm-boundaries
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
💡 **Even if you control your imports, boundaries can still leak through the ORM.**
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
`pytest-orm-boundaries` is a pytest plugin that reports ORM queries crossing your DDD aggregate boundaries.
|
|
33
35
|
|
|
34
|
-
Currently works with Django ORM.
|
|
36
|
+
Currently works with Django ORM, SQLAlchemy is in roadmap.
|
|
35
37
|
|
|
36
38
|
In domain-driven design, an aggregate is a consistency boundary: code in one
|
|
37
39
|
aggregate should not reach into the internals of another. Django's `__` relation
|
|
@@ -42,17 +44,19 @@ lookups make it easy to cross those boundaries silently:
|
|
|
42
44
|
Purchase.objects.get(client__name="John")
|
|
43
45
|
```
|
|
44
46
|
|
|
45
|
-
`pytest-orm-boundaries` watches the
|
|
46
|
-
|
|
47
|
-
`select_related`, subqueries, or hand-written `.raw()` SQL.
|
|
47
|
+
`pytest-orm-boundaries` watches the ORM activity exercised by your test suite
|
|
48
|
+
and reports access that crosses a configured boundary - through `__` lookups,
|
|
49
|
+
`select_related`, `prefetch_related`, subqueries, or hand-written `.raw()` SQL.
|
|
48
50
|
|
|
49
51
|
## Install
|
|
50
52
|
|
|
53
|
+
Install the plugin with Django support:
|
|
54
|
+
|
|
51
55
|
```bash
|
|
52
|
-
pip install pytest-orm-boundaries
|
|
56
|
+
pip install "pytest-orm-boundaries[django]"
|
|
53
57
|
```
|
|
54
58
|
|
|
55
|
-
pytest
|
|
59
|
+
pytest discovers the plugin automatically.
|
|
56
60
|
|
|
57
61
|
## Configure
|
|
58
62
|
|
|
@@ -71,9 +75,9 @@ not checked. Without a config file the plugin emits a warning and runs no checks
|
|
|
71
75
|
|
|
72
76
|
## What it catches
|
|
73
77
|
|
|
74
|
-
The plugin
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
The plugin flags executed ORM access that spans more than one configured group.
|
|
79
|
+
In the DDD example below, each crossing couples the `purchase` and `client`
|
|
80
|
+
aggregates:
|
|
77
81
|
|
|
78
82
|
- `__` relation lookups:
|
|
79
83
|
|
|
@@ -81,7 +85,7 @@ query was written. Each example below joins the `purchase` and `client` aggregat
|
|
|
81
85
|
Purchase.objects.get(client__name="John")
|
|
82
86
|
```
|
|
83
87
|
|
|
84
|
-
- `select_related
|
|
88
|
+
- `select_related`:
|
|
85
89
|
|
|
86
90
|
```python
|
|
87
91
|
Purchase.objects.select_related("client")
|
|
@@ -105,7 +109,13 @@ query was written. Each example below joins the `purchase` and `client` aggregat
|
|
|
105
109
|
|
|
106
110
|
- Bare `cursor.execute()` - the same join reached through a raw cursor.
|
|
107
111
|
|
|
108
|
-
|
|
112
|
+
- `prefetch_related`:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
Purchase.objects.prefetch_related("client")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Queries that don't actually join across the boundary are **not** flagged - for
|
|
109
119
|
example a foreign-key lookup by id, which Django resolves without a join:
|
|
110
120
|
|
|
111
121
|
```python
|
|
@@ -118,7 +128,7 @@ Purchase.objects.filter(client__pk=42) # Django trims the join
|
|
|
118
128
|
At the end of the run, the plugin prints one grouped entry per offending place:
|
|
119
129
|
|
|
120
130
|
```
|
|
121
|
-
|
|
131
|
+
====================== orm-boundaries: boundary crossings ======================
|
|
122
132
|
1 place(s) in your code crossed aggregate boundaries, affecting 1 test(s):
|
|
123
133
|
|
|
124
134
|
bookshop/reports.py:13
|
|
@@ -127,18 +137,29 @@ bookshop/reports.py:13
|
|
|
127
137
|
1 test(s) affected:
|
|
128
138
|
test_purchases.py::test_list_purchases_with_client
|
|
129
139
|
|
|
130
|
-
orm-boundaries: FAILED - 1 boundary
|
|
140
|
+
orm-boundaries: FAILED - 1 boundary crossing(s), run exits non-zero.
|
|
131
141
|
```
|
|
132
142
|
|
|
133
143
|
Each entry names the aggregates the query crossed and the models it joined.
|
|
134
144
|
Places are ordered by how many tests they affect. Pass `-v` to see every affected test
|
|
135
145
|
(otherwise the list is capped at 5 per place).
|
|
136
146
|
|
|
137
|
-
##
|
|
147
|
+
## Allow and ignore
|
|
148
|
+
|
|
149
|
+
CQRS read models may cross boundaries intentionally, also existing application code may contain crossings you want to fix over time. [allow] and [ignore] let you tell the plugin which is which:
|
|
138
150
|
|
|
139
|
-
|
|
151
|
+
- `[allow]` - the crossing is **intentional**. Use it for code that is meant to
|
|
152
|
+
span aggregates, such as CQRS read models or cross-aggregate reports. An
|
|
153
|
+
allowed crossing is suppressed and never reported.
|
|
154
|
+
- `[ignore]` - the crossing is **known debt** you plan to fix. It is suppressed
|
|
155
|
+
for now, and the plugin reminds you when an entry is no longer needed.
|
|
140
156
|
|
|
141
157
|
```toml
|
|
158
|
+
[allow]
|
|
159
|
+
files = [
|
|
160
|
+
"app/reports/sales_summary.py",
|
|
161
|
+
]
|
|
162
|
+
|
|
142
163
|
[ignore]
|
|
143
164
|
files = [
|
|
144
165
|
"app/billing.py",
|
|
@@ -146,25 +167,35 @@ files = [
|
|
|
146
167
|
]
|
|
147
168
|
```
|
|
148
169
|
|
|
149
|
-
Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html),
|
|
170
|
+
Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html)),
|
|
150
171
|
resolved relative to pytest's root directory and matched against either:
|
|
151
172
|
|
|
152
173
|
- the file that issues the query, or
|
|
153
174
|
- the test file.
|
|
154
175
|
|
|
155
|
-
|
|
156
|
-
boundary, the plugin
|
|
176
|
+
An `[ignore]` whose file runs through the whole suite without ever crossing a
|
|
177
|
+
boundary is stale — it is clean now, so the plugin lists it for removal:
|
|
157
178
|
|
|
158
179
|
```
|
|
159
180
|
======================= orm-boundaries: stale ignores ========================
|
|
160
|
-
These [ignore] entries no longer suppress any boundary
|
|
181
|
+
These [ignore] entries no longer suppress any boundary crossing - their files are clean now.
|
|
161
182
|
Remove them from boundaries.toml:
|
|
162
183
|
- app/billing.py
|
|
163
184
|
```
|
|
164
185
|
|
|
165
|
-
|
|
186
|
+
`[allow]` entries are never reported this way. If a file sits in both sections,
|
|
187
|
+
the allow wins and its `[ignore]` entry shows up as stale to remove.
|
|
188
|
+
|
|
189
|
+
## A note on Django internals
|
|
190
|
+
|
|
191
|
+
Catching `prefetch_related` relies on Django internals that come with no stability
|
|
192
|
+
promise, so new Django releases may require compatibility updates.
|
|
193
|
+
|
|
194
|
+
## Known gaps (on the project roadmap)
|
|
166
195
|
|
|
167
|
-
-
|
|
196
|
+
- lazy attribute access (e.g. `purchase.client`);
|
|
197
|
+
- related-manager reads/writes (`client.purchases.all()`, `client.purchases.create(...)`);
|
|
198
|
+
- a direct query on another aggregate's model, e.g. `Client.objects.get(...)` written inside purchase code.
|
|
168
199
|
|
|
169
200
|
## Status
|
|
170
201
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# pytest-orm-boundaries
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
💡 **Even if you control your imports, boundaries can still leak through the ORM.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
`pytest-orm-boundaries` is a pytest plugin that reports ORM queries crossing your DDD aggregate boundaries.
|
|
6
6
|
|
|
7
|
-
Currently works with Django ORM.
|
|
7
|
+
Currently works with Django ORM, SQLAlchemy is in roadmap.
|
|
8
8
|
|
|
9
9
|
In domain-driven design, an aggregate is a consistency boundary: code in one
|
|
10
10
|
aggregate should not reach into the internals of another. Django's `__` relation
|
|
@@ -15,17 +15,19 @@ lookups make it easy to cross those boundaries silently:
|
|
|
15
15
|
Purchase.objects.get(client__name="John")
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
`pytest-orm-boundaries` watches the
|
|
19
|
-
|
|
20
|
-
`select_related`, subqueries, or hand-written `.raw()` SQL.
|
|
18
|
+
`pytest-orm-boundaries` watches the ORM activity exercised by your test suite
|
|
19
|
+
and reports access that crosses a configured boundary - through `__` lookups,
|
|
20
|
+
`select_related`, `prefetch_related`, subqueries, or hand-written `.raw()` SQL.
|
|
21
21
|
|
|
22
22
|
## Install
|
|
23
23
|
|
|
24
|
+
Install the plugin with Django support:
|
|
25
|
+
|
|
24
26
|
```bash
|
|
25
|
-
pip install pytest-orm-boundaries
|
|
27
|
+
pip install "pytest-orm-boundaries[django]"
|
|
26
28
|
```
|
|
27
29
|
|
|
28
|
-
pytest
|
|
30
|
+
pytest discovers the plugin automatically.
|
|
29
31
|
|
|
30
32
|
## Configure
|
|
31
33
|
|
|
@@ -44,9 +46,9 @@ not checked. Without a config file the plugin emits a warning and runs no checks
|
|
|
44
46
|
|
|
45
47
|
## What it catches
|
|
46
48
|
|
|
47
|
-
The plugin
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
The plugin flags executed ORM access that spans more than one configured group.
|
|
50
|
+
In the DDD example below, each crossing couples the `purchase` and `client`
|
|
51
|
+
aggregates:
|
|
50
52
|
|
|
51
53
|
- `__` relation lookups:
|
|
52
54
|
|
|
@@ -54,7 +56,7 @@ query was written. Each example below joins the `purchase` and `client` aggregat
|
|
|
54
56
|
Purchase.objects.get(client__name="John")
|
|
55
57
|
```
|
|
56
58
|
|
|
57
|
-
- `select_related
|
|
59
|
+
- `select_related`:
|
|
58
60
|
|
|
59
61
|
```python
|
|
60
62
|
Purchase.objects.select_related("client")
|
|
@@ -78,7 +80,13 @@ query was written. Each example below joins the `purchase` and `client` aggregat
|
|
|
78
80
|
|
|
79
81
|
- Bare `cursor.execute()` - the same join reached through a raw cursor.
|
|
80
82
|
|
|
81
|
-
|
|
83
|
+
- `prefetch_related`:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
Purchase.objects.prefetch_related("client")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Queries that don't actually join across the boundary are **not** flagged - for
|
|
82
90
|
example a foreign-key lookup by id, which Django resolves without a join:
|
|
83
91
|
|
|
84
92
|
```python
|
|
@@ -91,7 +99,7 @@ Purchase.objects.filter(client__pk=42) # Django trims the join
|
|
|
91
99
|
At the end of the run, the plugin prints one grouped entry per offending place:
|
|
92
100
|
|
|
93
101
|
```
|
|
94
|
-
|
|
102
|
+
====================== orm-boundaries: boundary crossings ======================
|
|
95
103
|
1 place(s) in your code crossed aggregate boundaries, affecting 1 test(s):
|
|
96
104
|
|
|
97
105
|
bookshop/reports.py:13
|
|
@@ -100,18 +108,29 @@ bookshop/reports.py:13
|
|
|
100
108
|
1 test(s) affected:
|
|
101
109
|
test_purchases.py::test_list_purchases_with_client
|
|
102
110
|
|
|
103
|
-
orm-boundaries: FAILED - 1 boundary
|
|
111
|
+
orm-boundaries: FAILED - 1 boundary crossing(s), run exits non-zero.
|
|
104
112
|
```
|
|
105
113
|
|
|
106
114
|
Each entry names the aggregates the query crossed and the models it joined.
|
|
107
115
|
Places are ordered by how many tests they affect. Pass `-v` to see every affected test
|
|
108
116
|
(otherwise the list is capped at 5 per place).
|
|
109
117
|
|
|
110
|
-
##
|
|
118
|
+
## Allow and ignore
|
|
119
|
+
|
|
120
|
+
CQRS read models may cross boundaries intentionally, also existing application code may contain crossings you want to fix over time. [allow] and [ignore] let you tell the plugin which is which:
|
|
111
121
|
|
|
112
|
-
|
|
122
|
+
- `[allow]` - the crossing is **intentional**. Use it for code that is meant to
|
|
123
|
+
span aggregates, such as CQRS read models or cross-aggregate reports. An
|
|
124
|
+
allowed crossing is suppressed and never reported.
|
|
125
|
+
- `[ignore]` - the crossing is **known debt** you plan to fix. It is suppressed
|
|
126
|
+
for now, and the plugin reminds you when an entry is no longer needed.
|
|
113
127
|
|
|
114
128
|
```toml
|
|
129
|
+
[allow]
|
|
130
|
+
files = [
|
|
131
|
+
"app/reports/sales_summary.py",
|
|
132
|
+
]
|
|
133
|
+
|
|
115
134
|
[ignore]
|
|
116
135
|
files = [
|
|
117
136
|
"app/billing.py",
|
|
@@ -119,25 +138,35 @@ files = [
|
|
|
119
138
|
]
|
|
120
139
|
```
|
|
121
140
|
|
|
122
|
-
Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html),
|
|
141
|
+
Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html)),
|
|
123
142
|
resolved relative to pytest's root directory and matched against either:
|
|
124
143
|
|
|
125
144
|
- the file that issues the query, or
|
|
126
145
|
- the test file.
|
|
127
146
|
|
|
128
|
-
|
|
129
|
-
boundary, the plugin
|
|
147
|
+
An `[ignore]` whose file runs through the whole suite without ever crossing a
|
|
148
|
+
boundary is stale — it is clean now, so the plugin lists it for removal:
|
|
130
149
|
|
|
131
150
|
```
|
|
132
151
|
======================= orm-boundaries: stale ignores ========================
|
|
133
|
-
These [ignore] entries no longer suppress any boundary
|
|
152
|
+
These [ignore] entries no longer suppress any boundary crossing - their files are clean now.
|
|
134
153
|
Remove them from boundaries.toml:
|
|
135
154
|
- app/billing.py
|
|
136
155
|
```
|
|
137
156
|
|
|
138
|
-
|
|
157
|
+
`[allow]` entries are never reported this way. If a file sits in both sections,
|
|
158
|
+
the allow wins and its `[ignore]` entry shows up as stale to remove.
|
|
159
|
+
|
|
160
|
+
## A note on Django internals
|
|
161
|
+
|
|
162
|
+
Catching `prefetch_related` relies on Django internals that come with no stability
|
|
163
|
+
promise, so new Django releases may require compatibility updates.
|
|
164
|
+
|
|
165
|
+
## Known gaps (on the project roadmap)
|
|
139
166
|
|
|
140
|
-
-
|
|
167
|
+
- lazy attribute access (e.g. `purchase.client`);
|
|
168
|
+
- related-manager reads/writes (`client.purchases.all()`, `client.purchases.create(...)`);
|
|
169
|
+
- a direct query on another aggregate's model, e.g. `Client.objects.get(...)` written inside purchase code.
|
|
141
170
|
|
|
142
171
|
## Status
|
|
143
172
|
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pytest-orm-boundaries"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "Pytest plugin that fails tests when ORM queries cross DDD aggregate boundaries (Django supported today)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -35,6 +35,9 @@ classifiers = [
|
|
|
35
35
|
]
|
|
36
36
|
dependencies = ["pytest>=8", "sqlglot>=30,<31"]
|
|
37
37
|
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
django = ["django>=4.2"]
|
|
40
|
+
|
|
38
41
|
[project.urls]
|
|
39
42
|
Homepage = "https://github.com/evchibisova/pytest-orm-boundaries"
|
|
40
43
|
Repository = "https://github.com/evchibisova/pytest-orm-boundaries"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Per-file allows: files whose boundary crossings are intentional.
|
|
2
|
+
|
|
3
|
+
Unlike ``[ignore]`` (known debt, tracked for staleness), an ``[allow]`` marks
|
|
4
|
+
architecture that is meant to span aggregates - CQRS read models, cross-aggregate
|
|
5
|
+
reports - so a matching crossing is suppressed and never surfaces as stale.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from fnmatch import fnmatch
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AllowList:
|
|
15
|
+
"""Decides whether a crossing's call place is intentional and so allowed.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, *, patterns: Iterable[str]) -> None:
|
|
19
|
+
self._patterns = list(dict.fromkeys(patterns)) # ordered, de-duped
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def is_active(self) -> bool:
|
|
23
|
+
return bool(self._patterns)
|
|
24
|
+
|
|
25
|
+
def has_allow_for(self, *, file_paths: Iterable[str]) -> bool:
|
|
26
|
+
return any(fnmatch(f, p) for f in file_paths for p in self._patterns)
|
{pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/config.py
RENAMED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import tomllib
|
|
6
|
+
from dataclasses import dataclass
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import Any
|
|
8
9
|
|
|
@@ -13,6 +14,15 @@ class BoundariesConfigError(Exception):
|
|
|
13
14
|
"""Raised when the config file is malformed or semantically invalid."""
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class BoundariesConfig:
|
|
19
|
+
"""Parsed and validated content of a ``boundaries.toml``."""
|
|
20
|
+
|
|
21
|
+
aggregates_by_model: dict[str, str] # {"app.model" lower-cased: aggregate_name}
|
|
22
|
+
ignored_files: list[str] # globs for known debt, tracked for staleness
|
|
23
|
+
allowed_files: list[str] # globs for intentional crossings, never stale
|
|
24
|
+
|
|
25
|
+
|
|
16
26
|
def discover_config_path(*, explicit: str | None, rootpath: Path) -> Path | None:
|
|
17
27
|
"""Resolve the config path.
|
|
18
28
|
|
|
@@ -29,6 +39,15 @@ def discover_config_path(*, explicit: str | None, rootpath: Path) -> Path | None
|
|
|
29
39
|
return default if default.is_file() else None
|
|
30
40
|
|
|
31
41
|
|
|
42
|
+
def load_config(*, path: Path) -> BoundariesConfig:
|
|
43
|
+
data = _read_config(path=path)
|
|
44
|
+
return BoundariesConfig(
|
|
45
|
+
aggregates_by_model=_parse_aggregates(data=data, path=path),
|
|
46
|
+
ignored_files=_parse_file_globs(data=data, path=path, section_name="ignore"),
|
|
47
|
+
allowed_files=_parse_file_globs(data=data, path=path, section_name="allow"),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
32
51
|
def _read_config(*, path: Path) -> dict[str, Any]:
|
|
33
52
|
try:
|
|
34
53
|
with path.open("rb") as fh:
|
|
@@ -37,16 +56,14 @@ def _read_config(*, path: Path) -> dict[str, Any]:
|
|
|
37
56
|
raise BoundariesConfigError(f"{path}: invalid TOML ({exc})") from exc
|
|
38
57
|
|
|
39
58
|
|
|
40
|
-
def
|
|
41
|
-
"""
|
|
59
|
+
def _parse_aggregates(*, data: dict[str, Any], path: Path) -> dict[str, str]:
|
|
60
|
+
"""Build a {model_label: aggregate_name} map from the ``[aggregates]`` table.
|
|
42
61
|
|
|
43
62
|
Model labels are lower-cased ("app_label.modelname") for case-insensitive
|
|
44
63
|
matching.
|
|
45
64
|
"""
|
|
46
|
-
config = _read_config(path=path)
|
|
47
|
-
|
|
48
65
|
aggregates_by_model: dict[str, str] = {}
|
|
49
|
-
for aggregate, members in
|
|
66
|
+
for aggregate, members in data.get("aggregates", {}).items():
|
|
50
67
|
if isinstance(members, dict):
|
|
51
68
|
members = members.get("models", [])
|
|
52
69
|
if not isinstance(members, list):
|
|
@@ -67,25 +84,25 @@ def load_aggregates_from_config(*, path: Path) -> dict[str, str]:
|
|
|
67
84
|
return aggregates_by_model
|
|
68
85
|
|
|
69
86
|
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if not isinstance(
|
|
76
|
-
raise BoundariesConfigError(f"{path}: [
|
|
87
|
+
def _parse_file_globs(
|
|
88
|
+
*, data: dict[str, Any], path: Path, section_name: str
|
|
89
|
+
) -> list[str]:
|
|
90
|
+
"""Read ``[allow]`` and ``[ignore] files`` into a list of glob patterns."""
|
|
91
|
+
section_data = data.get(section_name, {})
|
|
92
|
+
if not isinstance(section_data, dict):
|
|
93
|
+
raise BoundariesConfigError(f"{path}: [{section_name}] must be a table")
|
|
77
94
|
|
|
78
|
-
files =
|
|
95
|
+
files = section_data.get("files", [])
|
|
79
96
|
if not isinstance(files, list):
|
|
80
97
|
raise BoundariesConfigError(
|
|
81
|
-
f"{path}: [
|
|
98
|
+
f"{path}: [{section_name}] files must be a list of glob patterns"
|
|
82
99
|
)
|
|
83
100
|
|
|
84
101
|
patterns: list[str] = []
|
|
85
102
|
for entry in files:
|
|
86
103
|
if not isinstance(entry, str):
|
|
87
104
|
raise BoundariesConfigError(
|
|
88
|
-
f"{path}: [
|
|
105
|
+
f"{path}: [{section_name}] files has a non-string entry: {entry!r}"
|
|
89
106
|
)
|
|
90
107
|
patterns.append(entry)
|
|
91
108
|
return patterns
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Track aggregate crossings: apply the rule to models and accumulate the crossings found."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from pytest_orm_boundaries.callstack import find_frames_inside_project
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
|
|
14
|
+
from pytest_orm_boundaries.allows import AllowList
|
|
15
|
+
from pytest_orm_boundaries.ignores import IgnoreTracker
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class CrossingRecord:
|
|
20
|
+
"""One offending call place and the tests that reached it."""
|
|
21
|
+
|
|
22
|
+
file: str
|
|
23
|
+
line_number: int
|
|
24
|
+
crossed_aggregates: tuple[str, ...] # ("order", "payment")
|
|
25
|
+
involved_models: tuple[str, ...] # ("order.Invoice", "payrolls.IncomePayment")
|
|
26
|
+
tests: set[str] = field(default_factory=set)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CrossingTracker:
|
|
30
|
+
"""Checks label sets against the aggregate rule and records the crossings,
|
|
31
|
+
skipping allowed and ignored files and naming each crossing's call place and
|
|
32
|
+
test."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
aggregates_config: dict[str, str],
|
|
38
|
+
allow_list: AllowList,
|
|
39
|
+
ignore_tracker: IgnoreTracker,
|
|
40
|
+
root: Path,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._aggregates_config = aggregates_config
|
|
43
|
+
self._allow_list = allow_list
|
|
44
|
+
self._ignore_tracker = ignore_tracker
|
|
45
|
+
self._root_path = Path(root)
|
|
46
|
+
self._current_test: str | None = None
|
|
47
|
+
self._crossings: dict[tuple[str, int, tuple[str, ...]], CrossingRecord] = {}
|
|
48
|
+
|
|
49
|
+
def set_current_test(self, nodeid: str | None) -> None:
|
|
50
|
+
"""Remember which test is running so a recorded crossing can name it."""
|
|
51
|
+
self._current_test = nodeid
|
|
52
|
+
|
|
53
|
+
def check(self, *, label_sets: Iterable[Iterable[str]]) -> None:
|
|
54
|
+
"""Record a crossing for each label set that spans aggregates, unless an
|
|
55
|
+
allow or an ignore covers the call place.
|
|
56
|
+
|
|
57
|
+
Allow wins over ignore: an allowed crossing is suppressed without marking
|
|
58
|
+
the ignore used, so an ignore that only overlaps an allow surfaces as
|
|
59
|
+
stale and can be removed as redundant.
|
|
60
|
+
"""
|
|
61
|
+
# ``frames`` is the in-project part of the call stack
|
|
62
|
+
frames: list[tuple[str, int]] | None = None
|
|
63
|
+
file_paths: set[str] | None = None
|
|
64
|
+
if self._allow_list.is_active or self._ignore_tracker.is_active:
|
|
65
|
+
frames = find_frames_inside_project(root=self._root_path)
|
|
66
|
+
file_paths = {path for path, _ in frames}
|
|
67
|
+
self._ignore_tracker.mark_seen(file_paths=file_paths)
|
|
68
|
+
|
|
69
|
+
for labels in label_sets:
|
|
70
|
+
crossing = self.find_crossing(labels=labels)
|
|
71
|
+
if crossing is None:
|
|
72
|
+
continue
|
|
73
|
+
if file_paths is not None:
|
|
74
|
+
if self._allow_list.has_allow_for(file_paths=file_paths):
|
|
75
|
+
continue
|
|
76
|
+
if self._ignore_tracker.has_ignore_for(file_paths=file_paths):
|
|
77
|
+
self._ignore_tracker.mark_used(file_paths=file_paths)
|
|
78
|
+
continue
|
|
79
|
+
if frames is None:
|
|
80
|
+
frames = find_frames_inside_project(root=self._root_path)
|
|
81
|
+
call_place = frames[0] if frames else None
|
|
82
|
+
self.add_record(
|
|
83
|
+
call_place=call_place, crossing=crossing, test=self._current_test
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def find_crossing(
|
|
87
|
+
self, *, labels: Iterable[str]
|
|
88
|
+
) -> tuple[tuple[str, ...], tuple[str, ...]] | None:
|
|
89
|
+
"""Return ``(crossed aggregate names, involved model labels)`` if the
|
|
90
|
+
labels span more than one aggregate, else None.
|
|
91
|
+
|
|
92
|
+
e.g. ``(("order", "payment"), ("order.Invoice", "payrolls.IncomePayment"))``.
|
|
93
|
+
|
|
94
|
+
``labels`` are model labels.
|
|
95
|
+
"""
|
|
96
|
+
aggregate_by_label = {
|
|
97
|
+
label: aggregate
|
|
98
|
+
for label in labels
|
|
99
|
+
if (aggregate := self._aggregates_config.get(label.lower())) is not None
|
|
100
|
+
}
|
|
101
|
+
aggregates = set(aggregate_by_label.values())
|
|
102
|
+
if len(aggregates) <= 1:
|
|
103
|
+
return None
|
|
104
|
+
return tuple(sorted(aggregates)), tuple(sorted(aggregate_by_label.keys()))
|
|
105
|
+
|
|
106
|
+
def add_record(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
call_place: tuple[str, int] | None,
|
|
110
|
+
crossing: tuple[tuple[str, ...], tuple[str, ...]],
|
|
111
|
+
test: str | None,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Add one crossing, merging into the record for its call place."""
|
|
114
|
+
crossed_aggregates, involved_models = crossing
|
|
115
|
+
file, line = call_place if call_place is not None else ("<unknown>", 0)
|
|
116
|
+
key = (file, line, crossed_aggregates)
|
|
117
|
+
record = self._crossings.get(key)
|
|
118
|
+
if record is None:
|
|
119
|
+
record = CrossingRecord(
|
|
120
|
+
file=file,
|
|
121
|
+
line_number=line,
|
|
122
|
+
crossed_aggregates=crossed_aggregates,
|
|
123
|
+
involved_models=involved_models,
|
|
124
|
+
)
|
|
125
|
+
self._crossings[key] = record
|
|
126
|
+
if test is not None:
|
|
127
|
+
record.tests.add(test)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def crossings(self) -> list[CrossingRecord]:
|
|
131
|
+
"""Recorded crossings, most-affecting first."""
|
|
132
|
+
return sorted(
|
|
133
|
+
self._crossings.values(),
|
|
134
|
+
key=lambda v: (-len(v.tests), v.file, v.line_number),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def find_stale_patterns(self) -> list[str]:
|
|
138
|
+
"""Ignore globs whose file ran but never crossed -- safe to delete."""
|
|
139
|
+
return self._ignore_tracker.find_stale_patterns()
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Django boundary guard: watches the queries a test suite executes and checks crossing.
|
|
2
|
+
|
|
3
|
+
Two sources of crossings: the SQL of each executed query, and the relation path
|
|
4
|
+
of each ``prefetch_related``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import weakref
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from functools import wraps
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from pytest_orm_boundaries.crossings import CrossingTracker
|
|
16
|
+
from pytest_orm_boundaries.model_resolution import resolve_labels
|
|
17
|
+
from pytest_orm_boundaries.prefetch_resolution import resolve_prefetch_step_models
|
|
18
|
+
from pytest_orm_boundaries.sql_parsing import extract_table_names, looks_like_data_query
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from django.db.backends.base.base import BaseDatabaseWrapper
|
|
22
|
+
|
|
23
|
+
from pytest_orm_boundaries.allows import AllowList
|
|
24
|
+
from pytest_orm_boundaries.crossings import CrossingRecord
|
|
25
|
+
from pytest_orm_boundaries.ignores import IgnoreTracker
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class _HookState:
|
|
30
|
+
"""On/off switch for one installed prefetch wrapper.
|
|
31
|
+
|
|
32
|
+
We monkey-patch Django's ``prefetch_related_objects``. On uninstall we
|
|
33
|
+
restore the original when our wrapper is still on top - but if another tool
|
|
34
|
+
wrapped it above ours, that tool holds a reference to our wrapper and we
|
|
35
|
+
can't take it out of the chain. So instead of removing it we set ``active``
|
|
36
|
+
to False: the wrapper keeps being called but does nothing.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
active: bool
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BoundaryGuard:
|
|
43
|
+
"""Records aggregate crossings in the queries a test suite executes."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
aggregates_config: dict[str, str],
|
|
49
|
+
allow_list: AllowList,
|
|
50
|
+
ignore_tracker: IgnoreTracker,
|
|
51
|
+
root: Path,
|
|
52
|
+
) -> None:
|
|
53
|
+
self._tracker = CrossingTracker(
|
|
54
|
+
aggregates_config=aggregates_config,
|
|
55
|
+
allow_list=allow_list,
|
|
56
|
+
ignore_tracker=ignore_tracker,
|
|
57
|
+
root=root,
|
|
58
|
+
)
|
|
59
|
+
self._attached_connections: list[BaseDatabaseWrapper] = []
|
|
60
|
+
self._original_prefetch = None
|
|
61
|
+
self._prefetch_wrapper = None
|
|
62
|
+
self._prefetch_hook_state: _HookState | None = None
|
|
63
|
+
|
|
64
|
+
def install(self) -> None:
|
|
65
|
+
"""Attach to every DB connection: those open now and any opened later
|
|
66
|
+
(each new connection fires ``connection_created``).
|
|
67
|
+
"""
|
|
68
|
+
from django.db import connections
|
|
69
|
+
from django.db.backends.signals import connection_created
|
|
70
|
+
|
|
71
|
+
connection_created.connect(self._handle_connection_created, weak=False)
|
|
72
|
+
for connection in connections.all(initialized_only=True):
|
|
73
|
+
self._attach_wrapper(connection)
|
|
74
|
+
self._install_prefetch_hook()
|
|
75
|
+
|
|
76
|
+
def uninstall(self) -> None:
|
|
77
|
+
from django.db.backends.signals import connection_created
|
|
78
|
+
|
|
79
|
+
self._remove_prefetch_hook()
|
|
80
|
+
connection_created.disconnect(self._handle_connection_created)
|
|
81
|
+
for connection in self._attached_connections:
|
|
82
|
+
try:
|
|
83
|
+
connection.execute_wrappers.remove(self._execute_wrapper)
|
|
84
|
+
except ValueError:
|
|
85
|
+
pass
|
|
86
|
+
self._attached_connections.clear()
|
|
87
|
+
|
|
88
|
+
def _handle_connection_created(
|
|
89
|
+
self, *, connection: BaseDatabaseWrapper, **_
|
|
90
|
+
) -> None:
|
|
91
|
+
self._attach_wrapper(connection)
|
|
92
|
+
|
|
93
|
+
def _attach_wrapper(self, connection: BaseDatabaseWrapper) -> None:
|
|
94
|
+
if self._execute_wrapper not in connection.execute_wrappers:
|
|
95
|
+
connection.execute_wrappers.append(self._execute_wrapper)
|
|
96
|
+
self._attached_connections.append(connection)
|
|
97
|
+
|
|
98
|
+
def _install_prefetch_hook(self) -> None:
|
|
99
|
+
"""Wrap django ``prefetch_related_objects`` so each prefetch reports the
|
|
100
|
+
models it walks between.
|
|
101
|
+
"""
|
|
102
|
+
import django.db.models.query as query_module
|
|
103
|
+
|
|
104
|
+
if self._prefetch_wrapper is not None:
|
|
105
|
+
# Already installed. A second install would capture our own wrapper
|
|
106
|
+
# as ``original`` and orphan the first _HookState
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
original = query_module.prefetch_related_objects
|
|
110
|
+
# hook can be switched off even when it can't be removed from the chain.
|
|
111
|
+
state = _HookState(active=True)
|
|
112
|
+
# strong ref is config.stash[guard_key]
|
|
113
|
+
guard_ref = weakref.ref(self)
|
|
114
|
+
|
|
115
|
+
@wraps(original)
|
|
116
|
+
def prefetch_with_check(model_instances, *lookups):
|
|
117
|
+
result = original(model_instances, *lookups)
|
|
118
|
+
guard = guard_ref()
|
|
119
|
+
if state.active and guard is not None:
|
|
120
|
+
guard._handle_prefetch(
|
|
121
|
+
model_instances=model_instances, lookups=lookups
|
|
122
|
+
)
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
self._prefetch_hook_state = state
|
|
126
|
+
self._original_prefetch = original
|
|
127
|
+
self._prefetch_wrapper = prefetch_with_check
|
|
128
|
+
query_module.prefetch_related_objects = prefetch_with_check
|
|
129
|
+
|
|
130
|
+
def _remove_prefetch_hook(self) -> None:
|
|
131
|
+
import django.db.models.query as query_module
|
|
132
|
+
|
|
133
|
+
if self._prefetch_wrapper is None:
|
|
134
|
+
return
|
|
135
|
+
# Disable our hook unconditionally. Restore the previous callable only
|
|
136
|
+
# when ours is still topmost; otherwise another wrapper may still
|
|
137
|
+
# reference it, and ours stays in the chain as an inert no-op.
|
|
138
|
+
self._prefetch_hook_state.active = False
|
|
139
|
+
if query_module.prefetch_related_objects is self._prefetch_wrapper:
|
|
140
|
+
query_module.prefetch_related_objects = self._original_prefetch
|
|
141
|
+
self._prefetch_hook_state = None
|
|
142
|
+
self._prefetch_wrapper = None
|
|
143
|
+
self._original_prefetch = None
|
|
144
|
+
|
|
145
|
+
def _execute_wrapper(self, execute, sql, params, many, context):
|
|
146
|
+
if looks_like_data_query(sql):
|
|
147
|
+
self._handle_query(sql, context["connection"].vendor)
|
|
148
|
+
return execute(sql, params, many, context)
|
|
149
|
+
|
|
150
|
+
def set_current_test(self, nodeid: str | None) -> None:
|
|
151
|
+
"""Remember which test is running so a recorded crossing can name it."""
|
|
152
|
+
self._tracker.set_current_test(nodeid)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def crossings(self) -> list[CrossingRecord]:
|
|
156
|
+
return self._tracker.crossings
|
|
157
|
+
|
|
158
|
+
def find_stale_patterns(self) -> list[str]:
|
|
159
|
+
return self._tracker.find_stale_patterns()
|
|
160
|
+
|
|
161
|
+
def _handle_query(self, sql: str, vendor: str) -> None:
|
|
162
|
+
"""Map one executed data query to its table labels and check them."""
|
|
163
|
+
table_names = extract_table_names(sql, vendor)
|
|
164
|
+
if table_names is None:
|
|
165
|
+
return
|
|
166
|
+
labels = resolve_labels(table_names)
|
|
167
|
+
self._tracker.check(label_sets=[labels])
|
|
168
|
+
|
|
169
|
+
def _handle_prefetch(self, *, model_instances, lookups) -> None:
|
|
170
|
+
"""Map one prefetch to the model pair per relation step and check them."""
|
|
171
|
+
if not model_instances:
|
|
172
|
+
return
|
|
173
|
+
source_model = type(model_instances[0])
|
|
174
|
+
step_model_pairs = resolve_prefetch_step_models(
|
|
175
|
+
source_model=source_model, lookups=lookups
|
|
176
|
+
)
|
|
177
|
+
if not step_model_pairs:
|
|
178
|
+
return
|
|
179
|
+
self._tracker.check(label_sets=step_model_pairs)
|
{pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/ignores.py
RENAMED
|
@@ -7,7 +7,7 @@ from fnmatch import fnmatch
|
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class IgnoreTracker:
|
|
10
|
-
"""Decides whether a
|
|
10
|
+
"""Decides whether a crossing is ignored, and reports ignore globs that went
|
|
11
11
|
stale - their file ran but never crossed a boundary."""
|
|
12
12
|
|
|
13
13
|
def __init__(self, *, patterns: Iterable[str]) -> None:
|
{pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/plugin.py
RENAMED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import importlib.util
|
|
5
6
|
from pathlib import Path
|
|
6
|
-
from typing import TYPE_CHECKING
|
|
7
7
|
|
|
8
8
|
import pytest
|
|
9
9
|
|
|
@@ -12,18 +12,18 @@ from pytest_orm_boundaries.config import (
|
|
|
12
12
|
CONFIG_FILE_NAME,
|
|
13
13
|
BoundariesConfigError,
|
|
14
14
|
discover_config_path,
|
|
15
|
+
load_config,
|
|
15
16
|
)
|
|
16
|
-
from pytest_orm_boundaries.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
from pytest_orm_boundaries.guard import BoundaryGuard
|
|
17
|
+
from pytest_orm_boundaries.allows import AllowList
|
|
18
|
+
from pytest_orm_boundaries.guard import BoundaryGuard
|
|
19
|
+
from pytest_orm_boundaries.ignores import IgnoreTracker
|
|
20
20
|
|
|
21
21
|
config_path_key = pytest.StashKey[Path | None]()
|
|
22
22
|
guard_key = pytest.StashKey["BoundaryGuard"]()
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
class BoundariesConfigWarning(UserWarning):
|
|
26
|
-
"""Plugin is installed but no config file
|
|
26
|
+
"""Plugin is installed but won't run: no config file, or Django is absent."""
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
def _installed_guard(config: pytest.Config) -> BoundaryGuard | None:
|
|
@@ -51,6 +51,22 @@ def pytest_addoption(parser: pytest.Parser) -> None:
|
|
|
51
51
|
)
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def _build_guard(*, rootpath: Path, config_path: Path) -> BoundaryGuard | None:
|
|
55
|
+
"""Build the guard from config, or None if no aggregates are declared."""
|
|
56
|
+
config = load_config(path=config_path)
|
|
57
|
+
if not config.aggregates_by_model:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
allow_list = AllowList(patterns=config.allowed_files)
|
|
61
|
+
ignore_tracker = IgnoreTracker(patterns=config.ignored_files)
|
|
62
|
+
return BoundaryGuard(
|
|
63
|
+
aggregates_config=config.aggregates_by_model,
|
|
64
|
+
allow_list=allow_list,
|
|
65
|
+
ignore_tracker=ignore_tracker,
|
|
66
|
+
root=rootpath,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
54
70
|
def pytest_configure(config: pytest.Config) -> None:
|
|
55
71
|
"""Resolve config and install the guard, stashing both for later hooks:
|
|
56
72
|
the config path for the report header, the guard for teardown and reporting.
|
|
@@ -66,7 +82,15 @@ def pytest_configure(config: pytest.Config) -> None:
|
|
|
66
82
|
config.issue_config_time_warning(warning, stacklevel=2)
|
|
67
83
|
return
|
|
68
84
|
|
|
69
|
-
|
|
85
|
+
if importlib.util.find_spec("django") is None:
|
|
86
|
+
warning = BoundariesConfigWarning(
|
|
87
|
+
f"{CONFIG_FILE_NAME} found but Django is not installed, no checks "
|
|
88
|
+
"will run (install pytest-orm-boundaries[django])"
|
|
89
|
+
)
|
|
90
|
+
config.issue_config_time_warning(warning, stacklevel=2)
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
guard = _build_guard(rootpath=config.rootpath, config_path=config_path)
|
|
70
94
|
if guard is not None:
|
|
71
95
|
guard.install()
|
|
72
96
|
config.stash[guard_key] = guard
|
|
@@ -82,7 +106,7 @@ def pytest_unconfigure(config: pytest.Config) -> None:
|
|
|
82
106
|
|
|
83
107
|
@pytest.hookimpl(wrapper=True)
|
|
84
108
|
def pytest_runtest_protocol(item: pytest.Item, nextitem: pytest.Item | None):
|
|
85
|
-
"""Tell the guard which test is running so a
|
|
109
|
+
"""Tell the guard which test is running so a crossing can name it."""
|
|
86
110
|
guard = _installed_guard(item.config)
|
|
87
111
|
if guard is not None:
|
|
88
112
|
guard.set_current_test(item.nodeid)
|
|
@@ -96,7 +120,7 @@ def pytest_runtest_protocol(item: pytest.Item, nextitem: pytest.Item | None):
|
|
|
96
120
|
def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
|
|
97
121
|
"""A clean-but-violating run must still fail the run (via the exit code)."""
|
|
98
122
|
guard = _installed_guard(session.config)
|
|
99
|
-
if guard is not None and guard.
|
|
123
|
+
if guard is not None and guard.crossings and exitstatus == pytest.ExitCode.OK:
|
|
100
124
|
session.exitstatus = pytest.ExitCode.TESTS_FAILED
|
|
101
125
|
|
|
102
126
|
|
|
@@ -108,9 +132,9 @@ def pytest_terminal_summary(
|
|
|
108
132
|
guard = _installed_guard(config)
|
|
109
133
|
if guard is None:
|
|
110
134
|
return
|
|
111
|
-
report.
|
|
135
|
+
report.report_crossings(
|
|
112
136
|
terminalreporter=terminalreporter,
|
|
113
|
-
|
|
137
|
+
crossings=guard.crossings,
|
|
114
138
|
verbose=config.getoption("verbose", 0) > 0,
|
|
115
139
|
)
|
|
116
140
|
stale = guard.find_stale_patterns()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Turn a ``prefetch_related`` lookup into the model pairs it steps between.
|
|
2
|
+
|
|
3
|
+
A prefetch loads the other aggregate with a separate single-table query, so the
|
|
4
|
+
SQL alone never looks like a boundary crossing. Django's prefetch,
|
|
5
|
+
though, is handed the exact relation path it walks; so the guard can apply the aggregate rule to
|
|
6
|
+
each step without inspecting any SQL.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
|
|
16
|
+
from django.db.models import Model
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_prefetch_step_models(
|
|
20
|
+
*, source_model: type[Model],
|
|
21
|
+
lookups: Iterable[object],
|
|
22
|
+
) -> list[tuple[str, str]]:
|
|
23
|
+
"""Return one ``(owner_model_label, target_model_label)`` pair per relation step.
|
|
24
|
+
|
|
25
|
+
A nested lookup such as ``lines__product`` yields a pair for each step.
|
|
26
|
+
"""
|
|
27
|
+
from django.db.models.constants import LOOKUP_SEP
|
|
28
|
+
|
|
29
|
+
step_model_pairs: list[tuple[str, str]] = []
|
|
30
|
+
for lookup in lookups:
|
|
31
|
+
path = _extract_lookup_path(lookup=lookup)
|
|
32
|
+
if path is None:
|
|
33
|
+
continue
|
|
34
|
+
owner = source_model
|
|
35
|
+
for lookup_step in path.split(LOOKUP_SEP):
|
|
36
|
+
target = _resolve_target_lookup_step_model(model=owner, lookup_step=lookup_step)
|
|
37
|
+
if target is None:
|
|
38
|
+
break
|
|
39
|
+
step_models = (owner._meta.label, target._meta.label)
|
|
40
|
+
step_model_pairs.append(step_models)
|
|
41
|
+
owner = target
|
|
42
|
+
return step_model_pairs
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _extract_lookup_path(*, lookup: object) -> str | None:
|
|
46
|
+
if isinstance(lookup, str):
|
|
47
|
+
return lookup
|
|
48
|
+
return getattr(lookup, "prefetch_through", None)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _resolve_target_lookup_step_model(
|
|
52
|
+
*,
|
|
53
|
+
model: type[Model],
|
|
54
|
+
lookup_step: str,
|
|
55
|
+
) -> type[Model] | None:
|
|
56
|
+
from django.core.exceptions import FieldDoesNotExist
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
field = model._meta.get_field(lookup_step)
|
|
60
|
+
except FieldDoesNotExist:
|
|
61
|
+
field = None
|
|
62
|
+
if field is not None and field.is_relation:
|
|
63
|
+
return field.related_model
|
|
64
|
+
# A reverse relation without ``related_name`` is reached by its accessor
|
|
65
|
+
# (``order_set``), a name get_field doesn't know; match it by accessor.
|
|
66
|
+
for candidate in model._meta.get_fields():
|
|
67
|
+
if not candidate.is_relation:
|
|
68
|
+
continue
|
|
69
|
+
read_accessor_name = getattr(candidate, "get_accessor_name", None)
|
|
70
|
+
if read_accessor_name is not None and read_accessor_name() == lookup_step:
|
|
71
|
+
return candidate.related_model
|
|
72
|
+
return None
|
{pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/report.py
RENAMED
|
@@ -11,61 +11,61 @@ if TYPE_CHECKING:
|
|
|
11
11
|
|
|
12
12
|
import pytest
|
|
13
13
|
|
|
14
|
-
from pytest_orm_boundaries.
|
|
14
|
+
from pytest_orm_boundaries.crossings import CrossingRecord
|
|
15
15
|
|
|
16
16
|
MAX_TESTS_SHOWN = 5
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
def
|
|
19
|
+
def report_crossings(
|
|
20
20
|
*,
|
|
21
21
|
terminalreporter: pytest.TerminalReporter,
|
|
22
|
-
|
|
22
|
+
crossings: list[CrossingRecord],
|
|
23
23
|
verbose: bool = False,
|
|
24
24
|
) -> None:
|
|
25
25
|
"""Print one grouped entry per offending call place: which aggregates the
|
|
26
|
-
query crossed, the models
|
|
26
|
+
query crossed, the models involved, and which tests reached it.
|
|
27
27
|
"""
|
|
28
|
-
if not
|
|
28
|
+
if not crossings:
|
|
29
29
|
return
|
|
30
30
|
|
|
31
|
-
affected = len({test for
|
|
31
|
+
affected = len({test for crossing in crossings for test in crossing.tests})
|
|
32
32
|
terminalreporter.section(
|
|
33
|
-
"orm-boundaries: boundary
|
|
33
|
+
"orm-boundaries: boundary crossings", red=True, bold=True
|
|
34
34
|
)
|
|
35
35
|
terminalreporter.write_line(
|
|
36
|
-
f"{len(
|
|
36
|
+
f"{len(crossings)} place(s) in your code crossed aggregate boundaries, "
|
|
37
37
|
f"affecting {affected} test(s):",
|
|
38
38
|
red=True,
|
|
39
39
|
)
|
|
40
|
-
for
|
|
41
|
-
|
|
42
|
-
terminalreporter=terminalreporter,
|
|
40
|
+
for crossing in crossings:
|
|
41
|
+
_write_crossing(
|
|
42
|
+
terminalreporter=terminalreporter, crossing=crossing, verbose=verbose
|
|
43
43
|
)
|
|
44
44
|
terminalreporter.write_line("")
|
|
45
45
|
terminalreporter.write_line(
|
|
46
|
-
f"orm-boundaries: FAILED - {len(
|
|
46
|
+
f"orm-boundaries: FAILED - {len(crossings)} boundary crossing(s), "
|
|
47
47
|
"run exits non-zero.",
|
|
48
48
|
red=True,
|
|
49
49
|
bold=True,
|
|
50
50
|
)
|
|
51
51
|
|
|
52
52
|
|
|
53
|
-
def
|
|
53
|
+
def _write_crossing(
|
|
54
54
|
*,
|
|
55
55
|
terminalreporter: pytest.TerminalReporter,
|
|
56
|
-
|
|
56
|
+
crossing: CrossingRecord,
|
|
57
57
|
verbose: bool,
|
|
58
58
|
) -> None:
|
|
59
59
|
terminalreporter.write_line("")
|
|
60
|
-
terminalreporter.write_line(f"{
|
|
61
|
-
aggregates = " ↔ ".join(
|
|
60
|
+
terminalreporter.write_line(f"{crossing.file}:{crossing.line_number}", bold=True)
|
|
61
|
+
aggregates = " ↔ ".join(crossing.crossed_aggregates)
|
|
62
62
|
terminalreporter.write_line(
|
|
63
63
|
f" crossed aggregates: {aggregates}", yellow=True, bold=True
|
|
64
64
|
)
|
|
65
|
-
models = ", ".join(
|
|
65
|
+
models = ", ".join(crossing.involved_models)
|
|
66
66
|
terminalreporter.write_line(f" models: {models}")
|
|
67
67
|
|
|
68
|
-
tests = sorted(
|
|
68
|
+
tests = sorted(crossing.tests)
|
|
69
69
|
if not tests:
|
|
70
70
|
terminalreporter.write_line(" tests affected: (none captured)", light=True)
|
|
71
71
|
return
|
|
@@ -88,7 +88,7 @@ def report_stale_ignores(
|
|
|
88
88
|
return
|
|
89
89
|
terminalreporter.section("orm-boundaries: stale ignores", yellow=True, bold=True)
|
|
90
90
|
terminalreporter.write_line(
|
|
91
|
-
"These [ignore] entries no longer suppress any boundary
|
|
91
|
+
"These [ignore] entries no longer suppress any boundary crossing - "
|
|
92
92
|
f"their files are clean now. Remove them from {CONFIG_FILE_NAME}:",
|
|
93
93
|
yellow=True,
|
|
94
94
|
)
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
"""Django boundary guard: applies the aggregate-crossing rule to the queries a
|
|
2
|
-
test suite executes and records the crossings.
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
from __future__ import annotations
|
|
6
|
-
|
|
7
|
-
from dataclasses import dataclass, field
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from typing import TYPE_CHECKING
|
|
10
|
-
|
|
11
|
-
from pytest_orm_boundaries.callstack import find_frames_inside_project
|
|
12
|
-
from pytest_orm_boundaries.config import (
|
|
13
|
-
load_aggregates_from_config,
|
|
14
|
-
load_ignored_files_from_config,
|
|
15
|
-
)
|
|
16
|
-
from pytest_orm_boundaries.ignores import IgnoreTracker
|
|
17
|
-
from pytest_orm_boundaries.model_resolution import resolve_labels
|
|
18
|
-
from pytest_orm_boundaries.sql_parsing import extract_table_names, looks_like_data_query
|
|
19
|
-
|
|
20
|
-
if TYPE_CHECKING:
|
|
21
|
-
from collections.abc import Iterable
|
|
22
|
-
|
|
23
|
-
from django.db.backends.base.base import BaseDatabaseWrapper
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
@dataclass
|
|
27
|
-
class ViolationRecord:
|
|
28
|
-
"""One offending call place and the tests that reached it.
|
|
29
|
-
|
|
30
|
-
Grouped by call place so a crossing shared by many tests is one entry,
|
|
31
|
-
not one line per test.
|
|
32
|
-
"""
|
|
33
|
-
|
|
34
|
-
file: str
|
|
35
|
-
line_number: int
|
|
36
|
-
crossed_aggregates: tuple[str, ...] # ("order", "payment")
|
|
37
|
-
joined_models: tuple[str, ...] # ("order.Invoice", "payrolls.IncomePayment")
|
|
38
|
-
tests: set[str] = field(default_factory=set)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
class BoundaryGuard:
|
|
42
|
-
"""Records aggregate crossings in the queries a test suite executes."""
|
|
43
|
-
|
|
44
|
-
def __init__(
|
|
45
|
-
self,
|
|
46
|
-
*,
|
|
47
|
-
aggregates_config: dict[str, str],
|
|
48
|
-
ignore_tracker: IgnoreTracker,
|
|
49
|
-
root: Path,
|
|
50
|
-
) -> None:
|
|
51
|
-
self._aggregates_config = aggregates_config
|
|
52
|
-
self._ignore_tracker = ignore_tracker
|
|
53
|
-
self._root_path = Path(root)
|
|
54
|
-
self._current_test: str | None = None
|
|
55
|
-
self._violations: dict[tuple[str, int, tuple[str, ...]], ViolationRecord] = {}
|
|
56
|
-
self._attached_connections: list[BaseDatabaseWrapper] = []
|
|
57
|
-
|
|
58
|
-
def install(self) -> None:
|
|
59
|
-
"""Attach to every DB connection: those open now and any opened later
|
|
60
|
-
(each new connection fires ``connection_created``).
|
|
61
|
-
"""
|
|
62
|
-
from django.db import connections
|
|
63
|
-
from django.db.backends.signals import connection_created
|
|
64
|
-
|
|
65
|
-
connection_created.connect(self._handle_connection_created, weak=False)
|
|
66
|
-
for connection in connections.all(initialized_only=True):
|
|
67
|
-
self._attach_wrapper(connection)
|
|
68
|
-
|
|
69
|
-
def uninstall(self) -> None:
|
|
70
|
-
from django.db.backends.signals import connection_created
|
|
71
|
-
|
|
72
|
-
connection_created.disconnect(self._handle_connection_created)
|
|
73
|
-
for connection in self._attached_connections:
|
|
74
|
-
try:
|
|
75
|
-
connection.execute_wrappers.remove(self._execute_wrapper)
|
|
76
|
-
except ValueError:
|
|
77
|
-
pass
|
|
78
|
-
self._attached_connections.clear()
|
|
79
|
-
|
|
80
|
-
def _handle_connection_created(
|
|
81
|
-
self, *, connection: BaseDatabaseWrapper, **_
|
|
82
|
-
) -> None:
|
|
83
|
-
self._attach_wrapper(connection)
|
|
84
|
-
|
|
85
|
-
def _attach_wrapper(self, connection: BaseDatabaseWrapper) -> None:
|
|
86
|
-
if self._execute_wrapper not in connection.execute_wrappers:
|
|
87
|
-
connection.execute_wrappers.append(self._execute_wrapper)
|
|
88
|
-
self._attached_connections.append(connection)
|
|
89
|
-
|
|
90
|
-
def _execute_wrapper(self, execute, sql, params, many, context):
|
|
91
|
-
if looks_like_data_query(sql):
|
|
92
|
-
self._check_violations_in_query(sql, context["connection"].vendor)
|
|
93
|
-
return execute(sql, params, many, context)
|
|
94
|
-
|
|
95
|
-
def set_current_test(self, nodeid: str | None) -> None:
|
|
96
|
-
"""Remember which test is running so a recorded crossing can name it."""
|
|
97
|
-
self._current_test = nodeid
|
|
98
|
-
|
|
99
|
-
@property
|
|
100
|
-
def violations(self) -> list[ViolationRecord]:
|
|
101
|
-
"""Recorded crossings, most-affecting first (then by call place)."""
|
|
102
|
-
return sorted(
|
|
103
|
-
self._violations.values(),
|
|
104
|
-
key=lambda v: (-len(v.tests), v.file, v.line_number),
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
def find_stale_patterns(self) -> list[str]:
|
|
108
|
-
return self._ignore_tracker.find_stale_patterns()
|
|
109
|
-
|
|
110
|
-
def _check_violations_in_query(self, sql: str, vendor: str) -> None:
|
|
111
|
-
"""Handle one executed data query: gather stack context, apply the
|
|
112
|
-
aggregate rule, and record a crossing (unless it's clean or ignored).
|
|
113
|
-
"""
|
|
114
|
-
# ``frames`` is the in-project part of this query's call stack,
|
|
115
|
-
# used by the ignore/stale check and the report.
|
|
116
|
-
frames: list[tuple[str, int]] | None = None
|
|
117
|
-
file_paths: set[str] | None = None
|
|
118
|
-
if self._ignore_tracker.is_active:
|
|
119
|
-
frames = find_frames_inside_project(root=self._root_path)
|
|
120
|
-
file_paths = {path for path, _ in frames}
|
|
121
|
-
self._ignore_tracker.mark_seen(file_paths=file_paths)
|
|
122
|
-
|
|
123
|
-
table_names = extract_table_names(sql, vendor)
|
|
124
|
-
if table_names is None: # unparseable data query -- skip it (see ROADMAP)
|
|
125
|
-
return
|
|
126
|
-
labels = resolve_labels(table_names)
|
|
127
|
-
crossing = self._find_crossing(labels=labels)
|
|
128
|
-
if crossing is None:
|
|
129
|
-
return
|
|
130
|
-
|
|
131
|
-
if file_paths is not None and self._ignore_tracker.has_ignore_for(
|
|
132
|
-
file_paths=file_paths
|
|
133
|
-
):
|
|
134
|
-
self._ignore_tracker.mark_used(file_paths=file_paths)
|
|
135
|
-
return
|
|
136
|
-
|
|
137
|
-
if frames is None:
|
|
138
|
-
frames = find_frames_inside_project(root=self._root_path)
|
|
139
|
-
self._record_violation(
|
|
140
|
-
call_place=frames[0] if frames else None, crossing=crossing
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
def _find_crossing(
|
|
144
|
-
self, *, labels: Iterable[str]
|
|
145
|
-
) -> tuple[tuple[str, ...], tuple[str, ...]] | None:
|
|
146
|
-
"""Return ``(crossed aggregate names, joined model labels)`` if the query
|
|
147
|
-
crosses a boundary, else None.
|
|
148
|
-
|
|
149
|
-
e.g. ``(("order", "payment"), ("order.Invoice", "payrolls.IncomePayment"))``.
|
|
150
|
-
"""
|
|
151
|
-
aggregate_by_label = {
|
|
152
|
-
label: aggregate
|
|
153
|
-
for label in labels
|
|
154
|
-
if (aggregate := self._aggregates_config.get(label.lower())) is not None
|
|
155
|
-
}
|
|
156
|
-
aggregates = set(aggregate_by_label.values())
|
|
157
|
-
if len(aggregates) <= 1:
|
|
158
|
-
return None
|
|
159
|
-
return tuple(sorted(aggregates)), tuple(sorted(aggregate_by_label.keys()))
|
|
160
|
-
|
|
161
|
-
def _record_violation(
|
|
162
|
-
self,
|
|
163
|
-
*,
|
|
164
|
-
call_place: tuple[str, int] | None,
|
|
165
|
-
crossing: tuple[tuple[str, ...], tuple[str, ...]],
|
|
166
|
-
) -> None:
|
|
167
|
-
crossed_aggregates, joined_models = crossing
|
|
168
|
-
file, line = call_place if call_place is not None else ("<unknown>", 0)
|
|
169
|
-
key = (file, line, crossed_aggregates)
|
|
170
|
-
record = self._violations.get(key)
|
|
171
|
-
if record is None:
|
|
172
|
-
record = ViolationRecord(
|
|
173
|
-
file=file,
|
|
174
|
-
line_number=line,
|
|
175
|
-
crossed_aggregates=crossed_aggregates,
|
|
176
|
-
joined_models=joined_models,
|
|
177
|
-
)
|
|
178
|
-
self._violations[key] = record
|
|
179
|
-
if self._current_test is not None:
|
|
180
|
-
record.tests.add(self._current_test)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def build_guard(*, rootpath: Path, config_path: Path) -> BoundaryGuard | None:
|
|
184
|
-
aggregates_by_model = load_aggregates_from_config(path=config_path)
|
|
185
|
-
if not aggregates_by_model:
|
|
186
|
-
return None
|
|
187
|
-
|
|
188
|
-
ignore_patterns = load_ignored_files_from_config(path=config_path)
|
|
189
|
-
tracker = IgnoreTracker(patterns=ignore_patterns)
|
|
190
|
-
|
|
191
|
-
return BoundaryGuard(
|
|
192
|
-
aggregates_config=aggregates_by_model, ignore_tracker=tracker, root=rootpath
|
|
193
|
-
)
|
|
File without changes
|
{pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/__init__.py
RENAMED
|
File without changes
|
{pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/callstack.py
RENAMED
|
File without changes
|
|
File without changes
|
{pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/sql_parsing.py
RENAMED
|
File without changes
|