pytest-orm-boundaries 0.7.1__tar.gz → 0.7.3__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.7.1 → pytest_orm_boundaries-0.7.3}/PKG-INFO +1 -1
  2. {pytest_orm_boundaries-0.7.1 → pytest_orm_boundaries-0.7.3}/pyproject.toml +2 -2
  3. {pytest_orm_boundaries-0.7.1 → pytest_orm_boundaries-0.7.3}/src/pytest_orm_boundaries/crossings.py +62 -1
  4. {pytest_orm_boundaries-0.7.1 → pytest_orm_boundaries-0.7.3}/src/pytest_orm_boundaries/guard.py +15 -2
  5. {pytest_orm_boundaries-0.7.1 → pytest_orm_boundaries-0.7.3}/src/pytest_orm_boundaries/ignores.py +11 -0
  6. {pytest_orm_boundaries-0.7.1 → pytest_orm_boundaries-0.7.3}/src/pytest_orm_boundaries/plugin.py +17 -1
  7. {pytest_orm_boundaries-0.7.1 → pytest_orm_boundaries-0.7.3}/LICENSE +0 -0
  8. {pytest_orm_boundaries-0.7.1 → pytest_orm_boundaries-0.7.3}/README.md +0 -0
  9. {pytest_orm_boundaries-0.7.1 → pytest_orm_boundaries-0.7.3}/src/pytest_orm_boundaries/__init__.py +0 -0
  10. {pytest_orm_boundaries-0.7.1 → pytest_orm_boundaries-0.7.3}/src/pytest_orm_boundaries/allows.py +0 -0
  11. {pytest_orm_boundaries-0.7.1 → pytest_orm_boundaries-0.7.3}/src/pytest_orm_boundaries/callstack.py +0 -0
  12. {pytest_orm_boundaries-0.7.1 → pytest_orm_boundaries-0.7.3}/src/pytest_orm_boundaries/config.py +0 -0
  13. {pytest_orm_boundaries-0.7.1 → pytest_orm_boundaries-0.7.3}/src/pytest_orm_boundaries/model_resolution.py +0 -0
  14. {pytest_orm_boundaries-0.7.1 → pytest_orm_boundaries-0.7.3}/src/pytest_orm_boundaries/prefetch_resolution.py +0 -0
  15. {pytest_orm_boundaries-0.7.1 → pytest_orm_boundaries-0.7.3}/src/pytest_orm_boundaries/report.py +0 -0
  16. {pytest_orm_boundaries-0.7.1 → pytest_orm_boundaries-0.7.3}/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.7.1
3
+ Version: 0.7.3
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.7.1"
7
+ version = "0.7.3"
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"
@@ -54,4 +54,4 @@ exclude-newer = "2 weeks"
54
54
  testpaths = ["tests"]
55
55
 
56
56
  [dependency-groups]
57
- dev = ["django>=4.2"]
57
+ dev = ["django>=4.2", "pytest-xdist>=3.8"]
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass, field
6
6
  from pathlib import Path
7
- from typing import TYPE_CHECKING
7
+ from typing import TYPE_CHECKING, TypedDict
8
8
 
9
9
  from pytest_orm_boundaries.callstack import find_frames_inside_project
10
10
 
@@ -26,6 +26,24 @@ class CrossingRecord:
26
26
  tests: set[str] = field(default_factory=set)
27
27
 
28
28
 
29
+ class SerializedCrossing(TypedDict):
30
+ """One crossing encoded using values supported by xdist's worker channel."""
31
+
32
+ file: str
33
+ line_number: int
34
+ crossed_aggregates: list[str]
35
+ involved_models: list[str]
36
+ tests: list[str]
37
+
38
+
39
+ class SerializedTrackerState(TypedDict):
40
+ """All process-local result state that the xdist controller must merge."""
41
+
42
+ crossings: list[SerializedCrossing]
43
+ seen_ignore_patterns: list[str]
44
+ used_ignore_patterns: list[str]
45
+
46
+
29
47
  class CrossingTracker:
30
48
  """Checks label sets against the aggregate rule and records the crossings,
31
49
  skipping allowed and ignored files and naming each crossing's call place and
@@ -137,3 +155,46 @@ class CrossingTracker:
137
155
  def find_stale_patterns(self) -> list[str]:
138
156
  """Ignore globs whose file ran but never crossed -- safe to delete."""
139
157
  return self._ignore_tracker.find_stale_patterns()
158
+
159
+ def serialize_state(self) -> SerializedTrackerState:
160
+ """Encode collected results using only xdist-serializable values."""
161
+ seen, used = self._ignore_tracker.export_matched_patterns()
162
+ return {
163
+ "crossings": [
164
+ {
165
+ "file": crossing.file,
166
+ "line_number": crossing.line_number,
167
+ "crossed_aggregates": list(crossing.crossed_aggregates),
168
+ "involved_models": list(crossing.involved_models),
169
+ "tests": sorted(crossing.tests),
170
+ }
171
+ for crossing in self.crossings
172
+ ],
173
+ "seen_ignore_patterns": sorted(seen),
174
+ "used_ignore_patterns": sorted(used),
175
+ }
176
+
177
+ def merge_state(self, state: SerializedTrackerState) -> None:
178
+ """Merge results collected by another process into this tracker."""
179
+ for crossing in state["crossings"]:
180
+ crossed = (
181
+ tuple(crossing["crossed_aggregates"]),
182
+ tuple(crossing["involved_models"]),
183
+ )
184
+ tests = crossing["tests"]
185
+ if not tests:
186
+ self.add_record(
187
+ call_place=(crossing["file"], crossing["line_number"]),
188
+ crossing=crossed,
189
+ test=None,
190
+ )
191
+ for test in tests:
192
+ self.add_record(
193
+ call_place=(crossing["file"], crossing["line_number"]),
194
+ crossing=crossed,
195
+ test=test,
196
+ )
197
+ self._ignore_tracker.merge_matched_patterns(
198
+ seen=state["seen_ignore_patterns"],
199
+ used=state["used_ignore_patterns"],
200
+ )
@@ -12,7 +12,7 @@ from functools import wraps
12
12
  from pathlib import Path
13
13
  from typing import TYPE_CHECKING
14
14
 
15
- from pytest_orm_boundaries.crossings import CrossingTracker
15
+ from pytest_orm_boundaries.crossings import CrossingTracker, SerializedTrackerState
16
16
  from pytest_orm_boundaries.model_resolution import resolve_labels
17
17
  from pytest_orm_boundaries.prefetch_resolution import resolve_prefetch_step_models
18
18
  from pytest_orm_boundaries.sql_parsing import extract_table_names, looks_like_data_query
@@ -158,6 +158,14 @@ class BoundaryGuard:
158
158
  def find_stale_patterns(self) -> list[str]:
159
159
  return self._tracker.find_stale_patterns()
160
160
 
161
+ def serialize_state(self) -> SerializedTrackerState:
162
+ """Return process-local results for transport to an xdist controller."""
163
+ return self._tracker.serialize_state()
164
+
165
+ def merge_state(self, state: SerializedTrackerState) -> None:
166
+ """Merge results received by the xdist controller from one worker."""
167
+ self._tracker.merge_state(state)
168
+
161
169
  def _handle_query(self, sql: str, vendor: str) -> None:
162
170
  """Map one executed data query to its table labels and check them."""
163
171
  table_names = extract_table_names(sql, vendor)
@@ -170,7 +178,12 @@ class BoundaryGuard:
170
178
  """Map one prefetch to the model pair per relation step and check them."""
171
179
  if not model_instances:
172
180
  return
173
- source_model = type(model_instances[0])
181
+ from django.db.models import Model
182
+
183
+ first = model_instances[0]
184
+ if not isinstance(first, Model):
185
+ return
186
+ source_model = type(first)
174
187
  step_model_pairs = resolve_prefetch_step_models(
175
188
  source_model=source_model, lookups=lookups
176
189
  )
@@ -31,5 +31,16 @@ class IgnoreTracker:
31
31
  def find_stale_patterns(self) -> list[str]:
32
32
  return sorted(self._seen - self._used)
33
33
 
34
+ def export_matched_patterns(self) -> tuple[set[str], set[str]]:
35
+ """Return copies of the patterns seen and used during this run."""
36
+ return set(self._seen), set(self._used)
37
+
38
+ def merge_matched_patterns(
39
+ self, *, seen: Iterable[str], used: Iterable[str]
40
+ ) -> None:
41
+ """Merge ignore activity collected by another process."""
42
+ self._seen.update(seen)
43
+ self._used.update(used)
44
+
34
45
  def _find_matching_patterns(self, file_paths: Iterable[str]) -> set[str]:
35
46
  return {p for f in file_paths for p in self._patterns if fnmatch(f, p)}
@@ -20,6 +20,7 @@ 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
+ worker_output_key = "pytest_orm_boundaries"
23
24
 
24
25
 
25
26
  class BoundariesConfigWarning(UserWarning):
@@ -120,10 +121,25 @@ def pytest_runtest_protocol(item: pytest.Item, nextitem: pytest.Item | None):
120
121
  def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
121
122
  """A clean-but-violating run must still fail the run (via the exit code)."""
122
123
  guard = _installed_guard(session.config)
123
- if guard is not None and guard.crossings and exitstatus == pytest.ExitCode.OK:
124
+ if guard is None:
125
+ return
126
+ if hasattr(session.config, "workerinput"):
127
+ session.config.workeroutput[worker_output_key] = guard.serialize_state()
128
+ return
129
+ if guard.crossings and exitstatus == pytest.ExitCode.OK:
124
130
  session.exitstatus = pytest.ExitCode.TESTS_FAILED
125
131
 
126
132
 
133
+ @pytest.hookimpl(optionalhook=True)
134
+ def pytest_testnodedown(node, error) -> None:
135
+ """Merge one xdist worker's results into the controller's guard."""
136
+ guard = _installed_guard(node.config)
137
+ worker_output = getattr(node, "workeroutput", {})
138
+ state = worker_output.get(worker_output_key)
139
+ if guard is not None and state is not None:
140
+ guard.merge_state(state)
141
+
142
+
127
143
  def pytest_terminal_summary(
128
144
  terminalreporter: pytest.TerminalReporter,
129
145
  exitstatus: int,