pytest-orm-boundaries 0.1.0__tar.gz → 0.2.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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-orm-boundaries
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Pytest plugin that fails your 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
@@ -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 fails your tests when ORM queries cross 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,34 @@ 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
+ ## Ignoring files
72
+
73
+ Add exceptions so that known offenders keep passing while you fix them one file at a time:
74
+
75
+ ```toml
76
+ [ignore]
77
+ files = [
78
+ "app/billing.py",
79
+ "app/legacy/*",
80
+ ]
81
+ ```
82
+
83
+ Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html),
84
+ resolved relative to pytest's root directory and matched against either:
85
+
86
+ - the file that issues the query, or
87
+ - the test file.
88
+
89
+ If an ignored file runs queries through the whole suite without ever crossing a
90
+ boundary, the plugin says so at the end:
91
+
92
+ ```
93
+ ======================= orm-boundaries: stale ignores ========================
94
+ These [ignore] entries no longer suppress any boundary violation - their files are clean now.
95
+ Remove them from boundaries.toml:
96
+ - app/billing.py
97
+ ```
98
+
68
99
  ## Status
69
100
 
70
101
  Alpha - testing basic version.
@@ -0,0 +1,75 @@
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 fails your tests when ORM queries cross 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
+ ## Ignoring files
46
+
47
+ Add exceptions so that known offenders keep passing while you fix them one file at a time:
48
+
49
+ ```toml
50
+ [ignore]
51
+ files = [
52
+ "app/billing.py",
53
+ "app/legacy/*",
54
+ ]
55
+ ```
56
+
57
+ Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html),
58
+ resolved relative to pytest's root directory and matched against either:
59
+
60
+ - the file that issues the query, or
61
+ - the test file.
62
+
63
+ If an ignored file runs queries through the whole suite without ever crossing a
64
+ boundary, the plugin says so at the end:
65
+
66
+ ```
67
+ ======================= orm-boundaries: stale ignores ========================
68
+ These [ignore] entries no longer suppress any boundary violation - their files are clean now.
69
+ Remove them from boundaries.toml:
70
+ - app/billing.py
71
+ ```
72
+
73
+ ## Status
74
+
75
+ Alpha - testing basic version.
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "pytest-orm-boundaries"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Pytest plugin that fails your tests when ORM queries cross DDD aggregate boundaries (Django supported today)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,30 @@
1
+ """Terminal output: the run header and the stale-ignore notice."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from pytest_orm_boundaries.read_config import CONFIG_FILE_NAME
10
+
11
+
12
+ def report_stale_ignores(
13
+ *, terminalreporter: pytest.TerminalReporter, stale: list[str]
14
+ ) -> None:
15
+ if not stale:
16
+ return
17
+ terminalreporter.section("orm-boundaries: stale ignores", yellow=True, bold=True)
18
+ terminalreporter.write_line(
19
+ "These [ignore] entries no longer suppress any boundary violation - "
20
+ f"their files are clean now. Remove them from {CONFIG_FILE_NAME}:",
21
+ yellow=True,
22
+ )
23
+ for pattern in stale:
24
+ terminalreporter.write_line(f" - {pattern}", yellow=True)
25
+
26
+
27
+ def build_report_header(*, config_path: Path | None) -> str:
28
+ if config_path is None:
29
+ return "orm-boundaries: no config file, checks disabled"
30
+ return f"orm-boundaries: config {config_path}"
@@ -0,0 +1,116 @@
1
+ """Django boundary guard: the aggregate-crossing rule and the query
2
+ interception that enforces it.
3
+
4
+ Patches SQLCompiler.execute_sql to check each query; violations from ignored
5
+ call sites are swallowed, and stale ignores are reported.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import functools
11
+ from collections.abc import Callable, Iterable
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from pytest_orm_boundaries.ignores import IgnoreTracker, find_source_files_in_stack
16
+ from pytest_orm_boundaries.read_config import (
17
+ load_aggregates_from_config,
18
+ load_ignored_files_from_config,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from django.db.models import Model
23
+ from django.db.models.sql.query import Query
24
+
25
+
26
+ class BoundaryViolation(Exception):
27
+ """Raised when a query joins models from more than one aggregate."""
28
+
29
+
30
+ class BoundaryGuard:
31
+ """Intercepts Django's executed queries and enforces the aggregate rule."""
32
+
33
+ def __init__(
34
+ self, *, aggregates_config: dict[str, str], ignore_tracker: IgnoreTracker
35
+ ) -> None:
36
+ self._aggregates_config = aggregates_config
37
+ self._tracker = ignore_tracker
38
+ self._original_runner: Callable[..., Any] | None = None
39
+
40
+ def install(self) -> None:
41
+ from django.db.models.sql.compiler import SQLCompiler
42
+
43
+ original_runner = SQLCompiler.execute_sql
44
+ self._original_runner = original_runner
45
+
46
+ def patched_execute_sql(compiler, *args, **kwargs):
47
+ self._check_query(query=compiler.query)
48
+ return original_runner(compiler, *args, **kwargs)
49
+
50
+ SQLCompiler.execute_sql = patched_execute_sql
51
+
52
+ def restore_original_runner(self) -> None:
53
+ from django.db.models.sql.compiler import SQLCompiler
54
+
55
+ SQLCompiler.execute_sql = self._original_runner
56
+
57
+ def find_stale_patterns(self) -> list[str]:
58
+ return self._tracker.find_stale_patterns()
59
+
60
+ def _check_query(self, *, query: Query) -> None:
61
+ """Raise BoundaryViolation unless the query is clean or ignored."""
62
+ file_paths: list[str] | None = None
63
+ if self._tracker.is_active:
64
+ file_paths = find_source_files_in_stack(root=self._tracker.root)
65
+ self._tracker.mark_seen(file_paths=file_paths)
66
+
67
+ table_to_model = _map_tables_to_models()
68
+ labels = [
69
+ table_to_model[table.table_name]._meta.label
70
+ for table in query.alias_map.values()
71
+ if table.table_name in table_to_model
72
+ ]
73
+
74
+ try:
75
+ self._check_boundaries(labels=labels)
76
+ except BoundaryViolation:
77
+ if file_paths is None or not self._tracker.has_ignore_for(
78
+ file_paths=file_paths
79
+ ):
80
+ raise
81
+ self._tracker.mark_used(file_paths=file_paths)
82
+
83
+ def _check_boundaries(self, *, labels: Iterable[str]) -> None:
84
+ aggregate_by_label = {
85
+ label: aggregate
86
+ for label in labels
87
+ if (aggregate := self._aggregates_config.get(label.lower())) is not None
88
+ }
89
+ aggregates = set(aggregate_by_label.values())
90
+ if len(aggregates) <= 1:
91
+ return
92
+
93
+ crossed = ", ".join(sorted(aggregates))
94
+ joined = ", ".join(sorted(aggregate_by_label.keys()))
95
+ raise BoundaryViolation(
96
+ f"aggregate boundaries crossed ({crossed}); joined models: {joined}"
97
+ )
98
+
99
+
100
+ def build_guard(*, rootpath: Path, config_path: Path) -> BoundaryGuard | None:
101
+ aggregates_by_model = load_aggregates_from_config(path=config_path)
102
+ if not aggregates_by_model:
103
+ return None
104
+
105
+ ignore_patterns = load_ignored_files_from_config(path=config_path)
106
+ tracker = IgnoreTracker(patterns=ignore_patterns, root=rootpath)
107
+
108
+ return BoundaryGuard(aggregates_config=aggregates_by_model, ignore_tracker=tracker)
109
+
110
+
111
+ @functools.cache
112
+ def _map_tables_to_models() -> dict[str, type[Model]]:
113
+ """Map db_table -> model class for every installed model."""
114
+ from django.apps import apps
115
+
116
+ return {model._meta.db_table: model for model in apps.get_models()}
@@ -0,0 +1,61 @@
1
+ """Per-file ignores at runtime: the tracker and call-stack inspection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import Iterable
7
+ from fnmatch import fnmatch
8
+ from pathlib import Path
9
+
10
+
11
+ class IgnoreTracker:
12
+ """Decides whether a violation is ignored, and reports ignore globs that went
13
+ stale - their file ran but never crossed a boundary."""
14
+
15
+ def __init__(self, *, patterns: Iterable[str], root: Path) -> None:
16
+ self.root = Path(root)
17
+ self._patterns = list(dict.fromkeys(patterns)) # ordered, de-duped
18
+ self._seen: set[str] = set() # patterns executed during the run
19
+ self._used: set[str] = set() # ...that also crossed a boundary
20
+
21
+ @property
22
+ def is_active(self) -> bool:
23
+ return bool(self._patterns)
24
+
25
+ def mark_seen(self, *, file_paths: Iterable[str]) -> None:
26
+ self._seen.update(self._find_matching_patterns(file_paths))
27
+
28
+ def mark_used(self, *, file_paths: Iterable[str]) -> None:
29
+ self._used.update(self._find_matching_patterns(file_paths))
30
+
31
+ def has_ignore_for(self, *, file_paths: Iterable[str]) -> bool:
32
+ return bool(self._find_matching_patterns(file_paths))
33
+
34
+ def find_stale_patterns(self) -> list[str]:
35
+ return sorted(self._seen - self._used)
36
+
37
+ def _find_matching_patterns(self, file_paths: Iterable[str]) -> set[str]:
38
+ return {p for f in file_paths for p in self._patterns if fnmatch(f, p)}
39
+
40
+
41
+ def find_source_files_in_stack(*, root: Path) -> list[str]:
42
+ """Root-relative (posix) paths of project files on the current call stack.
43
+
44
+ Frames outside ``root`` (Django, pytest, stdlib) are skipped, leaving the
45
+ application/test files that led to this query.
46
+ """
47
+ files: list[str] = []
48
+ seen: set[str] = set()
49
+ frame = inspect.currentframe()
50
+ frame = frame.f_back if frame else None # start at the caller, skip our own frame
51
+ while frame is not None:
52
+ filename = frame.f_code.co_filename
53
+ frame = frame.f_back # for next iteration
54
+ if filename in seen:
55
+ continue
56
+ seen.add(filename)
57
+ try:
58
+ files.append(Path(filename).relative_to(root).as_posix())
59
+ except ValueError:
60
+ continue
61
+ return files
@@ -0,0 +1,96 @@
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 | None"]()
23
+
24
+
25
+ class BoundariesConfigWarning(UserWarning):
26
+ """Plugin is installed but no config file was found."""
27
+
28
+
29
+ def pytest_addoption(parser: pytest.Parser) -> None:
30
+ group = parser.getgroup("orm-boundaries")
31
+ group.addoption(
32
+ "--boundaries-config",
33
+ dest="boundaries_config",
34
+ metavar="PATH",
35
+ default=None,
36
+ help=(
37
+ "Path to the boundaries TOML config. Defaults to "
38
+ f"{CONFIG_FILE_NAME} in the pytest root directory; if no "
39
+ "config file is found, boundary checks are disabled."
40
+ ),
41
+ )
42
+ parser.addini(
43
+ "boundaries_config",
44
+ help="Same as --boundaries-config.",
45
+ default="",
46
+ )
47
+
48
+
49
+ def pytest_configure(config: pytest.Config) -> None:
50
+ """Resolve the config and install the guard, stashing what other hooks need:
51
+
52
+ - ``config_path_key`` -- to show where the config came from in the report.
53
+ - ``guard_key`` -- to restore the patched query method on teardown and to
54
+ report stale ignores at the end of the run.
55
+ """
56
+ explicit_config = config.getoption("boundaries_config") or config.getini("boundaries_config")
57
+ try:
58
+ config_path = discover_config_path(
59
+ explicit=explicit_config, rootpath=config.rootpath
60
+ )
61
+ config.stash[config_path_key] = config_path
62
+ if config_path is None:
63
+ warning = BoundariesConfigWarning(f"No {CONFIG_FILE_NAME} found, no checks will run")
64
+ config.issue_config_time_warning(warning, stacklevel=2)
65
+ return
66
+
67
+ guard = build_guard(rootpath=config.rootpath, config_path=config_path)
68
+ if guard is not None:
69
+ guard.install()
70
+ config.stash[guard_key] = guard
71
+ except BoundariesConfigError as exc:
72
+ raise pytest.UsageError(f"orm-boundaries: {exc}") from exc
73
+
74
+
75
+ def pytest_unconfigure(config: pytest.Config) -> None:
76
+ guard = config.stash.get(guard_key, None)
77
+ if guard is not None:
78
+ guard.restore_original_runner()
79
+
80
+
81
+ def pytest_terminal_summary(
82
+ terminalreporter: pytest.TerminalReporter,
83
+ exitstatus: int,
84
+ config: pytest.Config,
85
+ ) -> None:
86
+ guard = config.stash.get(guard_key, None)
87
+ if guard is not None:
88
+ stale = guard.find_stale_patterns()
89
+ build_report.report_stale_ignores(
90
+ terminalreporter=terminalreporter, stale=stale
91
+ )
92
+
93
+
94
+ def pytest_report_header(config: pytest.Config) -> str:
95
+ config_path = config.stash.get(config_path_key, None)
96
+ return build_report.build_report_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}"