Skip to content

Commit 1c06615

Browse files
committed
feat: refactor for code clarity
1 parent 95d4b19 commit 1c06615

9 files changed

Lines changed: 309 additions & 223 deletions

File tree

Makefile

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
.PHONY: help install test lint format typecheck check clean build
2+
3+
help: ## Show this help
4+
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
5+
6+
install: ## Install in editable mode with dev dependencies
7+
pip install -e ".[dev]"
8+
9+
test: ## Run tests
10+
pytest tests/ -v
11+
12+
lint: ## Run ruff linter
13+
ruff check --no-fix src tests
14+
15+
format: ## Run ruff formatter (fix in place)
16+
ruff format src tests
17+
ruff check --fix src tests
18+
19+
format-check: ## Check formatting without changes
20+
ruff format --check src tests
21+
ruff check --no-fix src tests
22+
23+
typecheck: ## Run mypy and pyright
24+
mypy src
25+
pyright src
26+
27+
check: lint format-check typecheck test ## Run all checks (lint, format, types, tests)
28+
29+
clean: ## Remove build artifacts
30+
rm -rf dist build src/*.egg-info .pytest_cache .mypy_cache .ruff_cache htmlcov .coverage
31+
find . -type d -name __pycache__ -exec rm -rf {} +
32+
33+
build: clean ## Build source and wheel distributions
34+
python -m build

README.md

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -68,42 +68,65 @@ def test_string_path():
6868
- `patch.object()` targeting the protected function
6969
- Other mocking is allowed
7070

71-
## Testing
71+
## Development
7272

7373
```bash
74-
tox
74+
git clone git@github.com:LifeLex/pytest-do-not-mock.git
75+
cd pytest-do-not-mock
76+
python3 -m venv .venv
77+
source .venv/bin/activate
78+
make install
79+
```
80+
81+
### Available commands
82+
83+
```
84+
make help Show all commands
85+
make install Install in editable mode with dev dependencies
86+
make test Run tests
87+
make lint Run ruff linter
88+
make format Run ruff formatter
89+
make typecheck Run mypy and pyright
90+
make check Run all checks (lint, format, types, tests)
91+
make clean Remove build artifacts
92+
make build Build source and wheel distributions
7593
```
7694

77-
Runs tests across Python 3.10–3.13, plus ruff linting and type checking (mypy + pyright).
95+
### Running checks
7896

7997
```bash
80-
tox -e py313 # single Python version
81-
tox -e linting # ruff check + format
82-
tox -e typing # mypy + pyright
83-
pytest tests/ -v # run tests directly
98+
make check # everything: lint + format + typecheck + tests
99+
make lint # ruff linter only
100+
make typecheck # mypy + pyright
101+
make test # pytest only
84102
```
85103

86-
## Development
104+
Or via tox for multi-version testing:
87105

88106
```bash
89-
git clone git@github.com:LifeLex/pytest-do-not-mock.git
90-
cd pytest-do-not-mock
91-
python3 -m venv .venv
92-
source .venv/bin/activate
93-
pip install -e ".[dev]"
107+
tox # all environments (py310–py313, linting, typing)
108+
tox -e py313 # single Python version
94109
```
95110

96111
### Project structure
97112

98113
```
99114
src/pytest_do_not_mock/
100115
├── __init__.py # Public API: do_not_mock, DoNotMockError
101-
└── plugin.py # Decorator + pytest hooks
116+
├── plugin.py # Pytest hooks (entry point)
117+
├── decorator.py # @pytest.do_not_mock decorator
118+
├── guards.py # Mock interception and guard context manager
119+
└── protected.py # ProtectedFunc resolution and validation
102120
103121
tests/
104122
└── test_do_not_mock.py
105123
```
106124

125+
### Releasing
126+
127+
1. Tag the commit: `git tag v0.1.0 && git push origin v0.1.0`
128+
2. GitHub Actions builds and publishes to PyPI automatically via trusted publishing
129+
107130
## License
108131

109132
MIT

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ exclude_lines = [
8282
line-length = 120
8383
target-version = "py310"
8484
fix = true
85+
exclude = ["src/pytest_do_not_mock/_version.py"]
8586

8687
[tool.ruff.lint]
8788
select = ["E4", "E7", "E9", "F", "B", "I"]

src/pytest_do_not_mock/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
except ImportError:
66
__version__ = "unknown"
77

8-
from .plugin import DoNotMockError, do_not_mock
8+
from .decorator import do_not_mock
9+
from .errors import DoNotMockError
910

1011

1112
__all__ = ["do_not_mock", "DoNotMockError", "__version__"]
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from __future__ import annotations
2+
3+
import functools
4+
import unittest.mock
5+
from collections.abc import Callable
6+
from typing import Any, TypeVar
7+
8+
from .errors import DoNotMockError
9+
from .guards import mock_guard
10+
from .protected import ProtectedFunc, validate_no_mocks
11+
12+
13+
F = TypeVar("F", bound=Callable[..., Any])
14+
15+
16+
def do_not_mock(*funcs: Any) -> Any:
17+
"""Test decorator that prevents mocking.
18+
19+
Usage::
20+
21+
@pytest.do_not_mock # blocks ALL mocking
22+
@pytest.do_not_mock() # blocks ALL mocking
23+
@pytest.do_not_mock(func1, func2) # blocks only these functions
24+
@pytest.do_not_mock("myapp.module.func") # string paths supported
25+
"""
26+
# @pytest.do_not_mock without parentheses — single callable whose name starts with test_
27+
if len(funcs) == 1 and callable(funcs[0]) and not isinstance(funcs[0], str):
28+
func = funcs[0]
29+
if getattr(func, "__name__", "").startswith("test_"):
30+
return _wrap_block_all(func)
31+
32+
def decorator(test_func: F) -> F:
33+
if not funcs:
34+
return _wrap_block_all(test_func)
35+
return _wrap_targeted(test_func, list(funcs))
36+
37+
return decorator
38+
39+
40+
def _wrap_block_all(test_func: F) -> F:
41+
@functools.wraps(test_func)
42+
def wrapper(*args: Any, **kwargs: Any) -> Any:
43+
for arg in args:
44+
if isinstance(arg, unittest.mock.NonCallableMock):
45+
raise DoNotMockError(
46+
f"\nPatching is not allowed in test '{test_func.__name__}' (decorated with @pytest.do_not_mock).\n"
47+
)
48+
with mock_guard(test_func.__name__, block_all=True):
49+
return test_func(*args, **kwargs)
50+
51+
wrapper._pytest_do_not_mock = True # type: ignore[attr-defined]
52+
return wrapper # type: ignore[return-value]
53+
54+
55+
def _wrap_targeted(test_func: F, funcs: list[Any]) -> F:
56+
protected = [ProtectedFunc.from_arg(f) for f in funcs]
57+
58+
@functools.wraps(test_func)
59+
def wrapper(*args: Any, **kwargs: Any) -> Any:
60+
validate_no_mocks(protected, test_func.__name__, "before")
61+
with mock_guard(test_func.__name__, protected=protected):
62+
result = test_func(*args, **kwargs)
63+
validate_no_mocks(protected, test_func.__name__, "after")
64+
return result
65+
66+
wrapper._pytest_do_not_mock = True # type: ignore[attr-defined]
67+
wrapper._pytest_do_not_mock_funcs = funcs # type: ignore[attr-defined]
68+
return wrapper # type: ignore[return-value]

src/pytest_do_not_mock/errors.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Exceptions for pytest-do-not-mock."""
2+
3+
4+
class DoNotMockError(Exception):
5+
"""Raised when mocking is attempted in a protected test."""

src/pytest_do_not_mock/guards.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
from __future__ import annotations
2+
3+
import contextlib
4+
import unittest.mock
5+
from collections.abc import Iterator
6+
from dataclasses import dataclass, field
7+
from typing import Any
8+
9+
from .errors import DoNotMockError
10+
from .protected import ProtectedFunc
11+
12+
13+
_original_mock_init = unittest.mock.NonCallableMock.__init__
14+
_original_patch_enter = unittest.mock._patch.__enter__ # pyright: ignore[reportPrivateUsage,reportUnknownVariableType,reportUnknownMemberType]
15+
_original_patch_dict_enter = unittest.mock._patch_dict.__enter__ # pyright: ignore[reportPrivateUsage]
16+
17+
18+
@dataclass
19+
class _ActiveGuard:
20+
test_name: str
21+
block_all: bool = False
22+
protected: list[ProtectedFunc] = field(default_factory=lambda: [])
23+
24+
25+
_active_guard: _ActiveGuard | None = None
26+
27+
28+
def _guarded_mock_init(self: Any, *args: Any, **kwargs: Any) -> None:
29+
guard = _active_guard
30+
if guard is not None and guard.block_all:
31+
raise DoNotMockError(
32+
f"\nMocking is not allowed in test '{guard.test_name}' (decorated with @pytest.do_not_mock).\n"
33+
)
34+
_original_mock_init(self, *args, **kwargs)
35+
36+
37+
def _guarded_patch_enter(self: Any) -> Any:
38+
guard = _active_guard
39+
if guard is None:
40+
return _original_patch_enter(self) # pyright: ignore[reportUnknownVariableType]
41+
42+
if guard.block_all:
43+
raise DoNotMockError(
44+
f"\nPatching is not allowed in test '{guard.test_name}' (decorated with @pytest.do_not_mock).\n"
45+
)
46+
47+
# Targeted mode — only block patches that hit a protected function
48+
try:
49+
target = self.getter()
50+
except Exception:
51+
return _original_patch_enter(self) # pyright: ignore[reportUnknownVariableType]
52+
53+
for pf in guard.protected:
54+
if pf.matches_patch_target(target, self.attribute):
55+
raise DoNotMockError(
56+
f"\nTest '{guard.test_name}' marked '{pf.name}' with @pytest.do_not_mock\n"
57+
f"but it is being patched.\n"
58+
f"\nPlease remove the patch for '{pf.module_path}'.\n"
59+
)
60+
return _original_patch_enter(self) # pyright: ignore[reportUnknownVariableType]
61+
62+
63+
def _guarded_patch_dict_enter(self: Any) -> Any:
64+
guard = _active_guard
65+
if guard is not None and guard.block_all:
66+
raise DoNotMockError(
67+
f"\nPatching is not allowed in test '{guard.test_name}' (decorated with @pytest.do_not_mock).\n"
68+
)
69+
return _original_patch_dict_enter(self)
70+
71+
72+
@contextlib.contextmanager
73+
def mock_guard(
74+
test_name: str,
75+
*,
76+
block_all: bool = False,
77+
protected: list[ProtectedFunc] | None = None,
78+
) -> Iterator[None]:
79+
"""Install mock guards for the duration of a ``with`` block, then restore originals."""
80+
global _active_guard # noqa: PLW0603
81+
82+
_active_guard = _ActiveGuard(test_name=test_name, block_all=block_all, protected=protected or [])
83+
84+
unittest.mock.NonCallableMock.__init__ = _guarded_mock_init # type: ignore[method-assign]
85+
unittest.mock._patch.__enter__ = _guarded_patch_enter # type: ignore[method-assign]
86+
unittest.mock._patch_dict.__enter__ = _guarded_patch_dict_enter # type: ignore[method-assign]
87+
try:
88+
yield
89+
finally:
90+
unittest.mock.NonCallableMock.__init__ = _original_mock_init # type: ignore[method-assign]
91+
unittest.mock._patch.__enter__ = _original_patch_enter # type: ignore[method-assign]
92+
unittest.mock._patch_dict.__enter__ = _original_patch_dict_enter # type: ignore[method-assign]
93+
_active_guard = None

0 commit comments

Comments
 (0)