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.
- {pytest_orm_boundaries-0.1.0 → pytest_orm_boundaries-0.3.0}/PKG-INFO +56 -5
- pytest_orm_boundaries-0.3.0/README.md +95 -0
- {pytest_orm_boundaries-0.1.0 → pytest_orm_boundaries-0.3.0}/pyproject.toml +5 -2
- pytest_orm_boundaries-0.3.0/src/pytest_orm_boundaries/build_report.py +99 -0
- pytest_orm_boundaries-0.3.0/src/pytest_orm_boundaries/call_stack.py +38 -0
- pytest_orm_boundaries-0.3.0/src/pytest_orm_boundaries/guard.py +185 -0
- pytest_orm_boundaries-0.3.0/src/pytest_orm_boundaries/ignores.py +35 -0
- pytest_orm_boundaries-0.3.0/src/pytest_orm_boundaries/plugin.py +122 -0
- pytest_orm_boundaries-0.3.0/src/pytest_orm_boundaries/read_config.py +91 -0
- pytest_orm_boundaries-0.1.0/README.md +0 -44
- pytest_orm_boundaries-0.1.0/src/pytest_orm_boundaries/check_boundaries.py +0 -128
- pytest_orm_boundaries-0.1.0/src/pytest_orm_boundaries/plugin.py +0 -97
- {pytest_orm_boundaries-0.1.0 → pytest_orm_boundaries-0.3.0}/LICENSE +0 -0
- {pytest_orm_boundaries-0.1.0 → pytest_orm_boundaries-0.3.0}/src/pytest_orm_boundaries/__init__.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-orm-boundaries
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Pytest plugin that fails
|
|
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
|
-
|
|
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
|
-
#
|
|
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,
|
|
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.
|
|
8
|
-
description = "Pytest plugin that fails
|
|
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}"
|
|
File without changes
|
{pytest_orm_boundaries-0.1.0 → pytest_orm_boundaries-0.3.0}/src/pytest_orm_boundaries/__init__.py
RENAMED
|
File without changes
|