pytest-orm-boundaries 0.5.0__tar.gz → 0.7.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.5.0 → pytest_orm_boundaries-0.7.0}/PKG-INFO +40 -20
- {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/README.md +39 -19
- {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/pyproject.toml +1 -1
- pytest_orm_boundaries-0.7.0/src/pytest_orm_boundaries/allows.py +26 -0
- {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/config.py +48 -14
- {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/crossings.py +18 -10
- {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/guard.py +3 -0
- {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/plugin.py +5 -2
- {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/report.py +2 -2
- {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/LICENSE +0 -0
- {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/__init__.py +0 -0
- {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/callstack.py +0 -0
- {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/ignores.py +0 -0
- {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/model_resolution.py +0 -0
- {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/prefetch_resolution.py +0 -0
- {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.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.7.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
|
|
@@ -29,11 +29,11 @@ Description-Content-Type: text/markdown
|
|
|
29
29
|
|
|
30
30
|
# pytest-orm-boundaries
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
💡 **Even if you control your imports, boundaries can still leak through the ORM.**
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
`pytest-orm-boundaries` is a pytest plugin that reports ORM queries crossing your DDD aggregate boundaries.
|
|
35
35
|
|
|
36
|
-
Currently works with Django ORM.
|
|
36
|
+
Currently works with Django ORM, SQLAlchemy is in roadmap.
|
|
37
37
|
|
|
38
38
|
In domain-driven design, an aggregate is a consistency boundary: code in one
|
|
39
39
|
aggregate should not reach into the internals of another. Django's `__` relation
|
|
@@ -44,8 +44,8 @@ lookups make it easy to cross those boundaries silently:
|
|
|
44
44
|
Purchase.objects.get(client__name="John")
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
`pytest-orm-boundaries` watches the
|
|
48
|
-
|
|
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
49
|
`select_related`, `prefetch_related`, subqueries, or hand-written `.raw()` SQL.
|
|
50
50
|
|
|
51
51
|
## Install
|
|
@@ -60,14 +60,19 @@ pytest discovers the plugin automatically.
|
|
|
60
60
|
|
|
61
61
|
## Configure
|
|
62
62
|
|
|
63
|
-
Declare your aggregates in `boundaries.toml` at the project
|
|
64
|
-
the file with `--boundaries-config` / the
|
|
63
|
+
Declare your aggregates and their Django models in `boundaries.toml` at the project
|
|
64
|
+
root (or point at the file with `--boundaries-config` / the
|
|
65
|
+
`boundaries_config` ini option):
|
|
65
66
|
|
|
66
67
|
```toml
|
|
67
|
-
[aggregates]
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
[aggregates.client]
|
|
69
|
+
models = ["bookshop.Client"]
|
|
70
|
+
|
|
71
|
+
[aggregates.book]
|
|
72
|
+
models = ["bookshop.Book"]
|
|
73
|
+
|
|
74
|
+
[aggregates.purchase]
|
|
75
|
+
models = ["bookshop.Purchase", "bookshop.PurchaseLine"]
|
|
71
76
|
```
|
|
72
77
|
|
|
73
78
|
Models are written as `app_label.Model`. Models not listed in any aggregate are
|
|
@@ -75,8 +80,9 @@ not checked. Without a config file the plugin emits a warning and runs no checks
|
|
|
75
80
|
|
|
76
81
|
## What it catches
|
|
77
82
|
|
|
78
|
-
The plugin flags
|
|
79
|
-
the `purchase` and `client`
|
|
83
|
+
The plugin flags executed ORM access that spans more than one configured group.
|
|
84
|
+
In the DDD example below, each crossing couples the `purchase` and `client`
|
|
85
|
+
aggregates:
|
|
80
86
|
|
|
81
87
|
- `__` relation lookups:
|
|
82
88
|
|
|
@@ -114,7 +120,7 @@ the `purchase` and `client` aggregates:
|
|
|
114
120
|
Purchase.objects.prefetch_related("client")
|
|
115
121
|
```
|
|
116
122
|
|
|
117
|
-
Queries that don't actually join across the boundary are **not** flagged
|
|
123
|
+
Queries that don't actually join across the boundary are **not** flagged - for
|
|
118
124
|
example a foreign-key lookup by id, which Django resolves without a join:
|
|
119
125
|
|
|
120
126
|
```python
|
|
@@ -143,11 +149,22 @@ Each entry names the aggregates the query crossed and the models it joined.
|
|
|
143
149
|
Places are ordered by how many tests they affect. Pass `-v` to see every affected test
|
|
144
150
|
(otherwise the list is capped at 5 per place).
|
|
145
151
|
|
|
146
|
-
##
|
|
152
|
+
## Allow and ignore
|
|
147
153
|
|
|
148
|
-
|
|
154
|
+
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:
|
|
155
|
+
|
|
156
|
+
- `[allow]` - the crossing is **intentional**. Use it for code that is meant to
|
|
157
|
+
span aggregates, such as CQRS read models or cross-aggregate reports. An
|
|
158
|
+
allowed crossing is suppressed and never reported.
|
|
159
|
+
- `[ignore]` - the crossing is **known debt** you plan to fix. It is suppressed
|
|
160
|
+
for now, and the plugin reminds you when an entry is no longer needed.
|
|
149
161
|
|
|
150
162
|
```toml
|
|
163
|
+
[allow]
|
|
164
|
+
files = [
|
|
165
|
+
"app/reports/sales_summary.py",
|
|
166
|
+
]
|
|
167
|
+
|
|
151
168
|
[ignore]
|
|
152
169
|
files = [
|
|
153
170
|
"app/billing.py",
|
|
@@ -155,14 +172,14 @@ files = [
|
|
|
155
172
|
]
|
|
156
173
|
```
|
|
157
174
|
|
|
158
|
-
Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html),
|
|
175
|
+
Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html)),
|
|
159
176
|
resolved relative to pytest's root directory and matched against either:
|
|
160
177
|
|
|
161
178
|
- the file that issues the query, or
|
|
162
179
|
- the test file.
|
|
163
180
|
|
|
164
|
-
|
|
165
|
-
boundary, the plugin
|
|
181
|
+
An `[ignore]` whose file runs through the whole suite without ever crossing a
|
|
182
|
+
boundary is stale — it is clean now, so the plugin lists it for removal:
|
|
166
183
|
|
|
167
184
|
```
|
|
168
185
|
======================= orm-boundaries: stale ignores ========================
|
|
@@ -171,6 +188,9 @@ Remove them from boundaries.toml:
|
|
|
171
188
|
- app/billing.py
|
|
172
189
|
```
|
|
173
190
|
|
|
191
|
+
`[allow]` entries are never reported this way. If a file sits in both sections,
|
|
192
|
+
the allow wins and its `[ignore]` entry shows up as stale to remove.
|
|
193
|
+
|
|
174
194
|
## A note on Django internals
|
|
175
195
|
|
|
176
196
|
Catching `prefetch_related` relies on Django internals that come with no stability
|
|
@@ -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,8 +15,8 @@ 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
|
-
|
|
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
20
|
`select_related`, `prefetch_related`, subqueries, or hand-written `.raw()` SQL.
|
|
21
21
|
|
|
22
22
|
## Install
|
|
@@ -31,14 +31,19 @@ pytest discovers the plugin automatically.
|
|
|
31
31
|
|
|
32
32
|
## Configure
|
|
33
33
|
|
|
34
|
-
Declare your aggregates in `boundaries.toml` at the project
|
|
35
|
-
the file with `--boundaries-config` / the
|
|
34
|
+
Declare your aggregates and their Django models in `boundaries.toml` at the project
|
|
35
|
+
root (or point at the file with `--boundaries-config` / the
|
|
36
|
+
`boundaries_config` ini option):
|
|
36
37
|
|
|
37
38
|
```toml
|
|
38
|
-
[aggregates]
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
[aggregates.client]
|
|
40
|
+
models = ["bookshop.Client"]
|
|
41
|
+
|
|
42
|
+
[aggregates.book]
|
|
43
|
+
models = ["bookshop.Book"]
|
|
44
|
+
|
|
45
|
+
[aggregates.purchase]
|
|
46
|
+
models = ["bookshop.Purchase", "bookshop.PurchaseLine"]
|
|
42
47
|
```
|
|
43
48
|
|
|
44
49
|
Models are written as `app_label.Model`. Models not listed in any aggregate are
|
|
@@ -46,8 +51,9 @@ not checked. Without a config file the plugin emits a warning and runs no checks
|
|
|
46
51
|
|
|
47
52
|
## What it catches
|
|
48
53
|
|
|
49
|
-
The plugin flags
|
|
50
|
-
the `purchase` and `client`
|
|
54
|
+
The plugin flags executed ORM access that spans more than one configured group.
|
|
55
|
+
In the DDD example below, each crossing couples the `purchase` and `client`
|
|
56
|
+
aggregates:
|
|
51
57
|
|
|
52
58
|
- `__` relation lookups:
|
|
53
59
|
|
|
@@ -85,7 +91,7 @@ the `purchase` and `client` aggregates:
|
|
|
85
91
|
Purchase.objects.prefetch_related("client")
|
|
86
92
|
```
|
|
87
93
|
|
|
88
|
-
Queries that don't actually join across the boundary are **not** flagged
|
|
94
|
+
Queries that don't actually join across the boundary are **not** flagged - for
|
|
89
95
|
example a foreign-key lookup by id, which Django resolves without a join:
|
|
90
96
|
|
|
91
97
|
```python
|
|
@@ -114,11 +120,22 @@ Each entry names the aggregates the query crossed and the models it joined.
|
|
|
114
120
|
Places are ordered by how many tests they affect. Pass `-v` to see every affected test
|
|
115
121
|
(otherwise the list is capped at 5 per place).
|
|
116
122
|
|
|
117
|
-
##
|
|
123
|
+
## Allow and ignore
|
|
118
124
|
|
|
119
|
-
|
|
125
|
+
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:
|
|
126
|
+
|
|
127
|
+
- `[allow]` - the crossing is **intentional**. Use it for code that is meant to
|
|
128
|
+
span aggregates, such as CQRS read models or cross-aggregate reports. An
|
|
129
|
+
allowed crossing is suppressed and never reported.
|
|
130
|
+
- `[ignore]` - the crossing is **known debt** you plan to fix. It is suppressed
|
|
131
|
+
for now, and the plugin reminds you when an entry is no longer needed.
|
|
120
132
|
|
|
121
133
|
```toml
|
|
134
|
+
[allow]
|
|
135
|
+
files = [
|
|
136
|
+
"app/reports/sales_summary.py",
|
|
137
|
+
]
|
|
138
|
+
|
|
122
139
|
[ignore]
|
|
123
140
|
files = [
|
|
124
141
|
"app/billing.py",
|
|
@@ -126,14 +143,14 @@ files = [
|
|
|
126
143
|
]
|
|
127
144
|
```
|
|
128
145
|
|
|
129
|
-
Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html),
|
|
146
|
+
Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html)),
|
|
130
147
|
resolved relative to pytest's root directory and matched against either:
|
|
131
148
|
|
|
132
149
|
- the file that issues the query, or
|
|
133
150
|
- the test file.
|
|
134
151
|
|
|
135
|
-
|
|
136
|
-
boundary, the plugin
|
|
152
|
+
An `[ignore]` whose file runs through the whole suite without ever crossing a
|
|
153
|
+
boundary is stale — it is clean now, so the plugin lists it for removal:
|
|
137
154
|
|
|
138
155
|
```
|
|
139
156
|
======================= orm-boundaries: stale ignores ========================
|
|
@@ -142,6 +159,9 @@ Remove them from boundaries.toml:
|
|
|
142
159
|
- app/billing.py
|
|
143
160
|
```
|
|
144
161
|
|
|
162
|
+
`[allow]` entries are never reported this way. If a file sits in both sections,
|
|
163
|
+
the allow wins and its `[ignore]` entry shows up as stale to remove.
|
|
164
|
+
|
|
145
165
|
## A note on Django internals
|
|
146
166
|
|
|
147
167
|
Catching `prefetch_related` relies on Django internals that come with no stability
|
|
@@ -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.7.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"
|
|
@@ -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.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/config.py
RENAMED
|
@@ -19,7 +19,8 @@ class BoundariesConfig:
|
|
|
19
19
|
"""Parsed and validated content of a ``boundaries.toml``."""
|
|
20
20
|
|
|
21
21
|
aggregates_by_model: dict[str, str] # {"app.model" lower-cased: aggregate_name}
|
|
22
|
-
ignored_files: list[str] #
|
|
22
|
+
ignored_files: list[str] # globs for known debt, tracked for staleness
|
|
23
|
+
allowed_files: list[str] # globs for intentional crossings, never stale
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
def discover_config_path(*, explicit: str | None, rootpath: Path) -> Path | None:
|
|
@@ -42,7 +43,8 @@ def load_config(*, path: Path) -> BoundariesConfig:
|
|
|
42
43
|
data = _read_config(path=path)
|
|
43
44
|
return BoundariesConfig(
|
|
44
45
|
aggregates_by_model=_parse_aggregates(data=data, path=path),
|
|
45
|
-
ignored_files=
|
|
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"),
|
|
46
48
|
)
|
|
47
49
|
|
|
48
50
|
|
|
@@ -60,13 +62,41 @@ def _parse_aggregates(*, data: dict[str, Any], path: Path) -> dict[str, str]:
|
|
|
60
62
|
Model labels are lower-cased ("app_label.modelname") for case-insensitive
|
|
61
63
|
matching.
|
|
62
64
|
"""
|
|
65
|
+
aggregate_definitions = data.get("aggregates", {})
|
|
66
|
+
if not isinstance(aggregate_definitions, dict):
|
|
67
|
+
raise BoundariesConfigError(
|
|
68
|
+
f"{path}: define aggregates as named sections, for example "
|
|
69
|
+
"[aggregates.order] with models = [...]"
|
|
70
|
+
)
|
|
71
|
+
|
|
63
72
|
aggregates_by_model: dict[str, str] = {}
|
|
64
|
-
for aggregate,
|
|
65
|
-
if isinstance(
|
|
66
|
-
|
|
73
|
+
for aggregate, definition in aggregate_definitions.items():
|
|
74
|
+
if not isinstance(definition, dict):
|
|
75
|
+
raise BoundariesConfigError(
|
|
76
|
+
f"{path}: aggregate '{aggregate}' must define models in its own "
|
|
77
|
+
"section: "
|
|
78
|
+
f"[aggregates.{aggregate}] with models = [...]"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
unknown_fields = set(definition) - {"models"}
|
|
82
|
+
if unknown_fields:
|
|
83
|
+
fields = ", ".join(sorted(unknown_fields))
|
|
84
|
+
raise BoundariesConfigError(
|
|
85
|
+
f"{path}: aggregate '{aggregate}' has unknown field(s): {fields}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if "models" not in definition:
|
|
89
|
+
raise BoundariesConfigError(
|
|
90
|
+
f"{path}: aggregate '{aggregate}' is missing required 'models'"
|
|
91
|
+
)
|
|
92
|
+
members = definition["models"]
|
|
67
93
|
if not isinstance(members, list):
|
|
68
94
|
raise BoundariesConfigError(
|
|
69
|
-
f"{path}: aggregate '{aggregate}' must be a list
|
|
95
|
+
f"{path}: aggregate '{aggregate}' models must be a list"
|
|
96
|
+
)
|
|
97
|
+
if not members:
|
|
98
|
+
raise BoundariesConfigError(
|
|
99
|
+
f"{path}: aggregate '{aggregate}' models must not be empty"
|
|
70
100
|
)
|
|
71
101
|
for label in members:
|
|
72
102
|
if not isinstance(label, str):
|
|
@@ -82,23 +112,27 @@ def _parse_aggregates(*, data: dict[str, Any], path: Path) -> dict[str, str]:
|
|
|
82
112
|
return aggregates_by_model
|
|
83
113
|
|
|
84
114
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
115
|
+
def _parse_file_globs(
|
|
116
|
+
*, data: dict[str, Any], path: Path, section_name: str
|
|
117
|
+
) -> list[str]:
|
|
118
|
+
"""Read ``[allow]`` and ``[ignore] files`` into a list of glob patterns."""
|
|
119
|
+
section_data = data.get(section_name, {})
|
|
120
|
+
if not isinstance(section_data, dict):
|
|
121
|
+
raise BoundariesConfigError(
|
|
122
|
+
f"{path}: [{section_name}] must be a section with a 'files' list"
|
|
123
|
+
)
|
|
90
124
|
|
|
91
|
-
files =
|
|
125
|
+
files = section_data.get("files", [])
|
|
92
126
|
if not isinstance(files, list):
|
|
93
127
|
raise BoundariesConfigError(
|
|
94
|
-
f"{path}: [
|
|
128
|
+
f"{path}: [{section_name}] files must be a list of glob patterns"
|
|
95
129
|
)
|
|
96
130
|
|
|
97
131
|
patterns: list[str] = []
|
|
98
132
|
for entry in files:
|
|
99
133
|
if not isinstance(entry, str):
|
|
100
134
|
raise BoundariesConfigError(
|
|
101
|
-
f"{path}: [
|
|
135
|
+
f"{path}: [{section_name}] files has a non-string entry: {entry!r}"
|
|
102
136
|
)
|
|
103
137
|
patterns.append(entry)
|
|
104
138
|
return patterns
|
{pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/crossings.py
RENAMED
|
@@ -11,6 +11,7 @@ from pytest_orm_boundaries.callstack import find_frames_inside_project
|
|
|
11
11
|
if TYPE_CHECKING:
|
|
12
12
|
from collections.abc import Iterable
|
|
13
13
|
|
|
14
|
+
from pytest_orm_boundaries.allows import AllowList
|
|
14
15
|
from pytest_orm_boundaries.ignores import IgnoreTracker
|
|
15
16
|
|
|
16
17
|
|
|
@@ -27,16 +28,19 @@ class CrossingRecord:
|
|
|
27
28
|
|
|
28
29
|
class CrossingTracker:
|
|
29
30
|
"""Checks label sets against the aggregate rule and records the crossings,
|
|
30
|
-
skipping ignored files and naming each crossing's call place and
|
|
31
|
+
skipping allowed and ignored files and naming each crossing's call place and
|
|
32
|
+
test."""
|
|
31
33
|
|
|
32
34
|
def __init__(
|
|
33
35
|
self,
|
|
34
36
|
*,
|
|
35
37
|
aggregates_config: dict[str, str],
|
|
38
|
+
allow_list: AllowList,
|
|
36
39
|
ignore_tracker: IgnoreTracker,
|
|
37
40
|
root: Path,
|
|
38
41
|
) -> None:
|
|
39
42
|
self._aggregates_config = aggregates_config
|
|
43
|
+
self._allow_list = allow_list
|
|
40
44
|
self._ignore_tracker = ignore_tracker
|
|
41
45
|
self._root_path = Path(root)
|
|
42
46
|
self._current_test: str | None = None
|
|
@@ -48,13 +52,16 @@ class CrossingTracker:
|
|
|
48
52
|
|
|
49
53
|
def check(self, *, label_sets: Iterable[Iterable[str]]) -> None:
|
|
50
54
|
"""Record a crossing for each label set that spans aggregates, unless an
|
|
51
|
-
|
|
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.
|
|
52
60
|
"""
|
|
53
|
-
# ``frames`` is the in-project part of the call stack
|
|
54
|
-
# for the ignore/stale bookkeeping and to name each crossing's place.
|
|
61
|
+
# ``frames`` is the in-project part of the call stack
|
|
55
62
|
frames: list[tuple[str, int]] | None = None
|
|
56
63
|
file_paths: set[str] | None = None
|
|
57
|
-
if self._ignore_tracker.is_active:
|
|
64
|
+
if self._allow_list.is_active or self._ignore_tracker.is_active:
|
|
58
65
|
frames = find_frames_inside_project(root=self._root_path)
|
|
59
66
|
file_paths = {path for path, _ in frames}
|
|
60
67
|
self._ignore_tracker.mark_seen(file_paths=file_paths)
|
|
@@ -63,11 +70,12 @@ class CrossingTracker:
|
|
|
63
70
|
crossing = self.find_crossing(labels=labels)
|
|
64
71
|
if crossing is None:
|
|
65
72
|
continue
|
|
66
|
-
if file_paths is not None
|
|
67
|
-
file_paths=file_paths
|
|
68
|
-
|
|
69
|
-
self._ignore_tracker.
|
|
70
|
-
|
|
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
|
|
71
79
|
if frames is None:
|
|
72
80
|
frames = find_frames_inside_project(root=self._root_path)
|
|
73
81
|
call_place = frames[0] if frames else None
|
{pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/guard.py
RENAMED
|
@@ -20,6 +20,7 @@ from pytest_orm_boundaries.sql_parsing import extract_table_names, looks_like_da
|
|
|
20
20
|
if TYPE_CHECKING:
|
|
21
21
|
from django.db.backends.base.base import BaseDatabaseWrapper
|
|
22
22
|
|
|
23
|
+
from pytest_orm_boundaries.allows import AllowList
|
|
23
24
|
from pytest_orm_boundaries.crossings import CrossingRecord
|
|
24
25
|
from pytest_orm_boundaries.ignores import IgnoreTracker
|
|
25
26
|
|
|
@@ -45,11 +46,13 @@ class BoundaryGuard:
|
|
|
45
46
|
self,
|
|
46
47
|
*,
|
|
47
48
|
aggregates_config: dict[str, str],
|
|
49
|
+
allow_list: AllowList,
|
|
48
50
|
ignore_tracker: IgnoreTracker,
|
|
49
51
|
root: Path,
|
|
50
52
|
) -> None:
|
|
51
53
|
self._tracker = CrossingTracker(
|
|
52
54
|
aggregates_config=aggregates_config,
|
|
55
|
+
allow_list=allow_list,
|
|
53
56
|
ignore_tracker=ignore_tracker,
|
|
54
57
|
root=root,
|
|
55
58
|
)
|
{pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/plugin.py
RENAMED
|
@@ -14,6 +14,7 @@ from pytest_orm_boundaries.config import (
|
|
|
14
14
|
discover_config_path,
|
|
15
15
|
load_config,
|
|
16
16
|
)
|
|
17
|
+
from pytest_orm_boundaries.allows import AllowList
|
|
17
18
|
from pytest_orm_boundaries.guard import BoundaryGuard
|
|
18
19
|
from pytest_orm_boundaries.ignores import IgnoreTracker
|
|
19
20
|
|
|
@@ -56,10 +57,12 @@ def _build_guard(*, rootpath: Path, config_path: Path) -> BoundaryGuard | None:
|
|
|
56
57
|
if not config.aggregates_by_model:
|
|
57
58
|
return None
|
|
58
59
|
|
|
59
|
-
|
|
60
|
+
allow_list = AllowList(patterns=config.allowed_files)
|
|
61
|
+
ignore_tracker = IgnoreTracker(patterns=config.ignored_files)
|
|
60
62
|
return BoundaryGuard(
|
|
61
63
|
aggregates_config=config.aggregates_by_model,
|
|
62
|
-
|
|
64
|
+
allow_list=allow_list,
|
|
65
|
+
ignore_tracker=ignore_tracker,
|
|
63
66
|
root=rootpath,
|
|
64
67
|
)
|
|
65
68
|
|
{pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/report.py
RENAMED
|
@@ -88,8 +88,8 @@ 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
|
|
92
|
-
f"
|
|
91
|
+
"These [ignore] entries matched files that ran without crossing a "
|
|
92
|
+
f"boundary. Remove them from {CONFIG_FILE_NAME}:",
|
|
93
93
|
yellow=True,
|
|
94
94
|
)
|
|
95
95
|
for pattern in stale:
|
|
File without changes
|
{pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/__init__.py
RENAMED
|
File without changes
|
{pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/callstack.py
RENAMED
|
File without changes
|
{pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/ignores.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.7.0}/src/pytest_orm_boundaries/sql_parsing.py
RENAMED
|
File without changes
|