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

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