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.
- {pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.3.1}/PKG-INFO +1 -1
- {pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.3.1}/pyproject.toml +1 -1
- {pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.3.1}/src/pytest_orm_boundaries/build_report.py +13 -10
- {pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.3.1}/src/pytest_orm_boundaries/guard.py +37 -25
- {pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.3.1}/LICENSE +0 -0
- {pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.3.1}/README.md +0 -0
- {pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.3.1}/src/pytest_orm_boundaries/__init__.py +0 -0
- {pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.3.1}/src/pytest_orm_boundaries/call_stack.py +0 -0
- {pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.3.1}/src/pytest_orm_boundaries/ignores.py +0 -0
- {pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.3.1}/src/pytest_orm_boundaries/plugin.py +0 -0
- {pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.3.1}/src/pytest_orm_boundaries/read_config.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-orm-boundaries
|
|
3
|
-
Version: 0.3.
|
|
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.
|
|
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 =
|
|
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:
|
|
26
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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"
|
|
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 (
|
|
80
|
+
f" ... +{hidden} more (-v to list all)", light=True
|
|
78
81
|
)
|
|
79
82
|
|
|
80
83
|
|
{pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.3.1}/src/pytest_orm_boundaries/guard.py
RENAMED
|
@@ -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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
if
|
|
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(
|
|
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(
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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
|
|
151
|
+
return tuple(sorted(aggregates)), tuple(sorted(aggregate_by_label.keys()))
|
|
141
152
|
|
|
142
|
-
def _record_violation(
|
|
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,
|
|
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,
|
|
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}
|
|
File without changes
|
|
File without changes
|
{pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.3.1}/src/pytest_orm_boundaries/__init__.py
RENAMED
|
File without changes
|
{pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.3.1}/src/pytest_orm_boundaries/call_stack.py
RENAMED
|
File without changes
|
{pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.3.1}/src/pytest_orm_boundaries/ignores.py
RENAMED
|
File without changes
|
{pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.3.1}/src/pytest_orm_boundaries/plugin.py
RENAMED
|
File without changes
|
{pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.3.1}/src/pytest_orm_boundaries/read_config.py
RENAMED
|
File without changes
|