pytest-orm-boundaries 0.2.0__tar.gz → 0.3.1__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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-orm-boundaries
3
- Version: 0.2.0
4
- Summary: Pytest plugin that fails your tests when ORM queries cross DDD aggregate boundaries (Django supported today).
3
+ Version: 0.3.1
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
7
7
  License-Expression: MIT
@@ -28,7 +28,7 @@ Description-Content-Type: text/markdown
28
28
 
29
29
  > 💡 **Even if you control your imports — boundaries still can leak through the ORM**
30
30
 
31
- A `pytest-orm-boundaries` is a pytest plugin that fails your tests when ORM queries cross your DDD aggregate boundaries.
31
+ A `pytest-orm-boundaries` is a pytest plugin that reports ORM queries crossing your DDD aggregate boundaries.
32
32
 
33
33
  Currently works with Django ORM.
34
34
 
@@ -68,6 +68,26 @@ purchase = ["bookshop.Purchase", "bookshop.PurchaseLine"]
68
68
  Models are written as `app_label.Model`. Models not listed in any aggregate are
69
69
  not checked. Without a config file the plugin emits a warning and runs no checks.
70
70
 
71
+ ## The report
72
+
73
+ At the end of the run, the plugin prints one grouped entry per offending place:
74
+
75
+ ```
76
+ ===================== orm-boundaries: boundary violations ======================
77
+ 1 place(s) in your code crossed aggregate boundaries, affecting 1 test(s):
78
+
79
+ bookshop/purchases.py:29
80
+ code: return list(Purchase.objects.filter(client__name=name))
81
+ crosses: client, purchase
82
+ affected tests (1):
83
+ test_purchases.py::test_get_purchases_by_client_name
84
+
85
+ orm-boundaries: FAILED - 1 boundary violation(s), run exits non-zero.
86
+ ```
87
+
88
+ The run exits non-zero when there are violations, so CI catches them.
89
+ Pass `-v` to list all affected tests.
90
+
71
91
  ## Ignoring files
72
92
 
73
93
  Add exceptions so that known offenders keep passing while you fix them one file at a time:
@@ -2,7 +2,7 @@
2
2
 
3
3
  > 💡 **Even if you control your imports — boundaries still can leak through the ORM**
4
4
 
5
- A `pytest-orm-boundaries` is a pytest plugin that fails your tests when ORM queries cross your DDD aggregate boundaries.
5
+ A `pytest-orm-boundaries` is a pytest plugin that reports ORM queries crossing your DDD aggregate boundaries.
6
6
 
7
7
  Currently works with Django ORM.
8
8
 
@@ -42,6 +42,26 @@ purchase = ["bookshop.Purchase", "bookshop.PurchaseLine"]
42
42
  Models are written as `app_label.Model`. Models not listed in any aggregate are
43
43
  not checked. Without a config file the plugin emits a warning and runs no checks.
44
44
 
45
+ ## The report
46
+
47
+ At the end of the run, the plugin prints one grouped entry per offending place:
48
+
49
+ ```
50
+ ===================== orm-boundaries: boundary violations ======================
51
+ 1 place(s) in your code crossed aggregate boundaries, affecting 1 test(s):
52
+
53
+ bookshop/purchases.py:29
54
+ code: return list(Purchase.objects.filter(client__name=name))
55
+ crosses: client, purchase
56
+ affected tests (1):
57
+ test_purchases.py::test_get_purchases_by_client_name
58
+
59
+ orm-boundaries: FAILED - 1 boundary violation(s), run exits non-zero.
60
+ ```
61
+
62
+ The run exits non-zero when there are violations, so CI catches them.
63
+ Pass `-v` to list all affected tests.
64
+
45
65
  ## Ignoring files
46
66
 
47
67
  Add exceptions so that known offenders keep passing while you fix them one file at a time:
@@ -4,8 +4,8 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "pytest-orm-boundaries"
7
- version = "0.2.0"
8
- description = "Pytest plugin that fails your tests when ORM queries cross DDD aggregate boundaries (Django supported today)."
7
+ version = "0.3.1"
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"
11
11
  license = "MIT"
@@ -46,3 +46,6 @@ boundaries = "pytest_orm_boundaries.plugin"
46
46
 
47
47
  [tool.pytest.ini_options]
48
48
  testpaths = ["tests"]
49
+
50
+ [dependency-groups]
51
+ dev = ["django>=4.2"]
@@ -0,0 +1,102 @@
1
+ """Render orm-boundaries results to the pytest terminal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from pytest_orm_boundaries.read_config import CONFIG_FILE_NAME
8
+
9
+ if TYPE_CHECKING:
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+
14
+ from pytest_orm_boundaries.guard import ViolationRecord
15
+
16
+ MAX_TESTS_SHOWN = 5
17
+
18
+
19
+ def report_violations(
20
+ *,
21
+ terminalreporter: pytest.TerminalReporter,
22
+ violations: list[ViolationRecord],
23
+ verbose: bool = False,
24
+ ) -> None:
25
+ """Print one grouped entry per offending call place: which aggregates the
26
+ query crossed, the models it joined, and which tests reached it.
27
+ """
28
+ if not violations:
29
+ return
30
+
31
+ affected = len({test for violation in violations for test in violation.tests})
32
+ terminalreporter.section(
33
+ "orm-boundaries: boundary violations", red=True, bold=True
34
+ )
35
+ terminalreporter.write_line(
36
+ f"{len(violations)} place(s) in your code crossed aggregate boundaries, "
37
+ f"affecting {affected} test(s):",
38
+ red=True,
39
+ )
40
+ for violation in violations:
41
+ _write_violation(
42
+ terminalreporter=terminalreporter, violation=violation, verbose=verbose
43
+ )
44
+ terminalreporter.write_line("")
45
+ terminalreporter.write_line(
46
+ f"orm-boundaries: FAILED - {len(violations)} boundary violation(s), "
47
+ "run exits non-zero.",
48
+ red=True,
49
+ bold=True,
50
+ )
51
+
52
+
53
+ def _write_violation(
54
+ *,
55
+ terminalreporter: pytest.TerminalReporter,
56
+ violation: ViolationRecord,
57
+ verbose: bool,
58
+ ) -> None:
59
+ terminalreporter.write_line("")
60
+ terminalreporter.write_line(f"{violation.file}:{violation.line_number}", bold=True)
61
+ aggregates = " ↔ ".join(violation.crossed_aggregates)
62
+ terminalreporter.write_line(
63
+ f" crossed aggregates: {aggregates}", yellow=True, bold=True
64
+ )
65
+ models = ", ".join(violation.joined_models)
66
+ terminalreporter.write_line(f" models: {models}")
67
+
68
+ tests = sorted(violation.tests)
69
+ if not tests:
70
+ terminalreporter.write_line(" tests affected: (none captured)", light=True)
71
+ return
72
+
73
+ shown = tests if verbose else tests[:MAX_TESTS_SHOWN]
74
+ terminalreporter.write_line(f" {len(tests)} test(s) affected:", light=True)
75
+ for nodeid in shown:
76
+ terminalreporter.write_line(f" {nodeid}", light=True)
77
+ hidden = len(tests) - len(shown)
78
+ if hidden:
79
+ terminalreporter.write_line(
80
+ f" ... +{hidden} more (-v to list all)", light=True
81
+ )
82
+
83
+
84
+ def report_stale_ignores(
85
+ *, terminalreporter: pytest.TerminalReporter, stale: list[str]
86
+ ) -> None:
87
+ if not stale:
88
+ return
89
+ terminalreporter.section("orm-boundaries: stale ignores", yellow=True, bold=True)
90
+ terminalreporter.write_line(
91
+ "These [ignore] entries no longer suppress any boundary violation - "
92
+ f"their files are clean now. Remove them from {CONFIG_FILE_NAME}:",
93
+ yellow=True,
94
+ )
95
+ for pattern in stale:
96
+ terminalreporter.write_line(f" - {pattern}", yellow=True)
97
+
98
+
99
+ def render_header(*, config_path: Path | None) -> str:
100
+ if config_path is None:
101
+ return "orm-boundaries: no config file, checks disabled"
102
+ return f"orm-boundaries: config {config_path}"
@@ -0,0 +1,38 @@
1
+ """Call-stack inspection: the in-project frames behind a query."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from pathlib import Path
7
+
8
+ # Installed deps live under these segments even when .venv is inside the project
9
+ # root, so match the segment, not the (varying) venv dir name.
10
+ _THIRD_PARTY_SEGMENTS = frozenset({"site-packages", "dist-packages"})
11
+
12
+
13
+ def _is_third_party(*, relative: Path) -> bool:
14
+ """True if a root-relative path points into installed third-party code."""
15
+ return not _THIRD_PARTY_SEGMENTS.isdisjoint(relative.parts)
16
+
17
+
18
+ def find_in_project_frames(*, root: Path) -> list[tuple[str, int]]:
19
+ """(root-relative path, line) for each in-project frame, innermost first.
20
+
21
+ Skips stdlib (outside root) and installed packages; frames[0] is the line
22
+ that issued the ORM call.
23
+ """
24
+ frames: list[tuple[str, int]] = []
25
+ frame = inspect.currentframe()
26
+ frame = frame.f_back if frame else None # skip our own frame
27
+ while frame is not None:
28
+ filename = frame.f_code.co_filename
29
+ lineno = frame.f_lineno
30
+ frame = frame.f_back
31
+ try:
32
+ relative = Path(filename).relative_to(root)
33
+ except ValueError:
34
+ continue
35
+ if _is_third_party(relative=relative):
36
+ continue
37
+ frames.append((relative.as_posix(), lineno))
38
+ return frames
@@ -0,0 +1,197 @@
1
+ """Django boundary guard: the aggregate-crossing rule and the query
2
+ interception that records crossings.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import functools
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from pytest_orm_boundaries.call_stack import find_in_project_frames
13
+ from pytest_orm_boundaries.ignores import IgnoreTracker
14
+ from pytest_orm_boundaries.read_config import (
15
+ load_aggregates_from_config,
16
+ load_ignored_files_from_config,
17
+ )
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable, Iterable
21
+ from typing import Any
22
+
23
+ from django.db.models import Model
24
+ from django.db.models.sql.query import Query
25
+
26
+
27
+ @dataclass
28
+ class ViolationRecord:
29
+ """One offending call place and the tests that reached it.
30
+
31
+ Grouped by call place so a crossing shared by many tests is one entry,
32
+ not one line per test.
33
+ """
34
+
35
+ file: str
36
+ line_number: int
37
+ crossed_aggregates: tuple[str, ...] # ("order", "payment")
38
+ joined_models: tuple[str, ...] # ("order.Invoice", "payrolls.IncomePayment")
39
+ tests: set[str] = field(default_factory=set)
40
+
41
+
42
+ class BoundaryGuard:
43
+ """Intercepts Django's executed queries and records aggregate crossings."""
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ aggregates_config: dict[str, str],
49
+ ignore_tracker: IgnoreTracker,
50
+ root: Path,
51
+ ) -> None:
52
+ self._aggregates_config = aggregates_config
53
+ self._ignore_tracker = ignore_tracker
54
+ self._root = Path(root)
55
+ self._original_execute_sql: Callable[..., Any] | None = None
56
+ self._current_test: str | None = None
57
+ self._violations: dict[tuple[str, int, tuple[str, ...]], ViolationRecord] = {}
58
+
59
+ def install(self) -> None:
60
+ from django.db.models.sql.compiler import SQLCompiler
61
+
62
+ original_execute_sql = SQLCompiler.execute_sql
63
+ self._original_execute_sql = original_execute_sql
64
+
65
+ def patched_execute_sql(compiler, *args, **kwargs):
66
+ self._handle_query(query=compiler.query)
67
+ return original_execute_sql(compiler, *args, **kwargs)
68
+
69
+ SQLCompiler.execute_sql = patched_execute_sql
70
+
71
+ def uninstall(self) -> None:
72
+ from django.db.models.sql.compiler import SQLCompiler
73
+
74
+ SQLCompiler.execute_sql = self._original_execute_sql
75
+
76
+ def set_current_test(self, nodeid: str | None) -> None:
77
+ """Remember which test is running so a recorded crossing can name it."""
78
+ self._current_test = nodeid
79
+
80
+ @property
81
+ def violations(self) -> list[ViolationRecord]:
82
+ """Recorded crossings, most-affecting first (then by call place)."""
83
+ return sorted(
84
+ self._violations.values(),
85
+ key=lambda v: (-len(v.tests), v.file, v.line_number),
86
+ )
87
+
88
+ def find_stale_patterns(self) -> list[str]:
89
+ return self._ignore_tracker.find_stale_patterns()
90
+
91
+ def _handle_query(self, *, query: Query) -> None:
92
+ """Handle one executed query: gather stack context, apply the aggregate
93
+ rule, and record a crossing (unless it's clean or its place is ignored).
94
+ """
95
+ # ``frames`` is the in-project part of this query's call stack,
96
+ # used by the ignore/stale check and the report.
97
+ frames: list[tuple[str, int]] | None = None
98
+ file_paths: list[str] | None = None
99
+ if self._ignore_tracker.is_active:
100
+ frames = find_in_project_frames(root=self._root)
101
+ file_paths = self._extract_file_paths(frames)
102
+ self._ignore_tracker.mark_seen(file_paths=file_paths)
103
+
104
+ labels = self._read_labels(query=query)
105
+ crossing = self._find_crossing(labels=labels)
106
+ if crossing is None:
107
+ return
108
+
109
+ if file_paths is not None and self._ignore_tracker.has_ignore_for(
110
+ file_paths=file_paths
111
+ ):
112
+ self._ignore_tracker.mark_used(file_paths=file_paths)
113
+ return
114
+
115
+ if frames is None:
116
+ frames = find_in_project_frames(root=self._root)
117
+ self._record_violation(
118
+ call_place=frames[0] if frames else None, crossing=crossing
119
+ )
120
+
121
+ def _read_labels(self, *, query: Query) -> list[str]:
122
+ """Model labels of the tables the query actually reads.
123
+
124
+ A filter on a foreign-key id reads a column already on the current
125
+ table, so the table it points to isn't read -- and isn't counted.
126
+ """
127
+ table_to_model = _map_tables_to_models()
128
+ return [
129
+ table_to_model[table.table_name]._meta.label
130
+ for alias, table in query.alias_map.items()
131
+ if table.table_name in table_to_model
132
+ and query.alias_refcount.get(alias, 0) > 0
133
+ ]
134
+
135
+ def _find_crossing(
136
+ self, *, labels: Iterable[str]
137
+ ) -> tuple[tuple[str, ...], tuple[str, ...]] | None:
138
+ """Return ``(crossed aggregate names, joined model labels)`` if the query
139
+ crosses a boundary, else None.
140
+
141
+ e.g. ``(("order", "payment"), ("order.Invoice", "payrolls.IncomePayment"))``.
142
+ """
143
+ aggregate_by_label = {
144
+ label: aggregate
145
+ for label in labels
146
+ if (aggregate := self._aggregates_config.get(label.lower())) is not None
147
+ }
148
+ aggregates = set(aggregate_by_label.values())
149
+ if len(aggregates) <= 1:
150
+ return None
151
+ return tuple(sorted(aggregates)), tuple(sorted(aggregate_by_label.keys()))
152
+
153
+ def _record_violation(
154
+ self,
155
+ *,
156
+ call_place: tuple[str, int] | None,
157
+ crossing: tuple[tuple[str, ...], tuple[str, ...]],
158
+ ) -> None:
159
+ crossed_aggregates, joined_models = crossing
160
+ file, line = call_place if call_place is not None else ("<unknown>", 0)
161
+ key = (file, line, crossed_aggregates)
162
+ record = self._violations.get(key)
163
+ if record is None:
164
+ record = ViolationRecord(
165
+ file=file,
166
+ line_number=line,
167
+ crossed_aggregates=crossed_aggregates,
168
+ joined_models=joined_models,
169
+ )
170
+ self._violations[key] = record
171
+ if self._current_test is not None:
172
+ record.tests.add(self._current_test)
173
+
174
+ @staticmethod
175
+ def _extract_file_paths(frames: Iterable[tuple[str, int]]) -> set[str]:
176
+ return {path for path, _ in frames}
177
+
178
+
179
+ def build_guard(*, rootpath: Path, config_path: Path) -> BoundaryGuard | None:
180
+ aggregates_by_model = load_aggregates_from_config(path=config_path)
181
+ if not aggregates_by_model:
182
+ return None
183
+
184
+ ignore_patterns = load_ignored_files_from_config(path=config_path)
185
+ tracker = IgnoreTracker(patterns=ignore_patterns)
186
+
187
+ return BoundaryGuard(
188
+ aggregates_config=aggregates_by_model, ignore_tracker=tracker, root=rootpath
189
+ )
190
+
191
+
192
+ @functools.cache
193
+ def _map_tables_to_models() -> dict[str, type[Model]]:
194
+ """Map db_table -> model class for every installed model."""
195
+ from django.apps import apps
196
+
197
+ return {model._meta.db_table: model for model in apps.get_models()}
@@ -1,19 +1,16 @@
1
- """Per-file ignores at runtime: the tracker and call-stack inspection."""
1
+ """Per-file ignores at runtime: the IgnoreTracker book-keeping."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import inspect
6
5
  from collections.abc import Iterable
7
6
  from fnmatch import fnmatch
8
- from pathlib import Path
9
7
 
10
8
 
11
9
  class IgnoreTracker:
12
10
  """Decides whether a violation is ignored, and reports ignore globs that went
13
11
  stale - their file ran but never crossed a boundary."""
14
12
 
15
- def __init__(self, *, patterns: Iterable[str], root: Path) -> None:
16
- self.root = Path(root)
13
+ def __init__(self, *, patterns: Iterable[str]) -> None:
17
14
  self._patterns = list(dict.fromkeys(patterns)) # ordered, de-duped
18
15
  self._seen: set[str] = set() # patterns executed during the run
19
16
  self._used: set[str] = set() # ...that also crossed a boundary
@@ -24,7 +21,7 @@ class IgnoreTracker:
24
21
 
25
22
  def mark_seen(self, *, file_paths: Iterable[str]) -> None:
26
23
  self._seen.update(self._find_matching_patterns(file_paths))
27
-
24
+
28
25
  def mark_used(self, *, file_paths: Iterable[str]) -> None:
29
26
  self._used.update(self._find_matching_patterns(file_paths))
30
27
 
@@ -36,26 +33,3 @@ class IgnoreTracker:
36
33
 
37
34
  def _find_matching_patterns(self, file_paths: Iterable[str]) -> set[str]:
38
35
  return {p for f in file_paths for p in self._patterns if fnmatch(f, p)}
39
-
40
-
41
- def find_source_files_in_stack(*, root: Path) -> list[str]:
42
- """Root-relative (posix) paths of project files on the current call stack.
43
-
44
- Frames outside ``root`` (Django, pytest, stdlib) are skipped, leaving the
45
- application/test files that led to this query.
46
- """
47
- files: list[str] = []
48
- seen: set[str] = set()
49
- frame = inspect.currentframe()
50
- frame = frame.f_back if frame else None # start at the caller, skip our own frame
51
- while frame is not None:
52
- filename = frame.f_code.co_filename
53
- frame = frame.f_back # for next iteration
54
- if filename in seen:
55
- continue
56
- seen.add(filename)
57
- try:
58
- files.append(Path(filename).relative_to(root).as_posix())
59
- except ValueError:
60
- continue
61
- return files
@@ -19,13 +19,18 @@ if TYPE_CHECKING:
19
19
  from pytest_orm_boundaries.guard import BoundaryGuard
20
20
 
21
21
  config_path_key = pytest.StashKey[Path | None]()
22
- guard_key = pytest.StashKey["BoundaryGuard | None"]()
22
+ guard_key = pytest.StashKey["BoundaryGuard"]()
23
23
 
24
24
 
25
25
  class BoundariesConfigWarning(UserWarning):
26
26
  """Plugin is installed but no config file was found."""
27
27
 
28
28
 
29
+ def _installed_guard(config: pytest.Config) -> BoundaryGuard | None:
30
+ """The guard for this run, or None if no config or aggregates were found."""
31
+ return config.stash.get(guard_key, None)
32
+
33
+
29
34
  def pytest_addoption(parser: pytest.Parser) -> None:
30
35
  group = parser.getgroup("orm-boundaries")
31
36
  group.addoption(
@@ -47,11 +52,8 @@ def pytest_addoption(parser: pytest.Parser) -> None:
47
52
 
48
53
 
49
54
  def pytest_configure(config: pytest.Config) -> None:
50
- """Resolve the config and install the guard, stashing what other hooks need:
51
-
52
- - ``config_path_key`` -- to show where the config came from in the report.
53
- - ``guard_key`` -- to restore the patched query method on teardown and to
54
- report stale ignores at the end of the run.
55
+ """Resolve config and install the guard, stashing both for later hooks:
56
+ the config path for the report header, the guard for teardown and reporting.
55
57
  """
56
58
  explicit_config = config.getoption("boundaries_config") or config.getini("boundaries_config")
57
59
  try:
@@ -73,9 +75,29 @@ def pytest_configure(config: pytest.Config) -> None:
73
75
 
74
76
 
75
77
  def pytest_unconfigure(config: pytest.Config) -> None:
76
- guard = config.stash.get(guard_key, None)
78
+ guard = _installed_guard(config)
77
79
  if guard is not None:
78
- guard.restore_original_runner()
80
+ guard.uninstall()
81
+
82
+
83
+ @pytest.hookimpl(wrapper=True)
84
+ 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."""
86
+ guard = _installed_guard(item.config)
87
+ if guard is not None:
88
+ guard.set_current_test(item.nodeid)
89
+ try:
90
+ return (yield)
91
+ finally:
92
+ if guard is not None:
93
+ guard.set_current_test(None)
94
+
95
+
96
+ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
97
+ """A clean-but-violating run must still fail the run (via the exit code)."""
98
+ guard = _installed_guard(session.config)
99
+ if guard is not None and guard.violations and exitstatus == pytest.ExitCode.OK:
100
+ session.exitstatus = pytest.ExitCode.TESTS_FAILED
79
101
 
80
102
 
81
103
  def pytest_terminal_summary(
@@ -83,14 +105,18 @@ def pytest_terminal_summary(
83
105
  exitstatus: int,
84
106
  config: pytest.Config,
85
107
  ) -> None:
86
- guard = config.stash.get(guard_key, None)
87
- if guard is not None:
88
- stale = guard.find_stale_patterns()
89
- build_report.report_stale_ignores(
90
- terminalreporter=terminalreporter, stale=stale
91
- )
108
+ guard = _installed_guard(config)
109
+ if guard is None:
110
+ return
111
+ build_report.report_violations(
112
+ terminalreporter=terminalreporter,
113
+ violations=guard.violations,
114
+ verbose=config.getoption("verbose", 0) > 0,
115
+ )
116
+ stale = guard.find_stale_patterns()
117
+ build_report.report_stale_ignores(terminalreporter=terminalreporter, stale=stale)
92
118
 
93
119
 
94
120
  def pytest_report_header(config: pytest.Config) -> str:
95
121
  config_path = config.stash.get(config_path_key, None)
96
- return build_report.build_report_header(config_path=config_path)
122
+ return build_report.render_header(config_path=config_path)
@@ -1,30 +0,0 @@
1
- """Terminal output: the run header and the stale-ignore notice."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
-
7
- import pytest
8
-
9
- from pytest_orm_boundaries.read_config import CONFIG_FILE_NAME
10
-
11
-
12
- def report_stale_ignores(
13
- *, terminalreporter: pytest.TerminalReporter, stale: list[str]
14
- ) -> None:
15
- if not stale:
16
- return
17
- terminalreporter.section("orm-boundaries: stale ignores", yellow=True, bold=True)
18
- terminalreporter.write_line(
19
- "These [ignore] entries no longer suppress any boundary violation - "
20
- f"their files are clean now. Remove them from {CONFIG_FILE_NAME}:",
21
- yellow=True,
22
- )
23
- for pattern in stale:
24
- terminalreporter.write_line(f" - {pattern}", yellow=True)
25
-
26
-
27
- def build_report_header(*, config_path: Path | None) -> str:
28
- if config_path is None:
29
- return "orm-boundaries: no config file, checks disabled"
30
- return f"orm-boundaries: config {config_path}"
@@ -1,116 +0,0 @@
1
- """Django boundary guard: the aggregate-crossing rule and the query
2
- interception that enforces it.
3
-
4
- Patches SQLCompiler.execute_sql to check each query; violations from ignored
5
- call sites are swallowed, and stale ignores are reported.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import functools
11
- from collections.abc import Callable, Iterable
12
- from pathlib import Path
13
- from typing import TYPE_CHECKING, Any
14
-
15
- from pytest_orm_boundaries.ignores import IgnoreTracker, find_source_files_in_stack
16
- from pytest_orm_boundaries.read_config import (
17
- load_aggregates_from_config,
18
- load_ignored_files_from_config,
19
- )
20
-
21
- if TYPE_CHECKING:
22
- from django.db.models import Model
23
- from django.db.models.sql.query import Query
24
-
25
-
26
- class BoundaryViolation(Exception):
27
- """Raised when a query joins models from more than one aggregate."""
28
-
29
-
30
- class BoundaryGuard:
31
- """Intercepts Django's executed queries and enforces the aggregate rule."""
32
-
33
- def __init__(
34
- self, *, aggregates_config: dict[str, str], ignore_tracker: IgnoreTracker
35
- ) -> None:
36
- self._aggregates_config = aggregates_config
37
- self._tracker = ignore_tracker
38
- self._original_runner: Callable[..., Any] | None = None
39
-
40
- def install(self) -> None:
41
- from django.db.models.sql.compiler import SQLCompiler
42
-
43
- original_runner = SQLCompiler.execute_sql
44
- self._original_runner = original_runner
45
-
46
- def patched_execute_sql(compiler, *args, **kwargs):
47
- self._check_query(query=compiler.query)
48
- return original_runner(compiler, *args, **kwargs)
49
-
50
- SQLCompiler.execute_sql = patched_execute_sql
51
-
52
- def restore_original_runner(self) -> None:
53
- from django.db.models.sql.compiler import SQLCompiler
54
-
55
- SQLCompiler.execute_sql = self._original_runner
56
-
57
- def find_stale_patterns(self) -> list[str]:
58
- return self._tracker.find_stale_patterns()
59
-
60
- def _check_query(self, *, query: Query) -> None:
61
- """Raise BoundaryViolation unless the query is clean or ignored."""
62
- file_paths: list[str] | None = None
63
- if self._tracker.is_active:
64
- file_paths = find_source_files_in_stack(root=self._tracker.root)
65
- self._tracker.mark_seen(file_paths=file_paths)
66
-
67
- table_to_model = _map_tables_to_models()
68
- labels = [
69
- table_to_model[table.table_name]._meta.label
70
- for table in query.alias_map.values()
71
- if table.table_name in table_to_model
72
- ]
73
-
74
- try:
75
- self._check_boundaries(labels=labels)
76
- except BoundaryViolation:
77
- if file_paths is None or not self._tracker.has_ignore_for(
78
- file_paths=file_paths
79
- ):
80
- raise
81
- self._tracker.mark_used(file_paths=file_paths)
82
-
83
- def _check_boundaries(self, *, labels: Iterable[str]) -> None:
84
- aggregate_by_label = {
85
- label: aggregate
86
- for label in labels
87
- if (aggregate := self._aggregates_config.get(label.lower())) is not None
88
- }
89
- aggregates = set(aggregate_by_label.values())
90
- if len(aggregates) <= 1:
91
- return
92
-
93
- crossed = ", ".join(sorted(aggregates))
94
- joined = ", ".join(sorted(aggregate_by_label.keys()))
95
- raise BoundaryViolation(
96
- f"aggregate boundaries crossed ({crossed}); joined models: {joined}"
97
- )
98
-
99
-
100
- def build_guard(*, rootpath: Path, config_path: Path) -> BoundaryGuard | None:
101
- aggregates_by_model = load_aggregates_from_config(path=config_path)
102
- if not aggregates_by_model:
103
- return None
104
-
105
- ignore_patterns = load_ignored_files_from_config(path=config_path)
106
- tracker = IgnoreTracker(patterns=ignore_patterns, root=rootpath)
107
-
108
- return BoundaryGuard(aggregates_config=aggregates_by_model, ignore_tracker=tracker)
109
-
110
-
111
- @functools.cache
112
- def _map_tables_to_models() -> dict[str, type[Model]]:
113
- """Map db_table -> model class for every installed model."""
114
- from django.apps import apps
115
-
116
- return {model._meta.db_table: model for model in apps.get_models()}