pytest-orm-boundaries 0.1.0__tar.gz → 0.3.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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-orm-boundaries
3
- Version: 0.1.0
4
- Summary: Pytest plugin that fails your tests when ORM queries cross DDD aggregate boundaries (Django supported today).
3
+ Version: 0.3.0
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
@@ -26,7 +26,9 @@ Description-Content-Type: text/markdown
26
26
 
27
27
  # pytest-orm-boundaries
28
28
 
29
- A pytest plugin that fails your tests when ORM queries cross your DDD aggregate boundaries.
29
+ > 💡 **Even if you control your imports boundaries still can leak through the ORM**
30
+
31
+ A `pytest-orm-boundaries` is a pytest plugin that reports ORM queries crossing your DDD aggregate boundaries.
30
32
 
31
33
  Currently works with Django ORM.
32
34
 
@@ -35,12 +37,13 @@ aggregate should not reach into the internals of another. Django's `__` relation
35
37
  lookups make it easy to cross those boundaries silently:
36
38
 
37
39
  ```python
38
- # Payment and Order belong to different aggregates — this query couples them.
40
+ # Purchase and Client belong to different aggregates — this query couples them.
39
41
  Purchase.objects.get(client__name="John")
40
42
  ```
41
43
 
42
44
  `pytest-orm-boundaries` watches the queries your test suite executes and
43
- reports the ones that step outside their aggregate, including `__`, subqueries and other.
45
+ reports the ones that step outside their aggregate, whether through `__`
46
+ lookups, subqueries, or other joins.
44
47
 
45
48
  ## Install
46
49
 
@@ -65,6 +68,54 @@ purchase = ["bookshop.Purchase", "bookshop.PurchaseLine"]
65
68
  Models are written as `app_label.Model`. Models not listed in any aggregate are
66
69
  not checked. Without a config file the plugin emits a warning and runs no checks.
67
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
+
91
+ ## Ignoring files
92
+
93
+ Add exceptions so that known offenders keep passing while you fix them one file at a time:
94
+
95
+ ```toml
96
+ [ignore]
97
+ files = [
98
+ "app/billing.py",
99
+ "app/legacy/*",
100
+ ]
101
+ ```
102
+
103
+ Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html),
104
+ resolved relative to pytest's root directory and matched against either:
105
+
106
+ - the file that issues the query, or
107
+ - the test file.
108
+
109
+ If an ignored file runs queries through the whole suite without ever crossing a
110
+ boundary, the plugin says so at the end:
111
+
112
+ ```
113
+ ======================= orm-boundaries: stale ignores ========================
114
+ These [ignore] entries no longer suppress any boundary violation - their files are clean now.
115
+ Remove them from boundaries.toml:
116
+ - app/billing.py
117
+ ```
118
+
68
119
  ## Status
69
120
 
70
121
  Alpha - testing basic version.
@@ -0,0 +1,95 @@
1
+ # pytest-orm-boundaries
2
+
3
+ > 💡 **Even if you control your imports — boundaries still can leak through the ORM**
4
+
5
+ A `pytest-orm-boundaries` is a pytest plugin that reports ORM queries crossing your DDD aggregate boundaries.
6
+
7
+ Currently works with Django ORM.
8
+
9
+ In domain-driven design, an aggregate is a consistency boundary: code in one
10
+ aggregate should not reach into the internals of another. Django's `__` relation
11
+ lookups make it easy to cross those boundaries silently:
12
+
13
+ ```python
14
+ # Purchase and Client belong to different aggregates — this query couples them.
15
+ Purchase.objects.get(client__name="John")
16
+ ```
17
+
18
+ `pytest-orm-boundaries` watches the queries your test suite executes and
19
+ reports the ones that step outside their aggregate, whether through `__`
20
+ lookups, subqueries, or other joins.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install pytest-orm-boundaries
26
+ ```
27
+
28
+ pytest picks the plugin up automatically.
29
+
30
+ ## Configure
31
+
32
+ Declare your aggregates in `boundaries.toml` at the project root (or point at
33
+ the file with `--boundaries-config` / the `boundaries_config` ini option):
34
+
35
+ ```toml
36
+ [aggregates]
37
+ client = ["bookshop.Client"]
38
+ book = ["bookshop.Book"]
39
+ purchase = ["bookshop.Purchase", "bookshop.PurchaseLine"]
40
+ ```
41
+
42
+ Models are written as `app_label.Model`. Models not listed in any aggregate are
43
+ not checked. Without a config file the plugin emits a warning and runs no checks.
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
+
65
+ ## Ignoring files
66
+
67
+ Add exceptions so that known offenders keep passing while you fix them one file at a time:
68
+
69
+ ```toml
70
+ [ignore]
71
+ files = [
72
+ "app/billing.py",
73
+ "app/legacy/*",
74
+ ]
75
+ ```
76
+
77
+ Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html),
78
+ resolved relative to pytest's root directory and matched against either:
79
+
80
+ - the file that issues the query, or
81
+ - the test file.
82
+
83
+ If an ignored file runs queries through the whole suite without ever crossing a
84
+ boundary, the plugin says so at the end:
85
+
86
+ ```
87
+ ======================= orm-boundaries: stale ignores ========================
88
+ These [ignore] entries no longer suppress any boundary violation - their files are clean now.
89
+ Remove them from boundaries.toml:
90
+ - app/billing.py
91
+ ```
92
+
93
+ ## Status
94
+
95
+ Alpha - testing basic version.
@@ -4,8 +4,8 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "pytest-orm-boundaries"
7
- version = "0.1.0"
8
- description = "Pytest plugin that fails your tests when ORM queries cross DDD aggregate boundaries (Django supported today)."
7
+ version = "0.3.0"
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,99 @@
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 = 20
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 place: where the query crosses a
26
+ boundary, the offending line of code, 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
+ if violation.line_code:
62
+ terminalreporter.write_line(f" code: {violation.line_code}")
63
+ terminalreporter.write_line(f" crosses: {violation.crossed}")
64
+
65
+ tests = sorted(violation.tests)
66
+ if not tests:
67
+ terminalreporter.write_line(" affected tests: (none captured)")
68
+ return
69
+
70
+ shown = tests if verbose else tests[:MAX_TESTS_SHOWN]
71
+ terminalreporter.write_line(f" affected tests ({len(tests)}):")
72
+ for nodeid in shown:
73
+ terminalreporter.write_line(f" {nodeid}")
74
+ hidden = len(tests) - len(shown)
75
+ if hidden:
76
+ terminalreporter.write_line(
77
+ f" ... +{hidden} more (run with -v to list all)"
78
+ )
79
+
80
+
81
+ def report_stale_ignores(
82
+ *, terminalreporter: pytest.TerminalReporter, stale: list[str]
83
+ ) -> None:
84
+ if not stale:
85
+ return
86
+ terminalreporter.section("orm-boundaries: stale ignores", yellow=True, bold=True)
87
+ terminalreporter.write_line(
88
+ "These [ignore] entries no longer suppress any boundary violation - "
89
+ f"their files are clean now. Remove them from {CONFIG_FILE_NAME}:",
90
+ yellow=True,
91
+ )
92
+ for pattern in stale:
93
+ terminalreporter.write_line(f" - {pattern}", yellow=True)
94
+
95
+
96
+ def render_header(*, config_path: Path | None) -> str:
97
+ if config_path is None:
98
+ return "orm-boundaries: no config file, checks disabled"
99
+ 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,185 @@
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
+ import linecache
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ from pytest_orm_boundaries.call_stack import find_in_project_frames
14
+ from pytest_orm_boundaries.ignores import IgnoreTracker
15
+ from pytest_orm_boundaries.read_config import (
16
+ load_aggregates_from_config,
17
+ load_ignored_files_from_config,
18
+ )
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Callable, Iterable
22
+ from typing import Any
23
+
24
+ from django.db.models import Model
25
+ from django.db.models.sql.query import Query
26
+
27
+
28
+ @dataclass
29
+ class ViolationRecord:
30
+ """One offending call place and the tests that reached it.
31
+
32
+ Grouped by call place so a crossing shared by many tests is one entry,
33
+ not one line per test.
34
+ """
35
+
36
+ file: str
37
+ line_number: int
38
+ line_code: str
39
+ crossed: str
40
+ tests: set[str] = field(default_factory=set)
41
+
42
+
43
+ class BoundaryGuard:
44
+ """Intercepts Django's executed queries and records aggregate crossings."""
45
+
46
+ def __init__(
47
+ self,
48
+ *,
49
+ aggregates_config: dict[str, str],
50
+ ignore_tracker: IgnoreTracker,
51
+ root: Path,
52
+ ) -> None:
53
+ self._aggregates_config = aggregates_config
54
+ self._ignore_tracker = ignore_tracker
55
+ self._root = Path(root)
56
+ self._original_execute_sql: Callable[..., Any] | None = None
57
+ self._current_test: str | None = None
58
+ self._violations: dict[tuple[str, int, str], ViolationRecord] = {}
59
+
60
+ def install(self) -> None:
61
+ from django.db.models.sql.compiler import SQLCompiler
62
+
63
+ original_execute_sql = SQLCompiler.execute_sql
64
+ self._original_execute_sql = original_execute_sql
65
+
66
+ def patched_execute_sql(compiler, *args, **kwargs):
67
+ self._handle_query(query=compiler.query)
68
+ return original_execute_sql(compiler, *args, **kwargs)
69
+
70
+ SQLCompiler.execute_sql = patched_execute_sql
71
+
72
+ def uninstall(self) -> None:
73
+ from django.db.models.sql.compiler import SQLCompiler
74
+
75
+ SQLCompiler.execute_sql = self._original_execute_sql
76
+
77
+ def set_current_test(self, nodeid: str | None) -> None:
78
+ """Remember which test is running so a recorded crossing can name it."""
79
+ self._current_test = nodeid
80
+
81
+ @property
82
+ def violations(self) -> list[ViolationRecord]:
83
+ return sorted(self._violations.values(), key=lambda v: (v.file, v.line_number))
84
+
85
+ def find_stale_patterns(self) -> list[str]:
86
+ return self._ignore_tracker.find_stale_patterns()
87
+
88
+ def _handle_query(self, *, query: Query) -> None:
89
+ """Handle one executed query: gather stack context, apply the aggregate
90
+ rule, and record a crossing (unless it's clean or its place is ignored).
91
+ """
92
+ # ``frames`` is the in-project part of this query's call stack,
93
+ # used by the ignore/stale check and the report.
94
+ frames: list[tuple[str, int]] | None = None
95
+ file_paths: list[str] | None = None
96
+ if self._ignore_tracker.is_active:
97
+ frames = find_in_project_frames(root=self._root)
98
+ file_paths = self._extract_file_paths(frames)
99
+ self._ignore_tracker.mark_seen(file_paths=file_paths)
100
+
101
+ labels = self._read_labels(query=query)
102
+ crossed = self._find_crossing(labels=labels)
103
+ if crossed is None:
104
+ return
105
+
106
+ if file_paths is not None and self._ignore_tracker.has_ignore_for(
107
+ file_paths=file_paths
108
+ ):
109
+ self._ignore_tracker.mark_used(file_paths=file_paths)
110
+ return
111
+
112
+ if frames is None:
113
+ frames = find_in_project_frames(root=self._root)
114
+ self._record_violation(call_place=frames[0] if frames else None, crossed=crossed)
115
+
116
+ def _read_labels(self, *, query: Query) -> list[str]:
117
+ """Model labels of the tables the query actually reads.
118
+
119
+ A filter on a foreign-key id reads a column already on the current
120
+ table, so the table it points to isn't read -- and isn't counted.
121
+ """
122
+ table_to_model = _map_tables_to_models()
123
+ return [
124
+ table_to_model[table.table_name]._meta.label
125
+ for alias, table in query.alias_map.items()
126
+ if table.table_name in table_to_model
127
+ and query.alias_refcount.get(alias, 0) > 0
128
+ ]
129
+
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
+ for label in labels
136
+ if (aggregate := self._aggregates_config.get(label.lower())) is not None
137
+ }
138
+ if len(aggregates) <= 1:
139
+ return None
140
+ return ", ".join(sorted(aggregates))
141
+
142
+ def _record_violation(self, *, call_place: tuple[str, int] | None, crossed: str) -> None:
143
+ file, line = call_place if call_place is not None else ("<unknown>", 0)
144
+ key = (file, line, crossed)
145
+ record = self._violations.get(key)
146
+ if record is None:
147
+ line_code = self._read_line_code(file=file, line=line)
148
+ record = ViolationRecord(
149
+ file=file, line_number=line, line_code=line_code, crossed=crossed
150
+ )
151
+ self._violations[key] = record
152
+ if self._current_test is not None:
153
+ record.tests.add(self._current_test)
154
+
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
+ @staticmethod
163
+ def _extract_file_paths(frames: Iterable[tuple[str, int]]) -> set[str]:
164
+ return {path for path, _ in frames}
165
+
166
+
167
+ def build_guard(*, rootpath: Path, config_path: Path) -> BoundaryGuard | None:
168
+ aggregates_by_model = load_aggregates_from_config(path=config_path)
169
+ if not aggregates_by_model:
170
+ return None
171
+
172
+ ignore_patterns = load_ignored_files_from_config(path=config_path)
173
+ tracker = IgnoreTracker(patterns=ignore_patterns)
174
+
175
+ return BoundaryGuard(
176
+ aggregates_config=aggregates_by_model, ignore_tracker=tracker, root=rootpath
177
+ )
178
+
179
+
180
+ @functools.cache
181
+ def _map_tables_to_models() -> dict[str, type[Model]]:
182
+ """Map db_table -> model class for every installed model."""
183
+ from django.apps import apps
184
+
185
+ return {model._meta.db_table: model for model in apps.get_models()}
@@ -0,0 +1,35 @@
1
+ """Per-file ignores at runtime: the IgnoreTracker book-keeping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from fnmatch import fnmatch
7
+
8
+
9
+ class IgnoreTracker:
10
+ """Decides whether a violation is ignored, and reports ignore globs that went
11
+ stale - their file ran but never crossed a boundary."""
12
+
13
+ def __init__(self, *, patterns: Iterable[str]) -> None:
14
+ self._patterns = list(dict.fromkeys(patterns)) # ordered, de-duped
15
+ self._seen: set[str] = set() # patterns executed during the run
16
+ self._used: set[str] = set() # ...that also crossed a boundary
17
+
18
+ @property
19
+ def is_active(self) -> bool:
20
+ return bool(self._patterns)
21
+
22
+ def mark_seen(self, *, file_paths: Iterable[str]) -> None:
23
+ self._seen.update(self._find_matching_patterns(file_paths))
24
+
25
+ def mark_used(self, *, file_paths: Iterable[str]) -> None:
26
+ self._used.update(self._find_matching_patterns(file_paths))
27
+
28
+ def has_ignore_for(self, *, file_paths: Iterable[str]) -> bool:
29
+ return bool(self._find_matching_patterns(file_paths))
30
+
31
+ def find_stale_patterns(self) -> list[str]:
32
+ return sorted(self._seen - self._used)
33
+
34
+ def _find_matching_patterns(self, file_paths: Iterable[str]) -> set[str]:
35
+ return {p for f in file_paths for p in self._patterns if fnmatch(f, p)}
@@ -0,0 +1,122 @@
1
+ """Pytest plugin wiring: options, config discovery, activation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ import pytest
9
+
10
+ from pytest_orm_boundaries import build_report
11
+ from pytest_orm_boundaries.guard import build_guard
12
+ from pytest_orm_boundaries.read_config import (
13
+ CONFIG_FILE_NAME,
14
+ BoundariesConfigError,
15
+ discover_config_path,
16
+ )
17
+
18
+ if TYPE_CHECKING:
19
+ from pytest_orm_boundaries.guard import BoundaryGuard
20
+
21
+ config_path_key = pytest.StashKey[Path | None]()
22
+ guard_key = pytest.StashKey["BoundaryGuard"]()
23
+
24
+
25
+ class BoundariesConfigWarning(UserWarning):
26
+ """Plugin is installed but no config file was found."""
27
+
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
+
34
+ def pytest_addoption(parser: pytest.Parser) -> None:
35
+ group = parser.getgroup("orm-boundaries")
36
+ group.addoption(
37
+ "--boundaries-config",
38
+ dest="boundaries_config",
39
+ metavar="PATH",
40
+ default=None,
41
+ help=(
42
+ "Path to the boundaries TOML config. Defaults to "
43
+ f"{CONFIG_FILE_NAME} in the pytest root directory; if no "
44
+ "config file is found, boundary checks are disabled."
45
+ ),
46
+ )
47
+ parser.addini(
48
+ "boundaries_config",
49
+ help="Same as --boundaries-config.",
50
+ default="",
51
+ )
52
+
53
+
54
+ def pytest_configure(config: pytest.Config) -> None:
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.
57
+ """
58
+ explicit_config = config.getoption("boundaries_config") or config.getini("boundaries_config")
59
+ try:
60
+ config_path = discover_config_path(
61
+ explicit=explicit_config, rootpath=config.rootpath
62
+ )
63
+ config.stash[config_path_key] = config_path
64
+ if config_path is None:
65
+ warning = BoundariesConfigWarning(f"No {CONFIG_FILE_NAME} found, no checks will run")
66
+ config.issue_config_time_warning(warning, stacklevel=2)
67
+ return
68
+
69
+ guard = build_guard(rootpath=config.rootpath, config_path=config_path)
70
+ if guard is not None:
71
+ guard.install()
72
+ config.stash[guard_key] = guard
73
+ except BoundariesConfigError as exc:
74
+ raise pytest.UsageError(f"orm-boundaries: {exc}") from exc
75
+
76
+
77
+ def pytest_unconfigure(config: pytest.Config) -> None:
78
+ guard = _installed_guard(config)
79
+ if guard is not None:
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
101
+
102
+
103
+ def pytest_terminal_summary(
104
+ terminalreporter: pytest.TerminalReporter,
105
+ exitstatus: int,
106
+ config: pytest.Config,
107
+ ) -> None:
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)
118
+
119
+
120
+ def pytest_report_header(config: pytest.Config) -> str:
121
+ config_path = config.stash.get(config_path_key, None)
122
+ return build_report.render_header(config_path=config_path)
@@ -0,0 +1,91 @@
1
+ """Locating, reading, and validating ``boundaries.toml``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ CONFIG_FILE_NAME = "boundaries.toml"
10
+
11
+
12
+ class BoundariesConfigError(Exception):
13
+ """Raised when the config file is malformed or semantically invalid."""
14
+
15
+
16
+ def discover_config_path(*, explicit: str | None, rootpath: Path) -> Path | None:
17
+ """Resolve the config path.
18
+
19
+ Fall back to the rootdir default and return None if it is absent.
20
+ """
21
+ if explicit:
22
+ path = Path(explicit)
23
+ if not path.is_absolute():
24
+ path = rootpath / path
25
+ if not path.is_file():
26
+ raise BoundariesConfigError(f"config file not found: {path}")
27
+ return path
28
+ default = rootpath / CONFIG_FILE_NAME
29
+ return default if default.is_file() else None
30
+
31
+
32
+ def _read_config(*, path: Path) -> dict[str, Any]:
33
+ try:
34
+ with path.open("rb") as fh:
35
+ return tomllib.load(fh)
36
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
37
+ raise BoundariesConfigError(f"{path}: invalid TOML ({exc})") from exc
38
+
39
+
40
+ def load_aggregates_from_config(*, path: Path) -> dict[str, str]:
41
+ """Parse the config into a {model_label: aggregate_name} map.
42
+
43
+ Model labels are lower-cased ("app_label.modelname") for case-insensitive
44
+ matching.
45
+ """
46
+ config = _read_config(path=path)
47
+
48
+ aggregates_by_model: dict[str, str] = {}
49
+ for aggregate, members in config.get("aggregates", {}).items():
50
+ if isinstance(members, dict):
51
+ members = members.get("models", [])
52
+ if not isinstance(members, list):
53
+ raise BoundariesConfigError(
54
+ f"{path}: aggregate '{aggregate}' must be a list of model labels"
55
+ )
56
+ for label in members:
57
+ if not isinstance(label, str):
58
+ raise BoundariesConfigError(
59
+ f"{path}: aggregate '{aggregate}' has a non-string member: {label!r}"
60
+ )
61
+ owner = aggregates_by_model.setdefault(label.lower(), aggregate)
62
+ if owner != aggregate:
63
+ raise BoundariesConfigError(
64
+ f"{path}: model '{label}' claimed by two aggregates: "
65
+ f"'{owner}' and '{aggregate}'"
66
+ )
67
+ return aggregates_by_model
68
+
69
+
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
+
74
+ ignore = data.get("ignore", {})
75
+ if not isinstance(ignore, dict):
76
+ raise BoundariesConfigError(f"{path}: [ignore] must be a table")
77
+
78
+ files = ignore.get("files", [])
79
+ if not isinstance(files, list):
80
+ raise BoundariesConfigError(
81
+ f"{path}: [ignore] files must be a list of glob patterns"
82
+ )
83
+
84
+ patterns: list[str] = []
85
+ for entry in files:
86
+ if not isinstance(entry, str):
87
+ raise BoundariesConfigError(
88
+ f"{path}: [ignore] files has a non-string entry: {entry!r}"
89
+ )
90
+ patterns.append(entry)
91
+ return patterns
@@ -1,44 +0,0 @@
1
- # pytest-orm-boundaries
2
-
3
- A pytest plugin that fails your tests when ORM queries cross your DDD aggregate boundaries.
4
-
5
- Currently works with Django ORM.
6
-
7
- In domain-driven design, an aggregate is a consistency boundary: code in one
8
- aggregate should not reach into the internals of another. Django's `__` relation
9
- lookups make it easy to cross those boundaries silently:
10
-
11
- ```python
12
- # Payment and Order belong to different aggregates — this query couples them.
13
- Purchase.objects.get(client__name="John")
14
- ```
15
-
16
- `pytest-orm-boundaries` watches the queries your test suite executes and
17
- reports the ones that step outside their aggregate, including `__`, subqueries and other.
18
-
19
- ## Install
20
-
21
- ```bash
22
- pip install pytest-orm-boundaries
23
- ```
24
-
25
- pytest picks the plugin up automatically.
26
-
27
- ## Configure
28
-
29
- Declare your aggregates in `boundaries.toml` at the project root (or point at
30
- the file with `--boundaries-config` / the `boundaries_config` ini option):
31
-
32
- ```toml
33
- [aggregates]
34
- client = ["bookshop.Client"]
35
- book = ["bookshop.Book"]
36
- purchase = ["bookshop.Purchase", "bookshop.PurchaseLine"]
37
- ```
38
-
39
- Models are written as `app_label.Model`. Models not listed in any aggregate are
40
- not checked. Without a config file the plugin emits a warning and runs no checks.
41
-
42
- ## Status
43
-
44
- Alpha - testing basic version.
@@ -1,128 +0,0 @@
1
- """Aggregate-boundary checking: parse the config, watch queries, fail on crossings.
2
-
3
- Django is imported lazily, inside the functions that need it (and under
4
- TYPE_CHECKING for annotations), so importing this module never requires Django
5
- to be installed.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import functools
11
- import tomllib
12
- from collections.abc import Callable, Iterable
13
- from pathlib import Path
14
- from typing import TYPE_CHECKING, Any
15
-
16
- if TYPE_CHECKING:
17
- from django.db.models import Model
18
- from django.db.models.sql.query import Query
19
-
20
-
21
- class BoundaryViolation(Exception):
22
- """Raised when a query joins models from more than one aggregate."""
23
-
24
-
25
- class BoundariesConfigError(Exception):
26
- """Raised when the config file is malformed or semantically invalid."""
27
-
28
-
29
- def load_aggregates_from_config(*, path: Path) -> dict[str, str]:
30
- """Parse the config into a {model_label: aggregate_name} map.
31
-
32
- Model labels are lower-cased ("app_label.modelname") for case-insensitive
33
- matching.
34
- """
35
- try:
36
- with path.open("rb") as fh:
37
- data = tomllib.load(fh)
38
- except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
39
- raise BoundariesConfigError(f"{path}: invalid TOML ({exc})") from exc
40
-
41
- aggregates_by_model: dict[str, str] = {}
42
- for aggregate, members in data.get("aggregates", {}).items():
43
- if isinstance(members, dict):
44
- members = members.get("models", [])
45
- if not isinstance(members, list):
46
- raise BoundariesConfigError(
47
- f"{path}: aggregate '{aggregate}' must be a list of model labels"
48
- )
49
- for label in members:
50
- if not isinstance(label, str):
51
- raise BoundariesConfigError(
52
- f"{path}: aggregate '{aggregate}' has a non-string member: {label!r}"
53
- )
54
- owner = aggregates_by_model.setdefault(label.lower(), aggregate)
55
- if owner != aggregate:
56
- raise BoundariesConfigError(
57
- f"{path}: model '{label}' claimed by two aggregates: "
58
- f"'{owner}' and '{aggregate}'"
59
- )
60
- return aggregates_by_model
61
-
62
-
63
- def _check_models(
64
- *,
65
- models: Iterable[type[Model]],
66
- aggregates_config: dict[str, str],
67
- ) -> None:
68
-
69
- aggregate_by_model_label = {
70
- model._meta.label: aggregate
71
- for model in models
72
- if (aggregate := aggregates_config.get(model._meta.label_lower)) is not None
73
- }
74
- aggregates = set(aggregate_by_model_label.values())
75
- if len(aggregates) <= 1:
76
- return
77
-
78
- crossed = ", ".join(sorted(aggregates))
79
- joined = ", ".join(sorted(aggregate_by_model_label.keys()))
80
- raise BoundaryViolation(
81
- f"orm-boundaries: query crosses aggregate boundaries "
82
- f"({crossed}); joined models: {joined}"
83
- )
84
-
85
-
86
- @functools.cache
87
- def _table_to_model() -> dict[str, type[Model]]:
88
- """Map db_table -> model class for every installed model."""
89
- from django.apps import apps
90
-
91
- return {model._meta.db_table: model for model in apps.get_models()}
92
-
93
-
94
- def _get_models_from_query(*, query: Query) -> list[type[Model]]:
95
- table_to_model = _table_to_model()
96
- return [
97
- table_to_model[table.table_name]
98
- for table in query.alias_map.values()
99
- if table.table_name in table_to_model
100
- ]
101
-
102
-
103
- def install_guard(*, aggregates_config: dict[str, str]) -> Callable[..., Any]:
104
- """Wrap SQLCompiler.execute_sql to check every executed query.
105
-
106
- Returns the original method so the caller can restore it later.
107
- """
108
- from django.db.models.sql.compiler import SQLCompiler
109
-
110
- original = SQLCompiler.execute_sql
111
-
112
- def patched_execute_sql(self, *args, **kwargs):
113
- query_models = _get_models_from_query(query=self.query)
114
- _check_models(
115
- models=query_models,
116
- aggregates_config=aggregates_config,
117
- )
118
- return original(self, *args, **kwargs)
119
-
120
- SQLCompiler.execute_sql = patched_execute_sql
121
- return original
122
-
123
-
124
- def uninstall_guard(*, original: Callable[..., Any]) -> None:
125
- """Restore the original SQLCompiler.execute_sql."""
126
- from django.db.models.sql.compiler import SQLCompiler
127
-
128
- SQLCompiler.execute_sql = original
@@ -1,97 +0,0 @@
1
- """Pytest plugin wiring: options, config discovery, activation."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
-
7
- import pytest
8
-
9
- from .check_boundaries import (
10
- BoundariesConfigError,
11
- install_guard,
12
- load_aggregates_from_config,
13
- uninstall_guard,
14
- )
15
-
16
- CONFIG_FILE_NAME = "boundaries.toml"
17
-
18
- config_path_key = pytest.StashKey[Path | None]()
19
- original_execute_sql_key = pytest.StashKey[object]()
20
-
21
-
22
- class BoundariesConfigWarning(UserWarning):
23
- """Plugin is installed but no config file was found."""
24
-
25
-
26
- def pytest_addoption(parser: pytest.Parser) -> None:
27
- group = parser.getgroup("orm-boundaries")
28
- group.addoption(
29
- "--boundaries-config",
30
- dest="boundaries_config",
31
- metavar="PATH",
32
- default=None,
33
- help=(
34
- "Path to the boundaries TOML config. Defaults to "
35
- f"{CONFIG_FILE_NAME} in the pytest root directory; if no config "
36
- "file is found, boundary checks are disabled."
37
- ),
38
- )
39
- parser.addini(
40
- "boundaries_config",
41
- help="Same as --boundaries-config.",
42
- default="",
43
- )
44
-
45
-
46
- def _discover_config_path(*, config: pytest.Config) -> Path | None:
47
- explicit = config.getoption("boundaries_config") or config.getini(
48
- "boundaries_config"
49
- )
50
- if explicit:
51
- path = Path(explicit)
52
- if not path.is_absolute():
53
- path = config.rootpath / path
54
- if not path.is_file():
55
- raise pytest.UsageError(
56
- f"orm-boundaries: config file not found: {path}"
57
- )
58
- return path
59
- default = config.rootpath / CONFIG_FILE_NAME
60
- return default if default.is_file() else None
61
-
62
-
63
- def pytest_configure(config: pytest.Config) -> None:
64
- path = _discover_config_path(config=config)
65
- config.stash[config_path_key] = path
66
- if path is None:
67
- config.issue_config_time_warning(
68
- BoundariesConfigWarning(
69
- f"no {CONFIG_FILE_NAME} found in the pytest root directory; "
70
- "orm-boundaries is inactive, so no boundary checks will run."
71
- ),
72
- stacklevel=2,
73
- )
74
- return
75
- try:
76
- aggregates_by_model = load_aggregates_from_config(path=path)
77
- except BoundariesConfigError as exc:
78
- raise pytest.UsageError(f"orm-boundaries: {exc}") from exc
79
- if not aggregates_by_model:
80
- # Config present but defines no aggregates, so skip patching entirely.
81
- return
82
- config.stash[original_execute_sql_key] = install_guard(
83
- aggregates_config=aggregates_by_model
84
- )
85
-
86
-
87
- def pytest_unconfigure(config: pytest.Config) -> None:
88
- original = config.stash.get(original_execute_sql_key, None)
89
- if original is not None:
90
- uninstall_guard(original=original)
91
-
92
-
93
- def pytest_report_header(config: pytest.Config) -> str:
94
- path = config.stash.get(config_path_key, None)
95
- if path is None:
96
- return "orm-boundaries: no config file, checks disabled"
97
- return f"orm-boundaries: config {path}"