pytest-orm-boundaries 0.5.0__tar.gz → 0.6.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.
Files changed (16) hide show
  1. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.6.0}/PKG-INFO +29 -14
  2. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.6.0}/README.md +28 -13
  3. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.6.0}/pyproject.toml +1 -1
  4. pytest_orm_boundaries-0.6.0/src/pytest_orm_boundaries/allows.py +26 -0
  5. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/config.py +14 -10
  6. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/crossings.py +18 -10
  7. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/guard.py +3 -0
  8. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/plugin.py +5 -2
  9. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.6.0}/LICENSE +0 -0
  10. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/__init__.py +0 -0
  11. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/callstack.py +0 -0
  12. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/ignores.py +0 -0
  13. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/model_resolution.py +0 -0
  14. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/prefetch_resolution.py +0 -0
  15. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/report.py +0 -0
  16. {pytest_orm_boundaries-0.5.0 → pytest_orm_boundaries-0.6.0}/src/pytest_orm_boundaries/sql_parsing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-orm-boundaries
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Pytest plugin that fails tests when ORM queries cross DDD aggregate boundaries (Django supported today).
5
5
  Keywords: django,pytest,plugin,orm,ddd,aggregate,boundaries,architecture
6
6
  Author: Evgeniia Chibisova
@@ -29,11 +29,11 @@ Description-Content-Type: text/markdown
29
29
 
30
30
  # pytest-orm-boundaries
31
31
 
32
- > 💡 **Even if you control your imports boundaries still can leak through the ORM**
32
+ 💡 **Even if you control your imports, boundaries can still leak through the ORM.**
33
33
 
34
- A `pytest-orm-boundaries` is a pytest plugin that reports ORM queries crossing your DDD aggregate boundaries.
34
+ `pytest-orm-boundaries` is a pytest plugin that reports ORM queries crossing your DDD aggregate boundaries.
35
35
 
36
- Currently works with Django ORM.
36
+ Currently works with Django ORM, SQLAlchemy is in roadmap.
37
37
 
38
38
  In domain-driven design, an aggregate is a consistency boundary: code in one
39
39
  aggregate should not reach into the internals of another. Django's `__` relation
@@ -44,8 +44,8 @@ lookups make it easy to cross those boundaries silently:
44
44
  Purchase.objects.get(client__name="John")
45
45
  ```
46
46
 
47
- `pytest-orm-boundaries` watches the queries your test suite executes and reports
48
- the ones that step outside their aggregate through `__` lookups,
47
+ `pytest-orm-boundaries` watches the ORM activity exercised by your test suite
48
+ and reports access that crosses a configured boundary - through `__` lookups,
49
49
  `select_related`, `prefetch_related`, subqueries, or hand-written `.raw()` SQL.
50
50
 
51
51
  ## Install
@@ -75,8 +75,9 @@ not checked. Without a config file the plugin emits a warning and runs no checks
75
75
 
76
76
  ## What it catches
77
77
 
78
- The plugin flags queries that read across an aggregate boundary. Each example below couples
79
- the `purchase` and `client` aggregates:
78
+ The plugin flags executed ORM access that spans more than one configured group.
79
+ In the DDD example below, each crossing couples the `purchase` and `client`
80
+ aggregates:
80
81
 
81
82
  - `__` relation lookups:
82
83
 
@@ -114,7 +115,7 @@ the `purchase` and `client` aggregates:
114
115
  Purchase.objects.prefetch_related("client")
115
116
  ```
116
117
 
117
- Queries that don't actually join across the boundary are **not** flagged for
118
+ Queries that don't actually join across the boundary are **not** flagged - for
118
119
  example a foreign-key lookup by id, which Django resolves without a join:
119
120
 
120
121
  ```python
@@ -143,11 +144,22 @@ Each entry names the aggregates the query crossed and the models it joined.
143
144
  Places are ordered by how many tests they affect. Pass `-v` to see every affected test
144
145
  (otherwise the list is capped at 5 per place).
145
146
 
146
- ## Ignoring files
147
+ ## Allow and ignore
147
148
 
148
- Add exceptions so that known offenders keep passing while you fix them one file at a time:
149
+ CQRS read models may cross boundaries intentionally, also existing application code may contain crossings you want to fix over time. [allow] and [ignore] let you tell the plugin which is which:
150
+
151
+ - `[allow]` - the crossing is **intentional**. Use it for code that is meant to
152
+ span aggregates, such as CQRS read models or cross-aggregate reports. An
153
+ allowed crossing is suppressed and never reported.
154
+ - `[ignore]` - the crossing is **known debt** you plan to fix. It is suppressed
155
+ for now, and the plugin reminds you when an entry is no longer needed.
149
156
 
150
157
  ```toml
158
+ [allow]
159
+ files = [
160
+ "app/reports/sales_summary.py",
161
+ ]
162
+
151
163
  [ignore]
152
164
  files = [
153
165
  "app/billing.py",
@@ -155,14 +167,14 @@ files = [
155
167
  ]
156
168
  ```
157
169
 
158
- Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html),
170
+ Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html)),
159
171
  resolved relative to pytest's root directory and matched against either:
160
172
 
161
173
  - the file that issues the query, or
162
174
  - the test file.
163
175
 
164
- If an ignored file runs queries through the whole suite without ever crossing a
165
- boundary, the plugin says so at the end:
176
+ An `[ignore]` whose file runs through the whole suite without ever crossing a
177
+ boundary is stale — it is clean now, so the plugin lists it for removal:
166
178
 
167
179
  ```
168
180
  ======================= orm-boundaries: stale ignores ========================
@@ -171,6 +183,9 @@ Remove them from boundaries.toml:
171
183
  - app/billing.py
172
184
  ```
173
185
 
186
+ `[allow]` entries are never reported this way. If a file sits in both sections,
187
+ the allow wins and its `[ignore]` entry shows up as stale to remove.
188
+
174
189
  ## A note on Django internals
175
190
 
176
191
  Catching `prefetch_related` relies on Django internals that come with no stability
@@ -1,10 +1,10 @@
1
1
  # pytest-orm-boundaries
2
2
 
3
- > 💡 **Even if you control your imports boundaries still can leak through the ORM**
3
+ 💡 **Even if you control your imports, boundaries can still leak through the ORM.**
4
4
 
5
- A `pytest-orm-boundaries` is a pytest plugin that reports ORM queries crossing your DDD aggregate boundaries.
5
+ `pytest-orm-boundaries` is a pytest plugin that reports ORM queries crossing your DDD aggregate boundaries.
6
6
 
7
- Currently works with Django ORM.
7
+ Currently works with Django ORM, SQLAlchemy is in roadmap.
8
8
 
9
9
  In domain-driven design, an aggregate is a consistency boundary: code in one
10
10
  aggregate should not reach into the internals of another. Django's `__` relation
@@ -15,8 +15,8 @@ lookups make it easy to cross those boundaries silently:
15
15
  Purchase.objects.get(client__name="John")
16
16
  ```
17
17
 
18
- `pytest-orm-boundaries` watches the queries your test suite executes and reports
19
- the ones that step outside their aggregate through `__` lookups,
18
+ `pytest-orm-boundaries` watches the ORM activity exercised by your test suite
19
+ and reports access that crosses a configured boundary - through `__` lookups,
20
20
  `select_related`, `prefetch_related`, subqueries, or hand-written `.raw()` SQL.
21
21
 
22
22
  ## Install
@@ -46,8 +46,9 @@ not checked. Without a config file the plugin emits a warning and runs no checks
46
46
 
47
47
  ## What it catches
48
48
 
49
- The plugin flags queries that read across an aggregate boundary. Each example below couples
50
- the `purchase` and `client` aggregates:
49
+ The plugin flags executed ORM access that spans more than one configured group.
50
+ In the DDD example below, each crossing couples the `purchase` and `client`
51
+ aggregates:
51
52
 
52
53
  - `__` relation lookups:
53
54
 
@@ -85,7 +86,7 @@ the `purchase` and `client` aggregates:
85
86
  Purchase.objects.prefetch_related("client")
86
87
  ```
87
88
 
88
- Queries that don't actually join across the boundary are **not** flagged for
89
+ Queries that don't actually join across the boundary are **not** flagged - for
89
90
  example a foreign-key lookup by id, which Django resolves without a join:
90
91
 
91
92
  ```python
@@ -114,11 +115,22 @@ Each entry names the aggregates the query crossed and the models it joined.
114
115
  Places are ordered by how many tests they affect. Pass `-v` to see every affected test
115
116
  (otherwise the list is capped at 5 per place).
116
117
 
117
- ## Ignoring files
118
+ ## Allow and ignore
118
119
 
119
- Add exceptions so that known offenders keep passing while you fix them one file at a time:
120
+ CQRS read models may cross boundaries intentionally, also existing application code may contain crossings you want to fix over time. [allow] and [ignore] let you tell the plugin which is which:
121
+
122
+ - `[allow]` - the crossing is **intentional**. Use it for code that is meant to
123
+ span aggregates, such as CQRS read models or cross-aggregate reports. An
124
+ allowed crossing is suppressed and never reported.
125
+ - `[ignore]` - the crossing is **known debt** you plan to fix. It is suppressed
126
+ for now, and the plugin reminds you when an entry is no longer needed.
120
127
 
121
128
  ```toml
129
+ [allow]
130
+ files = [
131
+ "app/reports/sales_summary.py",
132
+ ]
133
+
122
134
  [ignore]
123
135
  files = [
124
136
  "app/billing.py",
@@ -126,14 +138,14 @@ files = [
126
138
  ]
127
139
  ```
128
140
 
129
- Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html),
141
+ Each entry is a glob ([`fnmatch`](https://docs.python.org/3/library/fnmatch.html)),
130
142
  resolved relative to pytest's root directory and matched against either:
131
143
 
132
144
  - the file that issues the query, or
133
145
  - the test file.
134
146
 
135
- If an ignored file runs queries through the whole suite without ever crossing a
136
- boundary, the plugin says so at the end:
147
+ An `[ignore]` whose file runs through the whole suite without ever crossing a
148
+ boundary is stale — it is clean now, so the plugin lists it for removal:
137
149
 
138
150
  ```
139
151
  ======================= orm-boundaries: stale ignores ========================
@@ -142,6 +154,9 @@ Remove them from boundaries.toml:
142
154
  - app/billing.py
143
155
  ```
144
156
 
157
+ `[allow]` entries are never reported this way. If a file sits in both sections,
158
+ the allow wins and its `[ignore]` entry shows up as stale to remove.
159
+
145
160
  ## A note on Django internals
146
161
 
147
162
  Catching `prefetch_related` relies on Django internals that come with no stability
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "pytest-orm-boundaries"
7
- version = "0.5.0"
7
+ version = "0.6.0"
8
8
  description = "Pytest plugin that fails tests when ORM queries cross DDD aggregate boundaries (Django supported today)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,26 @@
1
+ """Per-file allows: files whose boundary crossings are intentional.
2
+
3
+ Unlike ``[ignore]`` (known debt, tracked for staleness), an ``[allow]`` marks
4
+ architecture that is meant to span aggregates - CQRS read models, cross-aggregate
5
+ reports - so a matching crossing is suppressed and never surfaces as stale.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterable
11
+ from fnmatch import fnmatch
12
+
13
+
14
+ class AllowList:
15
+ """Decides whether a crossing's call place is intentional and so allowed.
16
+ """
17
+
18
+ def __init__(self, *, patterns: Iterable[str]) -> None:
19
+ self._patterns = list(dict.fromkeys(patterns)) # ordered, de-duped
20
+
21
+ @property
22
+ def is_active(self) -> bool:
23
+ return bool(self._patterns)
24
+
25
+ def has_allow_for(self, *, file_paths: Iterable[str]) -> bool:
26
+ return any(fnmatch(f, p) for f in file_paths for p in self._patterns)
@@ -19,7 +19,8 @@ class BoundariesConfig:
19
19
  """Parsed and validated content of a ``boundaries.toml``."""
20
20
 
21
21
  aggregates_by_model: dict[str, str] # {"app.model" lower-cased: aggregate_name}
22
- ignored_files: list[str] # glob patterns
22
+ ignored_files: list[str] # globs for known debt, tracked for staleness
23
+ allowed_files: list[str] # globs for intentional crossings, never stale
23
24
 
24
25
 
25
26
  def discover_config_path(*, explicit: str | None, rootpath: Path) -> Path | None:
@@ -42,7 +43,8 @@ def load_config(*, path: Path) -> BoundariesConfig:
42
43
  data = _read_config(path=path)
43
44
  return BoundariesConfig(
44
45
  aggregates_by_model=_parse_aggregates(data=data, path=path),
45
- ignored_files=_parse_ignored_files(data=data, path=path),
46
+ ignored_files=_parse_file_globs(data=data, path=path, section_name="ignore"),
47
+ allowed_files=_parse_file_globs(data=data, path=path, section_name="allow"),
46
48
  )
47
49
 
48
50
 
@@ -82,23 +84,25 @@ def _parse_aggregates(*, data: dict[str, Any], path: Path) -> dict[str, str]:
82
84
  return aggregates_by_model
83
85
 
84
86
 
85
- def _parse_ignored_files(*, data: dict[str, Any], path: Path) -> list[str]:
86
- """Read ``[ignore] files`` into a list of glob patterns."""
87
- ignore = data.get("ignore", {})
88
- if not isinstance(ignore, dict):
89
- raise BoundariesConfigError(f"{path}: [ignore] must be a table")
87
+ def _parse_file_globs(
88
+ *, data: dict[str, Any], path: Path, section_name: str
89
+ ) -> list[str]:
90
+ """Read ``[allow]`` and ``[ignore] files`` into a list of glob patterns."""
91
+ section_data = data.get(section_name, {})
92
+ if not isinstance(section_data, dict):
93
+ raise BoundariesConfigError(f"{path}: [{section_name}] must be a table")
90
94
 
91
- files = ignore.get("files", [])
95
+ files = section_data.get("files", [])
92
96
  if not isinstance(files, list):
93
97
  raise BoundariesConfigError(
94
- f"{path}: [ignore] files must be a list of glob patterns"
98
+ f"{path}: [{section_name}] files must be a list of glob patterns"
95
99
  )
96
100
 
97
101
  patterns: list[str] = []
98
102
  for entry in files:
99
103
  if not isinstance(entry, str):
100
104
  raise BoundariesConfigError(
101
- f"{path}: [ignore] files has a non-string entry: {entry!r}"
105
+ f"{path}: [{section_name}] files has a non-string entry: {entry!r}"
102
106
  )
103
107
  patterns.append(entry)
104
108
  return patterns
@@ -11,6 +11,7 @@ from pytest_orm_boundaries.callstack import find_frames_inside_project
11
11
  if TYPE_CHECKING:
12
12
  from collections.abc import Iterable
13
13
 
14
+ from pytest_orm_boundaries.allows import AllowList
14
15
  from pytest_orm_boundaries.ignores import IgnoreTracker
15
16
 
16
17
 
@@ -27,16 +28,19 @@ class CrossingRecord:
27
28
 
28
29
  class CrossingTracker:
29
30
  """Checks label sets against the aggregate rule and records the crossings,
30
- skipping ignored files and naming each crossing's call place and test."""
31
+ skipping allowed and ignored files and naming each crossing's call place and
32
+ test."""
31
33
 
32
34
  def __init__(
33
35
  self,
34
36
  *,
35
37
  aggregates_config: dict[str, str],
38
+ allow_list: AllowList,
36
39
  ignore_tracker: IgnoreTracker,
37
40
  root: Path,
38
41
  ) -> None:
39
42
  self._aggregates_config = aggregates_config
43
+ self._allow_list = allow_list
40
44
  self._ignore_tracker = ignore_tracker
41
45
  self._root_path = Path(root)
42
46
  self._current_test: str | None = None
@@ -48,13 +52,16 @@ class CrossingTracker:
48
52
 
49
53
  def check(self, *, label_sets: Iterable[Iterable[str]]) -> None:
50
54
  """Record a crossing for each label set that spans aggregates, unless an
51
- active ignore covers the call place.
55
+ allow or an ignore covers the call place.
56
+
57
+ Allow wins over ignore: an allowed crossing is suppressed without marking
58
+ the ignore used, so an ignore that only overlaps an allow surfaces as
59
+ stale and can be removed as redundant.
52
60
  """
53
- # ``frames`` is the in-project part of the call stack, gathered lazily:
54
- # for the ignore/stale bookkeeping and to name each crossing's place.
61
+ # ``frames`` is the in-project part of the call stack
55
62
  frames: list[tuple[str, int]] | None = None
56
63
  file_paths: set[str] | None = None
57
- if self._ignore_tracker.is_active:
64
+ if self._allow_list.is_active or self._ignore_tracker.is_active:
58
65
  frames = find_frames_inside_project(root=self._root_path)
59
66
  file_paths = {path for path, _ in frames}
60
67
  self._ignore_tracker.mark_seen(file_paths=file_paths)
@@ -63,11 +70,12 @@ class CrossingTracker:
63
70
  crossing = self.find_crossing(labels=labels)
64
71
  if crossing is None:
65
72
  continue
66
- if file_paths is not None and self._ignore_tracker.has_ignore_for(
67
- file_paths=file_paths
68
- ):
69
- self._ignore_tracker.mark_used(file_paths=file_paths)
70
- continue
73
+ if file_paths is not None:
74
+ if self._allow_list.has_allow_for(file_paths=file_paths):
75
+ continue
76
+ if self._ignore_tracker.has_ignore_for(file_paths=file_paths):
77
+ self._ignore_tracker.mark_used(file_paths=file_paths)
78
+ continue
71
79
  if frames is None:
72
80
  frames = find_frames_inside_project(root=self._root_path)
73
81
  call_place = frames[0] if frames else None
@@ -20,6 +20,7 @@ from pytest_orm_boundaries.sql_parsing import extract_table_names, looks_like_da
20
20
  if TYPE_CHECKING:
21
21
  from django.db.backends.base.base import BaseDatabaseWrapper
22
22
 
23
+ from pytest_orm_boundaries.allows import AllowList
23
24
  from pytest_orm_boundaries.crossings import CrossingRecord
24
25
  from pytest_orm_boundaries.ignores import IgnoreTracker
25
26
 
@@ -45,11 +46,13 @@ class BoundaryGuard:
45
46
  self,
46
47
  *,
47
48
  aggregates_config: dict[str, str],
49
+ allow_list: AllowList,
48
50
  ignore_tracker: IgnoreTracker,
49
51
  root: Path,
50
52
  ) -> None:
51
53
  self._tracker = CrossingTracker(
52
54
  aggregates_config=aggregates_config,
55
+ allow_list=allow_list,
53
56
  ignore_tracker=ignore_tracker,
54
57
  root=root,
55
58
  )
@@ -14,6 +14,7 @@ from pytest_orm_boundaries.config import (
14
14
  discover_config_path,
15
15
  load_config,
16
16
  )
17
+ from pytest_orm_boundaries.allows import AllowList
17
18
  from pytest_orm_boundaries.guard import BoundaryGuard
18
19
  from pytest_orm_boundaries.ignores import IgnoreTracker
19
20
 
@@ -56,10 +57,12 @@ def _build_guard(*, rootpath: Path, config_path: Path) -> BoundaryGuard | None:
56
57
  if not config.aggregates_by_model:
57
58
  return None
58
59
 
59
- tracker = IgnoreTracker(patterns=config.ignored_files)
60
+ allow_list = AllowList(patterns=config.allowed_files)
61
+ ignore_tracker = IgnoreTracker(patterns=config.ignored_files)
60
62
  return BoundaryGuard(
61
63
  aggregates_config=config.aggregates_by_model,
62
- ignore_tracker=tracker,
64
+ allow_list=allow_list,
65
+ ignore_tracker=ignore_tracker,
63
66
  root=rootpath,
64
67
  )
65
68