pytest-orm-boundaries 0.4.0__tar.gz → 0.5.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.5.0}/PKG-INFO +31 -15
- {pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.5.0}/README.md +28 -14
- {pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.5.0}/pyproject.toml +4 -1
- {pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.5.0}/src/pytest_orm_boundaries/config.py +22 -9
- pytest_orm_boundaries-0.5.0/src/pytest_orm_boundaries/crossings.py +131 -0
- pytest_orm_boundaries-0.5.0/src/pytest_orm_boundaries/guard.py +176 -0
- {pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.5.0}/src/pytest_orm_boundaries/ignores.py +1 -1
- {pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.5.0}/src/pytest_orm_boundaries/plugin.py +32 -11
- pytest_orm_boundaries-0.5.0/src/pytest_orm_boundaries/prefetch_resolution.py +72 -0
- {pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.5.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.5.0}/LICENSE +0 -0
- {pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.5.0}/src/pytest_orm_boundaries/__init__.py +0 -0
- {pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.5.0}/src/pytest_orm_boundaries/callstack.py +0 -0
- {pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.5.0}/src/pytest_orm_boundaries/model_resolution.py +0 -0
- {pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.5.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.5.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,11 +18,13 @@ 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
|
|
@@ -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 queries your test suite executes and reports
|
|
48
|
+
the ones that step outside their aggregate — 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,8 @@ 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
|
-
query was written. Each example below joins the `purchase` and `client` aggregates:
|
|
78
|
+
The plugin flags queries that read across an aggregate boundary. Each example below couples
|
|
79
|
+
the `purchase` and `client` aggregates:
|
|
77
80
|
|
|
78
81
|
- `__` relation lookups:
|
|
79
82
|
|
|
@@ -81,7 +84,7 @@ query was written. Each example below joins the `purchase` and `client` aggregat
|
|
|
81
84
|
Purchase.objects.get(client__name="John")
|
|
82
85
|
```
|
|
83
86
|
|
|
84
|
-
- `select_related
|
|
87
|
+
- `select_related`:
|
|
85
88
|
|
|
86
89
|
```python
|
|
87
90
|
Purchase.objects.select_related("client")
|
|
@@ -105,6 +108,12 @@ query was written. Each example below joins the `purchase` and `client` aggregat
|
|
|
105
108
|
|
|
106
109
|
- Bare `cursor.execute()` - the same join reached through a raw cursor.
|
|
107
110
|
|
|
111
|
+
- `prefetch_related`:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
Purchase.objects.prefetch_related("client")
|
|
115
|
+
```
|
|
116
|
+
|
|
108
117
|
Queries that don't actually join across the boundary are **not** flagged — for
|
|
109
118
|
example a foreign-key lookup by id, which Django resolves without a join:
|
|
110
119
|
|
|
@@ -118,7 +127,7 @@ Purchase.objects.filter(client__pk=42) # Django trims the join
|
|
|
118
127
|
At the end of the run, the plugin prints one grouped entry per offending place:
|
|
119
128
|
|
|
120
129
|
```
|
|
121
|
-
|
|
130
|
+
====================== orm-boundaries: boundary crossings ======================
|
|
122
131
|
1 place(s) in your code crossed aggregate boundaries, affecting 1 test(s):
|
|
123
132
|
|
|
124
133
|
bookshop/reports.py:13
|
|
@@ -127,7 +136,7 @@ bookshop/reports.py:13
|
|
|
127
136
|
1 test(s) affected:
|
|
128
137
|
test_purchases.py::test_list_purchases_with_client
|
|
129
138
|
|
|
130
|
-
orm-boundaries: FAILED - 1 boundary
|
|
139
|
+
orm-boundaries: FAILED - 1 boundary crossing(s), run exits non-zero.
|
|
131
140
|
```
|
|
132
141
|
|
|
133
142
|
Each entry names the aggregates the query crossed and the models it joined.
|
|
@@ -157,14 +166,21 @@ boundary, the plugin says so at the end:
|
|
|
157
166
|
|
|
158
167
|
```
|
|
159
168
|
======================= orm-boundaries: stale ignores ========================
|
|
160
|
-
These [ignore] entries no longer suppress any boundary
|
|
169
|
+
These [ignore] entries no longer suppress any boundary crossing - their files are clean now.
|
|
161
170
|
Remove them from boundaries.toml:
|
|
162
171
|
- app/billing.py
|
|
163
172
|
```
|
|
164
173
|
|
|
165
|
-
##
|
|
174
|
+
## A note on Django internals
|
|
175
|
+
|
|
176
|
+
Catching `prefetch_related` relies on Django internals that come with no stability
|
|
177
|
+
promise, so new Django releases may require compatibility updates.
|
|
178
|
+
|
|
179
|
+
## Known gaps (on the project roadmap)
|
|
166
180
|
|
|
167
|
-
-
|
|
181
|
+
- lazy attribute access (e.g. `purchase.client`);
|
|
182
|
+
- related-manager reads/writes (`client.purchases.all()`, `client.purchases.create(...)`);
|
|
183
|
+
- a direct query on another aggregate's model, e.g. `Client.objects.get(...)` written inside purchase code.
|
|
168
184
|
|
|
169
185
|
## Status
|
|
170
186
|
|
|
@@ -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 queries your test suite executes and reports
|
|
19
|
+
the ones that step outside their aggregate — 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,8 @@ 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
|
-
query was written. Each example below joins the `purchase` and `client` aggregates:
|
|
49
|
+
The plugin flags queries that read across an aggregate boundary. Each example below couples
|
|
50
|
+
the `purchase` and `client` aggregates:
|
|
50
51
|
|
|
51
52
|
- `__` relation lookups:
|
|
52
53
|
|
|
@@ -54,7 +55,7 @@ query was written. Each example below joins the `purchase` and `client` aggregat
|
|
|
54
55
|
Purchase.objects.get(client__name="John")
|
|
55
56
|
```
|
|
56
57
|
|
|
57
|
-
- `select_related
|
|
58
|
+
- `select_related`:
|
|
58
59
|
|
|
59
60
|
```python
|
|
60
61
|
Purchase.objects.select_related("client")
|
|
@@ -78,6 +79,12 @@ query was written. Each example below joins the `purchase` and `client` aggregat
|
|
|
78
79
|
|
|
79
80
|
- Bare `cursor.execute()` - the same join reached through a raw cursor.
|
|
80
81
|
|
|
82
|
+
- `prefetch_related`:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
Purchase.objects.prefetch_related("client")
|
|
86
|
+
```
|
|
87
|
+
|
|
81
88
|
Queries that don't actually join across the boundary are **not** flagged — for
|
|
82
89
|
example a foreign-key lookup by id, which Django resolves without a join:
|
|
83
90
|
|
|
@@ -91,7 +98,7 @@ Purchase.objects.filter(client__pk=42) # Django trims the join
|
|
|
91
98
|
At the end of the run, the plugin prints one grouped entry per offending place:
|
|
92
99
|
|
|
93
100
|
```
|
|
94
|
-
|
|
101
|
+
====================== orm-boundaries: boundary crossings ======================
|
|
95
102
|
1 place(s) in your code crossed aggregate boundaries, affecting 1 test(s):
|
|
96
103
|
|
|
97
104
|
bookshop/reports.py:13
|
|
@@ -100,7 +107,7 @@ bookshop/reports.py:13
|
|
|
100
107
|
1 test(s) affected:
|
|
101
108
|
test_purchases.py::test_list_purchases_with_client
|
|
102
109
|
|
|
103
|
-
orm-boundaries: FAILED - 1 boundary
|
|
110
|
+
orm-boundaries: FAILED - 1 boundary crossing(s), run exits non-zero.
|
|
104
111
|
```
|
|
105
112
|
|
|
106
113
|
Each entry names the aggregates the query crossed and the models it joined.
|
|
@@ -130,14 +137,21 @@ boundary, the plugin says so at the end:
|
|
|
130
137
|
|
|
131
138
|
```
|
|
132
139
|
======================= orm-boundaries: stale ignores ========================
|
|
133
|
-
These [ignore] entries no longer suppress any boundary
|
|
140
|
+
These [ignore] entries no longer suppress any boundary crossing - their files are clean now.
|
|
134
141
|
Remove them from boundaries.toml:
|
|
135
142
|
- app/billing.py
|
|
136
143
|
```
|
|
137
144
|
|
|
138
|
-
##
|
|
145
|
+
## A note on Django internals
|
|
146
|
+
|
|
147
|
+
Catching `prefetch_related` relies on Django internals that come with no stability
|
|
148
|
+
promise, so new Django releases may require compatibility updates.
|
|
149
|
+
|
|
150
|
+
## Known gaps (on the project roadmap)
|
|
139
151
|
|
|
140
|
-
-
|
|
152
|
+
- lazy attribute access (e.g. `purchase.client`);
|
|
153
|
+
- related-manager reads/writes (`client.purchases.all()`, `client.purchases.create(...)`);
|
|
154
|
+
- a direct query on another aggregate's model, e.g. `Client.objects.get(...)` written inside purchase code.
|
|
141
155
|
|
|
142
156
|
## Status
|
|
143
157
|
|
|
@@ -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.5.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"
|
{pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.5.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,14 @@ 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] # glob patterns
|
|
23
|
+
|
|
24
|
+
|
|
16
25
|
def discover_config_path(*, explicit: str | None, rootpath: Path) -> Path | None:
|
|
17
26
|
"""Resolve the config path.
|
|
18
27
|
|
|
@@ -29,6 +38,14 @@ def discover_config_path(*, explicit: str | None, rootpath: Path) -> Path | None
|
|
|
29
38
|
return default if default.is_file() else None
|
|
30
39
|
|
|
31
40
|
|
|
41
|
+
def load_config(*, path: Path) -> BoundariesConfig:
|
|
42
|
+
data = _read_config(path=path)
|
|
43
|
+
return BoundariesConfig(
|
|
44
|
+
aggregates_by_model=_parse_aggregates(data=data, path=path),
|
|
45
|
+
ignored_files=_parse_ignored_files(data=data, path=path),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
32
49
|
def _read_config(*, path: Path) -> dict[str, Any]:
|
|
33
50
|
try:
|
|
34
51
|
with path.open("rb") as fh:
|
|
@@ -37,16 +54,14 @@ def _read_config(*, path: Path) -> dict[str, Any]:
|
|
|
37
54
|
raise BoundariesConfigError(f"{path}: invalid TOML ({exc})") from exc
|
|
38
55
|
|
|
39
56
|
|
|
40
|
-
def
|
|
41
|
-
"""
|
|
57
|
+
def _parse_aggregates(*, data: dict[str, Any], path: Path) -> dict[str, str]:
|
|
58
|
+
"""Build a {model_label: aggregate_name} map from the ``[aggregates]`` table.
|
|
42
59
|
|
|
43
60
|
Model labels are lower-cased ("app_label.modelname") for case-insensitive
|
|
44
61
|
matching.
|
|
45
62
|
"""
|
|
46
|
-
config = _read_config(path=path)
|
|
47
|
-
|
|
48
63
|
aggregates_by_model: dict[str, str] = {}
|
|
49
|
-
for aggregate, members in
|
|
64
|
+
for aggregate, members in data.get("aggregates", {}).items():
|
|
50
65
|
if isinstance(members, dict):
|
|
51
66
|
members = members.get("models", [])
|
|
52
67
|
if not isinstance(members, list):
|
|
@@ -67,10 +82,8 @@ def load_aggregates_from_config(*, path: Path) -> dict[str, str]:
|
|
|
67
82
|
return aggregates_by_model
|
|
68
83
|
|
|
69
84
|
|
|
70
|
-
def
|
|
71
|
-
"""
|
|
72
|
-
data = _read_config(path=path)
|
|
73
|
-
|
|
85
|
+
def _parse_ignored_files(*, data: dict[str, Any], path: Path) -> list[str]:
|
|
86
|
+
"""Read ``[ignore] files`` into a list of glob patterns."""
|
|
74
87
|
ignore = data.get("ignore", {})
|
|
75
88
|
if not isinstance(ignore, dict):
|
|
76
89
|
raise BoundariesConfigError(f"{path}: [ignore] must be a table")
|
|
@@ -0,0 +1,131 @@
|
|
|
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.ignores import IgnoreTracker
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class CrossingRecord:
|
|
19
|
+
"""One offending call place and the tests that reached it."""
|
|
20
|
+
|
|
21
|
+
file: str
|
|
22
|
+
line_number: int
|
|
23
|
+
crossed_aggregates: tuple[str, ...] # ("order", "payment")
|
|
24
|
+
involved_models: tuple[str, ...] # ("order.Invoice", "payrolls.IncomePayment")
|
|
25
|
+
tests: set[str] = field(default_factory=set)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CrossingTracker:
|
|
29
|
+
"""Checks label sets against the aggregate rule and records the crossings,
|
|
30
|
+
skipping ignored files and naming each crossing's call place and test."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
aggregates_config: dict[str, str],
|
|
36
|
+
ignore_tracker: IgnoreTracker,
|
|
37
|
+
root: Path,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._aggregates_config = aggregates_config
|
|
40
|
+
self._ignore_tracker = ignore_tracker
|
|
41
|
+
self._root_path = Path(root)
|
|
42
|
+
self._current_test: str | None = None
|
|
43
|
+
self._crossings: dict[tuple[str, int, tuple[str, ...]], CrossingRecord] = {}
|
|
44
|
+
|
|
45
|
+
def set_current_test(self, nodeid: str | None) -> None:
|
|
46
|
+
"""Remember which test is running so a recorded crossing can name it."""
|
|
47
|
+
self._current_test = nodeid
|
|
48
|
+
|
|
49
|
+
def check(self, *, label_sets: Iterable[Iterable[str]]) -> None:
|
|
50
|
+
"""Record a crossing for each label set that spans aggregates, unless an
|
|
51
|
+
active ignore covers the call place.
|
|
52
|
+
"""
|
|
53
|
+
# ``frames`` is the in-project part of the call stack, gathered lazily:
|
|
54
|
+
# for the ignore/stale bookkeeping and to name each crossing's place.
|
|
55
|
+
frames: list[tuple[str, int]] | None = None
|
|
56
|
+
file_paths: set[str] | None = None
|
|
57
|
+
if self._ignore_tracker.is_active:
|
|
58
|
+
frames = find_frames_inside_project(root=self._root_path)
|
|
59
|
+
file_paths = {path for path, _ in frames}
|
|
60
|
+
self._ignore_tracker.mark_seen(file_paths=file_paths)
|
|
61
|
+
|
|
62
|
+
for labels in label_sets:
|
|
63
|
+
crossing = self.find_crossing(labels=labels)
|
|
64
|
+
if crossing is None:
|
|
65
|
+
continue
|
|
66
|
+
if file_paths is not None and self._ignore_tracker.has_ignore_for(
|
|
67
|
+
file_paths=file_paths
|
|
68
|
+
):
|
|
69
|
+
self._ignore_tracker.mark_used(file_paths=file_paths)
|
|
70
|
+
continue
|
|
71
|
+
if frames is None:
|
|
72
|
+
frames = find_frames_inside_project(root=self._root_path)
|
|
73
|
+
call_place = frames[0] if frames else None
|
|
74
|
+
self.add_record(
|
|
75
|
+
call_place=call_place, crossing=crossing, test=self._current_test
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def find_crossing(
|
|
79
|
+
self, *, labels: Iterable[str]
|
|
80
|
+
) -> tuple[tuple[str, ...], tuple[str, ...]] | None:
|
|
81
|
+
"""Return ``(crossed aggregate names, involved model labels)`` if the
|
|
82
|
+
labels span more than one aggregate, else None.
|
|
83
|
+
|
|
84
|
+
e.g. ``(("order", "payment"), ("order.Invoice", "payrolls.IncomePayment"))``.
|
|
85
|
+
|
|
86
|
+
``labels`` are model labels.
|
|
87
|
+
"""
|
|
88
|
+
aggregate_by_label = {
|
|
89
|
+
label: aggregate
|
|
90
|
+
for label in labels
|
|
91
|
+
if (aggregate := self._aggregates_config.get(label.lower())) is not None
|
|
92
|
+
}
|
|
93
|
+
aggregates = set(aggregate_by_label.values())
|
|
94
|
+
if len(aggregates) <= 1:
|
|
95
|
+
return None
|
|
96
|
+
return tuple(sorted(aggregates)), tuple(sorted(aggregate_by_label.keys()))
|
|
97
|
+
|
|
98
|
+
def add_record(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
call_place: tuple[str, int] | None,
|
|
102
|
+
crossing: tuple[tuple[str, ...], tuple[str, ...]],
|
|
103
|
+
test: str | None,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Add one crossing, merging into the record for its call place."""
|
|
106
|
+
crossed_aggregates, involved_models = crossing
|
|
107
|
+
file, line = call_place if call_place is not None else ("<unknown>", 0)
|
|
108
|
+
key = (file, line, crossed_aggregates)
|
|
109
|
+
record = self._crossings.get(key)
|
|
110
|
+
if record is None:
|
|
111
|
+
record = CrossingRecord(
|
|
112
|
+
file=file,
|
|
113
|
+
line_number=line,
|
|
114
|
+
crossed_aggregates=crossed_aggregates,
|
|
115
|
+
involved_models=involved_models,
|
|
116
|
+
)
|
|
117
|
+
self._crossings[key] = record
|
|
118
|
+
if test is not None:
|
|
119
|
+
record.tests.add(test)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def crossings(self) -> list[CrossingRecord]:
|
|
123
|
+
"""Recorded crossings, most-affecting first."""
|
|
124
|
+
return sorted(
|
|
125
|
+
self._crossings.values(),
|
|
126
|
+
key=lambda v: (-len(v.tests), v.file, v.line_number),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def find_stale_patterns(self) -> list[str]:
|
|
130
|
+
"""Ignore globs whose file ran but never crossed -- safe to delete."""
|
|
131
|
+
return self._ignore_tracker.find_stale_patterns()
|
|
@@ -0,0 +1,176 @@
|
|
|
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.crossings import CrossingRecord
|
|
24
|
+
from pytest_orm_boundaries.ignores import IgnoreTracker
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class _HookState:
|
|
29
|
+
"""On/off switch for one installed prefetch wrapper.
|
|
30
|
+
|
|
31
|
+
We monkey-patch Django's ``prefetch_related_objects``. On uninstall we
|
|
32
|
+
restore the original when our wrapper is still on top - but if another tool
|
|
33
|
+
wrapped it above ours, that tool holds a reference to our wrapper and we
|
|
34
|
+
can't take it out of the chain. So instead of removing it we set ``active``
|
|
35
|
+
to False: the wrapper keeps being called but does nothing.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
active: bool
|
|
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._tracker = CrossingTracker(
|
|
52
|
+
aggregates_config=aggregates_config,
|
|
53
|
+
ignore_tracker=ignore_tracker,
|
|
54
|
+
root=root,
|
|
55
|
+
)
|
|
56
|
+
self._attached_connections: list[BaseDatabaseWrapper] = []
|
|
57
|
+
self._original_prefetch = None
|
|
58
|
+
self._prefetch_wrapper = None
|
|
59
|
+
self._prefetch_hook_state: _HookState | None = None
|
|
60
|
+
|
|
61
|
+
def install(self) -> None:
|
|
62
|
+
"""Attach to every DB connection: those open now and any opened later
|
|
63
|
+
(each new connection fires ``connection_created``).
|
|
64
|
+
"""
|
|
65
|
+
from django.db import connections
|
|
66
|
+
from django.db.backends.signals import connection_created
|
|
67
|
+
|
|
68
|
+
connection_created.connect(self._handle_connection_created, weak=False)
|
|
69
|
+
for connection in connections.all(initialized_only=True):
|
|
70
|
+
self._attach_wrapper(connection)
|
|
71
|
+
self._install_prefetch_hook()
|
|
72
|
+
|
|
73
|
+
def uninstall(self) -> None:
|
|
74
|
+
from django.db.backends.signals import connection_created
|
|
75
|
+
|
|
76
|
+
self._remove_prefetch_hook()
|
|
77
|
+
connection_created.disconnect(self._handle_connection_created)
|
|
78
|
+
for connection in self._attached_connections:
|
|
79
|
+
try:
|
|
80
|
+
connection.execute_wrappers.remove(self._execute_wrapper)
|
|
81
|
+
except ValueError:
|
|
82
|
+
pass
|
|
83
|
+
self._attached_connections.clear()
|
|
84
|
+
|
|
85
|
+
def _handle_connection_created(
|
|
86
|
+
self, *, connection: BaseDatabaseWrapper, **_
|
|
87
|
+
) -> None:
|
|
88
|
+
self._attach_wrapper(connection)
|
|
89
|
+
|
|
90
|
+
def _attach_wrapper(self, connection: BaseDatabaseWrapper) -> None:
|
|
91
|
+
if self._execute_wrapper not in connection.execute_wrappers:
|
|
92
|
+
connection.execute_wrappers.append(self._execute_wrapper)
|
|
93
|
+
self._attached_connections.append(connection)
|
|
94
|
+
|
|
95
|
+
def _install_prefetch_hook(self) -> None:
|
|
96
|
+
"""Wrap django ``prefetch_related_objects`` so each prefetch reports the
|
|
97
|
+
models it walks between.
|
|
98
|
+
"""
|
|
99
|
+
import django.db.models.query as query_module
|
|
100
|
+
|
|
101
|
+
if self._prefetch_wrapper is not None:
|
|
102
|
+
# Already installed. A second install would capture our own wrapper
|
|
103
|
+
# as ``original`` and orphan the first _HookState
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
original = query_module.prefetch_related_objects
|
|
107
|
+
# hook can be switched off even when it can't be removed from the chain.
|
|
108
|
+
state = _HookState(active=True)
|
|
109
|
+
# strong ref is config.stash[guard_key]
|
|
110
|
+
guard_ref = weakref.ref(self)
|
|
111
|
+
|
|
112
|
+
@wraps(original)
|
|
113
|
+
def prefetch_with_check(model_instances, *lookups):
|
|
114
|
+
result = original(model_instances, *lookups)
|
|
115
|
+
guard = guard_ref()
|
|
116
|
+
if state.active and guard is not None:
|
|
117
|
+
guard._handle_prefetch(
|
|
118
|
+
model_instances=model_instances, lookups=lookups
|
|
119
|
+
)
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
self._prefetch_hook_state = state
|
|
123
|
+
self._original_prefetch = original
|
|
124
|
+
self._prefetch_wrapper = prefetch_with_check
|
|
125
|
+
query_module.prefetch_related_objects = prefetch_with_check
|
|
126
|
+
|
|
127
|
+
def _remove_prefetch_hook(self) -> None:
|
|
128
|
+
import django.db.models.query as query_module
|
|
129
|
+
|
|
130
|
+
if self._prefetch_wrapper is None:
|
|
131
|
+
return
|
|
132
|
+
# Disable our hook unconditionally. Restore the previous callable only
|
|
133
|
+
# when ours is still topmost; otherwise another wrapper may still
|
|
134
|
+
# reference it, and ours stays in the chain as an inert no-op.
|
|
135
|
+
self._prefetch_hook_state.active = False
|
|
136
|
+
if query_module.prefetch_related_objects is self._prefetch_wrapper:
|
|
137
|
+
query_module.prefetch_related_objects = self._original_prefetch
|
|
138
|
+
self._prefetch_hook_state = None
|
|
139
|
+
self._prefetch_wrapper = None
|
|
140
|
+
self._original_prefetch = None
|
|
141
|
+
|
|
142
|
+
def _execute_wrapper(self, execute, sql, params, many, context):
|
|
143
|
+
if looks_like_data_query(sql):
|
|
144
|
+
self._handle_query(sql, context["connection"].vendor)
|
|
145
|
+
return execute(sql, params, many, context)
|
|
146
|
+
|
|
147
|
+
def set_current_test(self, nodeid: str | None) -> None:
|
|
148
|
+
"""Remember which test is running so a recorded crossing can name it."""
|
|
149
|
+
self._tracker.set_current_test(nodeid)
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def crossings(self) -> list[CrossingRecord]:
|
|
153
|
+
return self._tracker.crossings
|
|
154
|
+
|
|
155
|
+
def find_stale_patterns(self) -> list[str]:
|
|
156
|
+
return self._tracker.find_stale_patterns()
|
|
157
|
+
|
|
158
|
+
def _handle_query(self, sql: str, vendor: str) -> None:
|
|
159
|
+
"""Map one executed data query to its table labels and check them."""
|
|
160
|
+
table_names = extract_table_names(sql, vendor)
|
|
161
|
+
if table_names is None:
|
|
162
|
+
return
|
|
163
|
+
labels = resolve_labels(table_names)
|
|
164
|
+
self._tracker.check(label_sets=[labels])
|
|
165
|
+
|
|
166
|
+
def _handle_prefetch(self, *, model_instances, lookups) -> None:
|
|
167
|
+
"""Map one prefetch to the model pair per relation step and check them."""
|
|
168
|
+
if not model_instances:
|
|
169
|
+
return
|
|
170
|
+
source_model = type(model_instances[0])
|
|
171
|
+
step_model_pairs = resolve_prefetch_step_models(
|
|
172
|
+
source_model=source_model, lookups=lookups
|
|
173
|
+
)
|
|
174
|
+
if not step_model_pairs:
|
|
175
|
+
return
|
|
176
|
+
self._tracker.check(label_sets=step_model_pairs)
|
{pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.5.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.5.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,17 @@ 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.guard import
|
|
17
|
-
|
|
18
|
-
if TYPE_CHECKING:
|
|
19
|
-
from pytest_orm_boundaries.guard import BoundaryGuard
|
|
17
|
+
from pytest_orm_boundaries.guard import BoundaryGuard
|
|
18
|
+
from pytest_orm_boundaries.ignores import IgnoreTracker
|
|
20
19
|
|
|
21
20
|
config_path_key = pytest.StashKey[Path | None]()
|
|
22
21
|
guard_key = pytest.StashKey["BoundaryGuard"]()
|
|
23
22
|
|
|
24
23
|
|
|
25
24
|
class BoundariesConfigWarning(UserWarning):
|
|
26
|
-
"""Plugin is installed but no config file
|
|
25
|
+
"""Plugin is installed but won't run: no config file, or Django is absent."""
|
|
27
26
|
|
|
28
27
|
|
|
29
28
|
def _installed_guard(config: pytest.Config) -> BoundaryGuard | None:
|
|
@@ -51,6 +50,20 @@ def pytest_addoption(parser: pytest.Parser) -> None:
|
|
|
51
50
|
)
|
|
52
51
|
|
|
53
52
|
|
|
53
|
+
def _build_guard(*, rootpath: Path, config_path: Path) -> BoundaryGuard | None:
|
|
54
|
+
"""Build the guard from config, or None if no aggregates are declared."""
|
|
55
|
+
config = load_config(path=config_path)
|
|
56
|
+
if not config.aggregates_by_model:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
tracker = IgnoreTracker(patterns=config.ignored_files)
|
|
60
|
+
return BoundaryGuard(
|
|
61
|
+
aggregates_config=config.aggregates_by_model,
|
|
62
|
+
ignore_tracker=tracker,
|
|
63
|
+
root=rootpath,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
54
67
|
def pytest_configure(config: pytest.Config) -> None:
|
|
55
68
|
"""Resolve config and install the guard, stashing both for later hooks:
|
|
56
69
|
the config path for the report header, the guard for teardown and reporting.
|
|
@@ -66,7 +79,15 @@ def pytest_configure(config: pytest.Config) -> None:
|
|
|
66
79
|
config.issue_config_time_warning(warning, stacklevel=2)
|
|
67
80
|
return
|
|
68
81
|
|
|
69
|
-
|
|
82
|
+
if importlib.util.find_spec("django") is None:
|
|
83
|
+
warning = BoundariesConfigWarning(
|
|
84
|
+
f"{CONFIG_FILE_NAME} found but Django is not installed, no checks "
|
|
85
|
+
"will run (install pytest-orm-boundaries[django])"
|
|
86
|
+
)
|
|
87
|
+
config.issue_config_time_warning(warning, stacklevel=2)
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
guard = _build_guard(rootpath=config.rootpath, config_path=config_path)
|
|
70
91
|
if guard is not None:
|
|
71
92
|
guard.install()
|
|
72
93
|
config.stash[guard_key] = guard
|
|
@@ -82,7 +103,7 @@ def pytest_unconfigure(config: pytest.Config) -> None:
|
|
|
82
103
|
|
|
83
104
|
@pytest.hookimpl(wrapper=True)
|
|
84
105
|
def pytest_runtest_protocol(item: pytest.Item, nextitem: pytest.Item | None):
|
|
85
|
-
"""Tell the guard which test is running so a
|
|
106
|
+
"""Tell the guard which test is running so a crossing can name it."""
|
|
86
107
|
guard = _installed_guard(item.config)
|
|
87
108
|
if guard is not None:
|
|
88
109
|
guard.set_current_test(item.nodeid)
|
|
@@ -96,7 +117,7 @@ def pytest_runtest_protocol(item: pytest.Item, nextitem: pytest.Item | None):
|
|
|
96
117
|
def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
|
|
97
118
|
"""A clean-but-violating run must still fail the run (via the exit code)."""
|
|
98
119
|
guard = _installed_guard(session.config)
|
|
99
|
-
if guard is not None and guard.
|
|
120
|
+
if guard is not None and guard.crossings and exitstatus == pytest.ExitCode.OK:
|
|
100
121
|
session.exitstatus = pytest.ExitCode.TESTS_FAILED
|
|
101
122
|
|
|
102
123
|
|
|
@@ -108,9 +129,9 @@ def pytest_terminal_summary(
|
|
|
108
129
|
guard = _installed_guard(config)
|
|
109
130
|
if guard is None:
|
|
110
131
|
return
|
|
111
|
-
report.
|
|
132
|
+
report.report_crossings(
|
|
112
133
|
terminalreporter=terminalreporter,
|
|
113
|
-
|
|
134
|
+
crossings=guard.crossings,
|
|
114
135
|
verbose=config.getoption("verbose", 0) > 0,
|
|
115
136
|
)
|
|
116
137
|
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.5.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.5.0}/src/pytest_orm_boundaries/__init__.py
RENAMED
|
File without changes
|
{pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.5.0}/src/pytest_orm_boundaries/callstack.py
RENAMED
|
File without changes
|
|
File without changes
|
{pytest_orm_boundaries-0.4.0 → pytest_orm_boundaries-0.5.0}/src/pytest_orm_boundaries/sql_parsing.py
RENAMED
|
File without changes
|