pytest-orm-boundaries 0.3.1__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.3.1 → pytest_orm_boundaries-0.5.0}/PKG-INFO +82 -16
- pytest_orm_boundaries-0.5.0/README.md +158 -0
- {pytest_orm_boundaries-0.3.1 → pytest_orm_boundaries-0.5.0}/pyproject.toml +8 -2
- pytest_orm_boundaries-0.3.1/src/pytest_orm_boundaries/call_stack.py → pytest_orm_boundaries-0.5.0/src/pytest_orm_boundaries/callstack.py +1 -1
- pytest_orm_boundaries-0.3.1/src/pytest_orm_boundaries/read_config.py → 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.3.1 → pytest_orm_boundaries-0.5.0}/src/pytest_orm_boundaries/ignores.py +1 -1
- pytest_orm_boundaries-0.5.0/src/pytest_orm_boundaries/model_resolution.py +29 -0
- {pytest_orm_boundaries-0.3.1 → pytest_orm_boundaries-0.5.0}/src/pytest_orm_boundaries/plugin.py +36 -15
- pytest_orm_boundaries-0.5.0/src/pytest_orm_boundaries/prefetch_resolution.py +72 -0
- pytest_orm_boundaries-0.3.1/src/pytest_orm_boundaries/build_report.py → pytest_orm_boundaries-0.5.0/src/pytest_orm_boundaries/report.py +20 -20
- pytest_orm_boundaries-0.5.0/src/pytest_orm_boundaries/sql_parsing.py +58 -0
- pytest_orm_boundaries-0.3.1/README.md +0 -95
- pytest_orm_boundaries-0.3.1/src/pytest_orm_boundaries/guard.py +0 -197
- {pytest_orm_boundaries-0.3.1 → pytest_orm_boundaries-0.5.0}/LICENSE +0 -0
- {pytest_orm_boundaries-0.3.1 → pytest_orm_boundaries-0.5.0}/src/pytest_orm_boundaries/__init__.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
|
|
@@ -17,11 +17,14 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
17
17
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
18
18
|
Classifier: Topic :: Software Development :: Testing
|
|
19
19
|
Requires-Dist: pytest>=8
|
|
20
|
+
Requires-Dist: sqlglot>=30,<31
|
|
21
|
+
Requires-Dist: django>=4.2 ; extra == 'django'
|
|
20
22
|
Requires-Python: >=3.11
|
|
21
23
|
Project-URL: Homepage, https://github.com/evchibisova/pytest-orm-boundaries
|
|
22
24
|
Project-URL: Repository, https://github.com/evchibisova/pytest-orm-boundaries
|
|
23
25
|
Project-URL: Issues, https://github.com/evchibisova/pytest-orm-boundaries/issues
|
|
24
26
|
Project-URL: Changelog, https://github.com/evchibisova/pytest-orm-boundaries/blob/main/CHANGELOG.md
|
|
27
|
+
Provides-Extra: django
|
|
25
28
|
Description-Content-Type: text/markdown
|
|
26
29
|
|
|
27
30
|
# pytest-orm-boundaries
|
|
@@ -41,17 +44,19 @@ lookups make it easy to cross those boundaries silently:
|
|
|
41
44
|
Purchase.objects.get(client__name="John")
|
|
42
45
|
```
|
|
43
46
|
|
|
44
|
-
`pytest-orm-boundaries` watches the queries your test suite executes and
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
47
50
|
|
|
48
51
|
## Install
|
|
49
52
|
|
|
53
|
+
Install the plugin with Django support:
|
|
54
|
+
|
|
50
55
|
```bash
|
|
51
|
-
pip install pytest-orm-boundaries
|
|
56
|
+
pip install "pytest-orm-boundaries[django]"
|
|
52
57
|
```
|
|
53
58
|
|
|
54
|
-
pytest
|
|
59
|
+
pytest discovers the plugin automatically.
|
|
55
60
|
|
|
56
61
|
## Configure
|
|
57
62
|
|
|
@@ -68,25 +73,75 @@ purchase = ["bookshop.Purchase", "bookshop.PurchaseLine"]
|
|
|
68
73
|
Models are written as `app_label.Model`. Models not listed in any aggregate are
|
|
69
74
|
not checked. Without a config file the plugin emits a warning and runs no checks.
|
|
70
75
|
|
|
76
|
+
## What it catches
|
|
77
|
+
|
|
78
|
+
The plugin flags queries that read across an aggregate boundary. Each example below couples
|
|
79
|
+
the `purchase` and `client` aggregates:
|
|
80
|
+
|
|
81
|
+
- `__` relation lookups:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
Purchase.objects.get(client__name="John")
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- `select_related`:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
Purchase.objects.select_related("client")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
- Subqueries - a table reached through a subquery still counts:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
berlin_clients = Client.objects.filter(city="Berlin").values("id")
|
|
97
|
+
Purchase.objects.filter(client_id__in=berlin_clients)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- Hand-written `.raw()` SQL:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
Purchase.objects.raw(
|
|
104
|
+
"SELECT p.id FROM bookshop_purchase p "
|
|
105
|
+
"JOIN bookshop_client c ON p.client_id = c.id"
|
|
106
|
+
)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- Bare `cursor.execute()` - the same join reached through a raw cursor.
|
|
110
|
+
|
|
111
|
+
- `prefetch_related`:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
Purchase.objects.prefetch_related("client")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Queries that don't actually join across the boundary are **not** flagged — for
|
|
118
|
+
example a foreign-key lookup by id, which Django resolves without a join:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
Purchase.objects.filter(client_id=42) # reads one table
|
|
122
|
+
Purchase.objects.filter(client__pk=42) # Django trims the join
|
|
123
|
+
```
|
|
124
|
+
|
|
71
125
|
## The report
|
|
72
126
|
|
|
73
127
|
At the end of the run, the plugin prints one grouped entry per offending place:
|
|
74
128
|
|
|
75
129
|
```
|
|
76
|
-
|
|
130
|
+
====================== orm-boundaries: boundary crossings ======================
|
|
77
131
|
1 place(s) in your code crossed aggregate boundaries, affecting 1 test(s):
|
|
78
132
|
|
|
79
|
-
bookshop/
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
test_purchases.py::
|
|
133
|
+
bookshop/reports.py:13
|
|
134
|
+
crossed aggregates: client ↔ purchase
|
|
135
|
+
models: bookshop.Client, bookshop.Purchase
|
|
136
|
+
1 test(s) affected:
|
|
137
|
+
test_purchases.py::test_list_purchases_with_client
|
|
84
138
|
|
|
85
|
-
orm-boundaries: FAILED - 1 boundary
|
|
139
|
+
orm-boundaries: FAILED - 1 boundary crossing(s), run exits non-zero.
|
|
86
140
|
```
|
|
87
141
|
|
|
88
|
-
|
|
89
|
-
Pass `-v` to
|
|
142
|
+
Each entry names the aggregates the query crossed and the models it joined.
|
|
143
|
+
Places are ordered by how many tests they affect. Pass `-v` to see every affected test
|
|
144
|
+
(otherwise the list is capped at 5 per place).
|
|
90
145
|
|
|
91
146
|
## Ignoring files
|
|
92
147
|
|
|
@@ -111,11 +166,22 @@ boundary, the plugin says so at the end:
|
|
|
111
166
|
|
|
112
167
|
```
|
|
113
168
|
======================= orm-boundaries: stale ignores ========================
|
|
114
|
-
These [ignore] entries no longer suppress any boundary
|
|
169
|
+
These [ignore] entries no longer suppress any boundary crossing - their files are clean now.
|
|
115
170
|
Remove them from boundaries.toml:
|
|
116
171
|
- app/billing.py
|
|
117
172
|
```
|
|
118
173
|
|
|
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)
|
|
180
|
+
|
|
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.
|
|
184
|
+
|
|
119
185
|
## Status
|
|
120
186
|
|
|
121
187
|
Alpha - testing basic version.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# pytest-orm-boundaries
|
|
2
|
+
|
|
3
|
+
> 💡 **Even if you control your imports — boundaries still can leak through the ORM**
|
|
4
|
+
|
|
5
|
+
A `pytest-orm-boundaries` is a pytest plugin that reports ORM queries crossing your DDD aggregate boundaries.
|
|
6
|
+
|
|
7
|
+
Currently works with Django ORM.
|
|
8
|
+
|
|
9
|
+
In domain-driven design, an aggregate is a consistency boundary: code in one
|
|
10
|
+
aggregate should not reach into the internals of another. Django's `__` relation
|
|
11
|
+
lookups make it easy to cross those boundaries silently:
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
# Purchase and Client belong to different aggregates — this query couples them.
|
|
15
|
+
Purchase.objects.get(client__name="John")
|
|
16
|
+
```
|
|
17
|
+
|
|
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
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
Install the plugin with Django support:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install "pytest-orm-boundaries[django]"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
pytest discovers the plugin automatically.
|
|
31
|
+
|
|
32
|
+
## Configure
|
|
33
|
+
|
|
34
|
+
Declare your aggregates in `boundaries.toml` at the project root (or point at
|
|
35
|
+
the file with `--boundaries-config` / the `boundaries_config` ini option):
|
|
36
|
+
|
|
37
|
+
```toml
|
|
38
|
+
[aggregates]
|
|
39
|
+
client = ["bookshop.Client"]
|
|
40
|
+
book = ["bookshop.Book"]
|
|
41
|
+
purchase = ["bookshop.Purchase", "bookshop.PurchaseLine"]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Models are written as `app_label.Model`. Models not listed in any aggregate are
|
|
45
|
+
not checked. Without a config file the plugin emits a warning and runs no checks.
|
|
46
|
+
|
|
47
|
+
## What it catches
|
|
48
|
+
|
|
49
|
+
The plugin flags queries that read across an aggregate boundary. Each example below couples
|
|
50
|
+
the `purchase` and `client` aggregates:
|
|
51
|
+
|
|
52
|
+
- `__` relation lookups:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
Purchase.objects.get(client__name="John")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- `select_related`:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
Purchase.objects.select_related("client")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- Subqueries - a table reached through a subquery still counts:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
berlin_clients = Client.objects.filter(city="Berlin").values("id")
|
|
68
|
+
Purchase.objects.filter(client_id__in=berlin_clients)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- Hand-written `.raw()` SQL:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
Purchase.objects.raw(
|
|
75
|
+
"SELECT p.id FROM bookshop_purchase p "
|
|
76
|
+
"JOIN bookshop_client c ON p.client_id = c.id"
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
- Bare `cursor.execute()` - the same join reached through a raw cursor.
|
|
81
|
+
|
|
82
|
+
- `prefetch_related`:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
Purchase.objects.prefetch_related("client")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Queries that don't actually join across the boundary are **not** flagged — for
|
|
89
|
+
example a foreign-key lookup by id, which Django resolves without a join:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
Purchase.objects.filter(client_id=42) # reads one table
|
|
93
|
+
Purchase.objects.filter(client__pk=42) # Django trims the join
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## The report
|
|
97
|
+
|
|
98
|
+
At the end of the run, the plugin prints one grouped entry per offending place:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
====================== orm-boundaries: boundary crossings ======================
|
|
102
|
+
1 place(s) in your code crossed aggregate boundaries, affecting 1 test(s):
|
|
103
|
+
|
|
104
|
+
bookshop/reports.py:13
|
|
105
|
+
crossed aggregates: client ↔ purchase
|
|
106
|
+
models: bookshop.Client, bookshop.Purchase
|
|
107
|
+
1 test(s) affected:
|
|
108
|
+
test_purchases.py::test_list_purchases_with_client
|
|
109
|
+
|
|
110
|
+
orm-boundaries: FAILED - 1 boundary crossing(s), run exits non-zero.
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Each entry names the aggregates the query crossed and the models it joined.
|
|
114
|
+
Places are ordered by how many tests they affect. Pass `-v` to see every affected test
|
|
115
|
+
(otherwise the list is capped at 5 per place).
|
|
116
|
+
|
|
117
|
+
## Ignoring files
|
|
118
|
+
|
|
119
|
+
Add exceptions so that known offenders keep passing while you fix them one file at a time:
|
|
120
|
+
|
|
121
|
+
```toml
|
|
122
|
+
[ignore]
|
|
123
|
+
files = [
|
|
124
|
+
"app/billing.py",
|
|
125
|
+
"app/legacy/*",
|
|
126
|
+
]
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html),
|
|
130
|
+
resolved relative to pytest's root directory and matched against either:
|
|
131
|
+
|
|
132
|
+
- the file that issues the query, or
|
|
133
|
+
- the test file.
|
|
134
|
+
|
|
135
|
+
If an ignored file runs queries through the whole suite without ever crossing a
|
|
136
|
+
boundary, the plugin says so at the end:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
======================= orm-boundaries: stale ignores ========================
|
|
140
|
+
These [ignore] entries no longer suppress any boundary crossing - their files are clean now.
|
|
141
|
+
Remove them from boundaries.toml:
|
|
142
|
+
- app/billing.py
|
|
143
|
+
```
|
|
144
|
+
|
|
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)
|
|
151
|
+
|
|
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.
|
|
155
|
+
|
|
156
|
+
## Status
|
|
157
|
+
|
|
158
|
+
Alpha - testing basic version.
|
|
@@ -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"
|
|
@@ -33,7 +33,10 @@ classifiers = [
|
|
|
33
33
|
"Topic :: Software Development :: Quality Assurance",
|
|
34
34
|
"Topic :: Software Development :: Testing",
|
|
35
35
|
]
|
|
36
|
-
dependencies = ["pytest>=8"]
|
|
36
|
+
dependencies = ["pytest>=8", "sqlglot>=30,<31"]
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
django = ["django>=4.2"]
|
|
37
40
|
|
|
38
41
|
[project.urls]
|
|
39
42
|
Homepage = "https://github.com/evchibisova/pytest-orm-boundaries"
|
|
@@ -44,6 +47,9 @@ Changelog = "https://github.com/evchibisova/pytest-orm-boundaries/blob/main/CHAN
|
|
|
44
47
|
[project.entry-points.pytest11]
|
|
45
48
|
boundaries = "pytest_orm_boundaries.plugin"
|
|
46
49
|
|
|
50
|
+
[tool.uv]
|
|
51
|
+
exclude-newer = "2 weeks"
|
|
52
|
+
|
|
47
53
|
[tool.pytest.ini_options]
|
|
48
54
|
testpaths = ["tests"]
|
|
49
55
|
|
|
@@ -15,7 +15,7 @@ def _is_third_party(*, relative: Path) -> bool:
|
|
|
15
15
|
return not _THIRD_PARTY_SEGMENTS.isdisjoint(relative.parts)
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
def
|
|
18
|
+
def find_frames_inside_project(*, root: Path) -> list[tuple[str, int]]:
|
|
19
19
|
"""(root-relative path, line) for each in-project frame, innermost first.
|
|
20
20
|
|
|
21
21
|
Skips stdlib (outside root) and installed packages; frames[0] is the line
|
|
@@ -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()
|