pytest-orm-boundaries 0.1.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/LICENSE +21 -0
- pytest_orm_boundaries-0.1.0/PKG-INFO +70 -0
- pytest_orm_boundaries-0.1.0/README.md +44 -0
- pytest_orm_boundaries-0.1.0/pyproject.toml +48 -0
- pytest_orm_boundaries-0.1.0/src/pytest_orm_boundaries/__init__.py +0 -0
- pytest_orm_boundaries-0.1.0/src/pytest_orm_boundaries/check_boundaries.py +128 -0
- pytest_orm_boundaries-0.1.0/src/pytest_orm_boundaries/plugin.py +97 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Evgeniia Chibisova
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
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).
|
|
5
|
+
Keywords: django,pytest,plugin,orm,ddd,aggregate,boundaries,architecture
|
|
6
|
+
Author: Evgeniia Chibisova
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: Django
|
|
11
|
+
Classifier: Framework :: Pytest
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
18
|
+
Classifier: Topic :: Software Development :: Testing
|
|
19
|
+
Requires-Dist: pytest>=8
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Project-URL: Homepage, https://github.com/evchibisova/pytest-orm-boundaries
|
|
22
|
+
Project-URL: Repository, https://github.com/evchibisova/pytest-orm-boundaries
|
|
23
|
+
Project-URL: Issues, https://github.com/evchibisova/pytest-orm-boundaries/issues
|
|
24
|
+
Project-URL: Changelog, https://github.com/evchibisova/pytest-orm-boundaries/blob/main/CHANGELOG.md
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# pytest-orm-boundaries
|
|
28
|
+
|
|
29
|
+
A pytest plugin that fails your tests when ORM queries cross your DDD aggregate boundaries.
|
|
30
|
+
|
|
31
|
+
Currently works with Django ORM.
|
|
32
|
+
|
|
33
|
+
In domain-driven design, an aggregate is a consistency boundary: code in one
|
|
34
|
+
aggregate should not reach into the internals of another. Django's `__` relation
|
|
35
|
+
lookups make it easy to cross those boundaries silently:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
# Payment and Order belong to different aggregates — this query couples them.
|
|
39
|
+
Purchase.objects.get(client__name="John")
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`pytest-orm-boundaries` watches the queries your test suite executes and
|
|
43
|
+
reports the ones that step outside their aggregate, including `__`, subqueries and other.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install pytest-orm-boundaries
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
pytest picks the plugin up automatically.
|
|
52
|
+
|
|
53
|
+
## Configure
|
|
54
|
+
|
|
55
|
+
Declare your aggregates in `boundaries.toml` at the project root (or point at
|
|
56
|
+
the file with `--boundaries-config` / the `boundaries_config` ini option):
|
|
57
|
+
|
|
58
|
+
```toml
|
|
59
|
+
[aggregates]
|
|
60
|
+
client = ["bookshop.Client"]
|
|
61
|
+
book = ["bookshop.Book"]
|
|
62
|
+
purchase = ["bookshop.Purchase", "bookshop.PurchaseLine"]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Models are written as `app_label.Model`. Models not listed in any aggregate are
|
|
66
|
+
not checked. Without a config file the plugin emits a warning and runs no checks.
|
|
67
|
+
|
|
68
|
+
## Status
|
|
69
|
+
|
|
70
|
+
Alpha - testing basic version.
|
|
@@ -0,0 +1,44 @@
|
|
|
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.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.11.19,<0.12"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
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)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Evgeniia Chibisova" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"django",
|
|
16
|
+
"pytest",
|
|
17
|
+
"plugin",
|
|
18
|
+
"orm",
|
|
19
|
+
"ddd",
|
|
20
|
+
"aggregate",
|
|
21
|
+
"boundaries",
|
|
22
|
+
"architecture",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 3 - Alpha",
|
|
26
|
+
"Framework :: Django",
|
|
27
|
+
"Framework :: Pytest",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Programming Language :: Python :: 3.13",
|
|
33
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
34
|
+
"Topic :: Software Development :: Testing",
|
|
35
|
+
]
|
|
36
|
+
dependencies = ["pytest>=8"]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/evchibisova/pytest-orm-boundaries"
|
|
40
|
+
Repository = "https://github.com/evchibisova/pytest-orm-boundaries"
|
|
41
|
+
Issues = "https://github.com/evchibisova/pytest-orm-boundaries/issues"
|
|
42
|
+
Changelog = "https://github.com/evchibisova/pytest-orm-boundaries/blob/main/CHANGELOG.md"
|
|
43
|
+
|
|
44
|
+
[project.entry-points.pytest11]
|
|
45
|
+
boundaries = "pytest_orm_boundaries.plugin"
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
testpaths = ["tests"]
|
|
File without changes
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
|
@@ -0,0 +1,97 @@
|
|
|
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}"
|