pytest-orm-boundaries 0.4.0__tar.gz → 0.6.0__tar.gz

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