pytest-orm-boundaries 0.3.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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-orm-boundaries
3
- Version: 0.3.0
3
+ Version: 0.3.1
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
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "pytest-orm-boundaries"
7
- version = "0.3.0"
7
+ version = "0.3.1"
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"
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
13
13
 
14
14
  from pytest_orm_boundaries.guard import ViolationRecord
15
15
 
16
- MAX_TESTS_SHOWN = 20
16
+ MAX_TESTS_SHOWN = 5
17
17
 
18
18
 
19
19
  def report_violations(
@@ -22,8 +22,8 @@ def report_violations(
22
22
  violations: list[ViolationRecord],
23
23
  verbose: bool = False,
24
24
  ) -> None:
25
- """Print one grouped entry per offending place: where the query crosses a
26
- boundary, the offending line of code, and which tests reached it.
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
27
  """
28
28
  if not violations:
29
29
  return
@@ -58,23 +58,26 @@ def _write_violation(
58
58
  ) -> None:
59
59
  terminalreporter.write_line("")
60
60
  terminalreporter.write_line(f"{violation.file}:{violation.line_number}", bold=True)
61
- if violation.line_code:
62
- terminalreporter.write_line(f" code: {violation.line_code}")
63
- terminalreporter.write_line(f" crosses: {violation.crossed}")
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}")
64
67
 
65
68
  tests = sorted(violation.tests)
66
69
  if not tests:
67
- terminalreporter.write_line(" affected tests: (none captured)")
70
+ terminalreporter.write_line(" tests affected: (none captured)", light=True)
68
71
  return
69
72
 
70
73
  shown = tests if verbose else tests[:MAX_TESTS_SHOWN]
71
- terminalreporter.write_line(f" affected tests ({len(tests)}):")
74
+ terminalreporter.write_line(f" {len(tests)} test(s) affected:", light=True)
72
75
  for nodeid in shown:
73
- terminalreporter.write_line(f" {nodeid}")
76
+ terminalreporter.write_line(f" {nodeid}", light=True)
74
77
  hidden = len(tests) - len(shown)
75
78
  if hidden:
76
79
  terminalreporter.write_line(
77
- f" ... +{hidden} more (run with -v to list all)"
80
+ f" ... +{hidden} more (-v to list all)", light=True
78
81
  )
79
82
 
80
83
 
@@ -5,7 +5,6 @@ interception that records crossings.
5
5
  from __future__ import annotations
6
6
 
7
7
  import functools
8
- import linecache
9
8
  from dataclasses import dataclass, field
10
9
  from pathlib import Path
11
10
  from typing import TYPE_CHECKING
@@ -35,8 +34,8 @@ class ViolationRecord:
35
34
 
36
35
  file: str
37
36
  line_number: int
38
- line_code: str
39
- crossed: str
37
+ crossed_aggregates: tuple[str, ...] # ("order", "payment")
38
+ joined_models: tuple[str, ...] # ("order.Invoice", "payrolls.IncomePayment")
40
39
  tests: set[str] = field(default_factory=set)
41
40
 
42
41
 
@@ -55,7 +54,7 @@ class BoundaryGuard:
55
54
  self._root = Path(root)
56
55
  self._original_execute_sql: Callable[..., Any] | None = None
57
56
  self._current_test: str | None = None
58
- self._violations: dict[tuple[str, int, str], ViolationRecord] = {}
57
+ self._violations: dict[tuple[str, int, tuple[str, ...]], ViolationRecord] = {}
59
58
 
60
59
  def install(self) -> None:
61
60
  from django.db.models.sql.compiler import SQLCompiler
@@ -80,7 +79,11 @@ class BoundaryGuard:
80
79
 
81
80
  @property
82
81
  def violations(self) -> list[ViolationRecord]:
83
- return sorted(self._violations.values(), key=lambda v: (v.file, v.line_number))
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
+ )
84
87
 
85
88
  def find_stale_patterns(self) -> list[str]:
86
89
  return self._ignore_tracker.find_stale_patterns()
@@ -99,8 +102,8 @@ class BoundaryGuard:
99
102
  self._ignore_tracker.mark_seen(file_paths=file_paths)
100
103
 
101
104
  labels = self._read_labels(query=query)
102
- crossed = self._find_crossing(labels=labels)
103
- if crossed is None:
105
+ crossing = self._find_crossing(labels=labels)
106
+ if crossing is None:
104
107
  return
105
108
 
106
109
  if file_paths is not None and self._ignore_tracker.has_ignore_for(
@@ -111,7 +114,9 @@ class BoundaryGuard:
111
114
 
112
115
  if frames is None:
113
116
  frames = find_in_project_frames(root=self._root)
114
- self._record_violation(call_place=frames[0] if frames else None, crossed=crossed)
117
+ self._record_violation(
118
+ call_place=frames[0] if frames else None, crossing=crossing
119
+ )
115
120
 
116
121
  def _read_labels(self, *, query: Query) -> list[str]:
117
122
  """Model labels of the tables the query actually reads.
@@ -127,38 +132,45 @@ class BoundaryGuard:
127
132
  and query.alias_refcount.get(alias, 0) > 0
128
133
  ]
129
134
 
130
- def _find_crossing(self, *, labels: Iterable[str]) -> str | None:
131
- """Return the crossed aggregate names ("order, payment") if the query
132
- crosses a boundary, else None."""
133
- aggregates = {
134
- aggregate
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
135
145
  for label in labels
136
146
  if (aggregate := self._aggregates_config.get(label.lower())) is not None
137
147
  }
148
+ aggregates = set(aggregate_by_label.values())
138
149
  if len(aggregates) <= 1:
139
150
  return None
140
- return ", ".join(sorted(aggregates))
151
+ return tuple(sorted(aggregates)), tuple(sorted(aggregate_by_label.keys()))
141
152
 
142
- def _record_violation(self, *, call_place: tuple[str, int] | None, crossed: str) -> None:
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
143
160
  file, line = call_place if call_place is not None else ("<unknown>", 0)
144
- key = (file, line, crossed)
161
+ key = (file, line, crossed_aggregates)
145
162
  record = self._violations.get(key)
146
163
  if record is None:
147
- line_code = self._read_line_code(file=file, line=line)
148
164
  record = ViolationRecord(
149
- file=file, line_number=line, line_code=line_code, crossed=crossed
165
+ file=file,
166
+ line_number=line,
167
+ crossed_aggregates=crossed_aggregates,
168
+ joined_models=joined_models,
150
169
  )
151
170
  self._violations[key] = record
152
171
  if self._current_test is not None:
153
172
  record.tests.add(self._current_test)
154
173
 
155
- def _read_line_code(self, *, file: str, line: int) -> str:
156
- """The offending line of code, stripped - or "" if it can't be read."""
157
- if line <= 0 or file == "<unknown>":
158
- return ""
159
- absolute_path = str(self._root / file)
160
- return linecache.getline(absolute_path, line).strip()
161
-
162
174
  @staticmethod
163
175
  def _extract_file_paths(frames: Iterable[tuple[str, int]]) -> set[str]:
164
176
  return {path for path, _ in frames}