Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@
- Detection of `unittest.mock.patch` (decorator, context manager, start/stop)
- Detection of `patch.object`, `patch.dict`
- Detection of `Mock`, `MagicMock`, `AsyncMock`, `create_autospec`
- Targeted protection matches by object identity: patching an aliased import of a protected function (`from myapp.payments import charge as my_charge`) is blocked the same as patching its canonical path, in both string and object mode
- String targets resolve to the live object at enforcement time (never at collection); an unresolvable path raises `DoNotMockError` instead of silently protecting nothing
- Dotted class-attribute paths are supported as string targets (`"myapp.payments.PaymentGateway.charge"`)
- Public introspection API for downstream tools: `resolve_do_not_mock(item)` returns the `DoNotMockContract` (the resolved union of all stacked markers) for a collected test item; `DoNotMockContract` and `ProtectedFunc` are exported alongside it
- Python 3.10-3.13 support
72 changes: 66 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,63 @@ A bare marker at any scope (no args, no `protect=`) wins over targeted inner mar
- `patch.object()` targeting the protected function
- Other mocking is allowed

Protection follows the **function object**, not the spelling of the patch target. If `myapp/api.py` does `from myapp.payments import charge as my_charge`, then `patch("myapp.api.my_charge")` is blocked just like `patch("myapp.payments.charge")` — both names point at the same protected function. This matters because the `unittest.mock` guidance is to patch where a function is *used*, which is usually an aliased import.

String targets resolve to the live object right before the test runs (collection never imports your application code). A path that cannot be imported raises `DoNotMockError` instead of silently protecting nothing, and dotted class attributes work too: `@pytest.mark.do_not_mock("myapp.payments.PaymentGateway.charge")`.

### Ask about a test's contract (for tool and plugin authors)

Tools that want to *respect* the contract rather than enforce it — test generators, other plugins, IDE integrations — can ask for the resolved marker state of any collected item instead of reimplementing the stacking rules:

```python
from pytest_do_not_mock import resolve_do_not_mock

def pytest_collection_modifyitems(config, items):
for item in items:
contract = resolve_do_not_mock(item)
if contract is None:
... # unmarked: mocking is fine
elif contract.block_all:
... # no mocking of any kind in this test
else:
... # contract.protected: tuple of ProtectedFunc, one per target
```

`resolve_do_not_mock` returns `None` for unmarked items, or a `DoNotMockContract` with the union of every `do_not_mock` marker stacked on the item (function, class, module — a bare marker at any scope means `block_all`). It is safe to call at collection time: string targets stay unresolved dotted paths (`ProtectedFunc.module_path`) and nothing gets imported. The plugin's own enforcement hook is built on this same function.

#### Markers in, contract out

```python
# test_payments.py
import pytest

pytestmark = pytest.mark.do_not_mock("myapp.db.save") # applies to every test below

def process_payment(amount): ...

@pytest.mark.do_not_mock
def test_bare(): ...

@pytest.mark.do_not_mock("myapp.payments.charge", protect=process_payment)
def test_targeted(): ...
```

```python
resolve_do_not_mock(item_for_test_bare)
# DoNotMockContract(block_all=True, protected=())
# bare marker wins over everything, including the module-level target

resolve_do_not_mock(item_for_test_targeted)
# DoNotMockContract(block_all=False, protected=(
# ProtectedFunc(name='charge', module_path='myapp.payments.charge', obj=None),
# ProtectedFunc(name='process_payment', module_path='test_payments.process_payment', obj=<function process_payment>),
# ProtectedFunc(name='save', module_path='myapp.db.save', obj=None),
# ))
# union of the function marker and the module-level pytestmark, deduplicated
```

String targets keep `obj=None` at collection time; targets passed as objects via `protect=` carry the live function. If a consumer needs the live object for a string target (for example to compare identities the way the guard does), it can opt in with `ProtectedFunc.resolve()`, accepting the import that implies.

## Development

```bash
Expand Down Expand Up @@ -170,18 +227,21 @@ tox -e py313 # single Python version

```
src/pytest_do_not_mock/
├── __init__.py # Public API: DoNotMockError
├── __init__.py # Public API: DoNotMockError, DoNotMockContract, ProtectedFunc, resolve_do_not_mock
├── contract.py # DoNotMockContract + resolve_do_not_mock (marker introspection)
├── errors.py # DoNotMockError exception
├── plugin.py # Pytest marker + hookwrapper (entry point)
├── guards.py # Mock interception and guard context manager
└── protected.py # ProtectedFunc resolution and validation

tests/
├── conftest.py # Shared fixtures and example app code
├── test_plugin.py # Marker registration, error messages, cleanup
├── test_block_all.py # Block-all mode (every mock/patch variant)
├── test_targeted.py # Targeted mode (protect=, string paths)
└── test_scopes.py # Class-level and module-level markers
├── conftest.py # Shared fixtures and example app code
├── test_plugin.py # Marker registration, error messages, cleanup
├── test_block_all.py # Block-all mode (every mock/patch variant)
├── test_targeted.py # Targeted mode (protect=, string paths)
├── test_scopes.py # Class-level and module-level markers
├── test_alias_protection.py # Identity matching: aliased imports, class attrs, typo'd paths
└── test_resolve.py # Public resolve_do_not_mock introspection API
```

### Releasing
Expand Down
10 changes: 9 additions & 1 deletion src/pytest_do_not_mock/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@
except ImportError:
__version__ = "unknown"

from .contract import DoNotMockContract, resolve_do_not_mock
from .errors import DoNotMockError
from .protected import ProtectedFunc


__all__ = ["DoNotMockError", "__version__"]
__all__ = [
"DoNotMockContract",
"DoNotMockError",
"ProtectedFunc",
"__version__",
"resolve_do_not_mock",
]
74 changes: 74 additions & 0 deletions src/pytest_do_not_mock/contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Public introspection API for the ``do_not_mock`` marker.

Downstream consumers (test generators, other plugins) call
:func:`resolve_do_not_mock` on a collected item to answer "is this test under
a no-mocks contract?" without reimplementing the marker-stacking rules. The
plugin's own hookwrapper uses the same function, so there is a single source
of truth for what the markers mean.
"""

from __future__ import annotations

from collections.abc import Iterator
from dataclasses import dataclass
from typing import Any

import pytest

from .protected import ProtectedFunc


@dataclass(frozen=True)
class DoNotMockContract:
"""The resolved union of every ``do_not_mock`` marker stacked on a test item.

When ``block_all`` is True, all mocking is forbidden and ``protected`` is
empty. Otherwise ``protected`` holds one entry per distinct target named
across the stacked markers, unresolved: string targets stay dotted paths
until enforcement time, so building a contract never imports application
code.
"""

block_all: bool
protected: tuple[ProtectedFunc, ...] = ()


def resolve_do_not_mock(item: pytest.Item) -> DoNotMockContract | None:
"""Return the no-mocks contract for *item*, or ``None`` if it is unmarked.

Walks every ``do_not_mock`` marker on the item (function, class, module)
and unions them. A bare marker at any scope wins: it forbids all mocking
regardless of targeted markers elsewhere in the stack. Safe to call at
collection time, before any test runs.
"""
markers = list(item.iter_markers("do_not_mock"))
if not markers:
return None
if any(_is_block_all(marker) for marker in markers):
return DoNotMockContract(block_all=True)

protected: dict[str, ProtectedFunc] = {}
for marker in markers:
for func in _named_targets(marker):
protected.setdefault(func.module_path, func)
return DoNotMockContract(block_all=False, protected=tuple(protected.values()))


def _is_block_all(marker: pytest.Mark) -> bool:
"""A marker that names no targets forbids all mocking."""
return not marker.args and not marker.kwargs.get("protect")


def _named_targets(marker: pytest.Mark) -> Iterator[ProtectedFunc]:
"""Yield one :class:`ProtectedFunc` per target named by *marker*.

Positional args are dotted string paths; ``protect=`` takes a function
object or a list of them, and combines with positional args.
"""
targets: list[Any] = list(marker.args)
protect: Any = marker.kwargs.get("protect")
if isinstance(protect, list):
targets.extend(protect) # pyright: ignore[reportUnknownArgumentType]
elif protect is not None:
targets.append(protect)
return (ProtectedFunc.from_arg(target) for target in targets)
57 changes: 11 additions & 46 deletions src/pytest_do_not_mock/plugin.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from __future__ import annotations

from collections.abc import Generator
from typing import Any

import pytest

from .contract import resolve_do_not_mock
from .guards import mock_guard
from .protected import ProtectedFunc, validate_no_mocks
from .protected import validate_no_mocks


def pytest_configure(config: pytest.Config) -> None:
Expand All @@ -19,59 +19,24 @@ def pytest_configure(config: pytest.Config) -> None:
)


def _collect_protected(marker: pytest.Mark) -> list[ProtectedFunc]:
"""Build the list of protected functions from marker args and kwargs.

Supports:
@pytest.mark.do_not_mock("mod.func") # string path
@pytest.mark.do_not_mock("mod.f1", "mod.f2") # multiple strings
@pytest.mark.do_not_mock(protect=func) # single function object
@pytest.mark.do_not_mock(protect=[f1, f2]) # multiple function objects
@pytest.mark.do_not_mock("mod.f1", protect=func) # mixed
"""
targets: list[Any] = list(marker.args)

protect: Any = marker.kwargs.get("protect")
if protect is not None:
if isinstance(protect, list):
for item in protect: # pyright: ignore[reportUnknownVariableType]
targets.append(item)
else:
targets.append(protect)

return [ProtectedFunc.from_arg(t) for t in targets]


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(item: pytest.Item) -> Generator[None, None, None]:
"""If the test is marked with ``@pytest.mark.do_not_mock``, install mock guards."""
markers = list(item.iter_markers("do_not_mock"))
if not markers:
"""Enforce the item's ``do_not_mock`` contract while the test body runs."""
contract = resolve_do_not_mock(item)
if contract is None:
yield
return

test_name = item.name

block_all = any(not m.args and not m.kwargs.get("protect") for m in markers)

if block_all:
with mock_guard(test_name, block_all=True):
if contract.block_all:
with mock_guard(item.name, block_all=True):
yield
return

seen: set[str] = set()
protected: list[ProtectedFunc] = []
for m in markers:
for pf in _collect_protected(m):
if pf.module_path in seen:
continue
seen.add(pf.module_path)
protected.append(pf)

validate_no_mocks(protected, test_name, "before")
with mock_guard(test_name, protected=protected):
protected = [func.resolve() for func in contract.protected]
validate_no_mocks(protected, item.name, "before")
with mock_guard(item.name, protected=protected):
yield
validate_no_mocks(protected, test_name, "after")
validate_no_mocks(protected, item.name, "after")


def pytest_report_header(config: pytest.Config) -> str:
Expand Down
27 changes: 25 additions & 2 deletions src/pytest_do_not_mock/protected.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from __future__ import annotations

import pkgutil
import sys
import unittest.mock
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from typing import Any

from .errors import DoNotMockError
Expand Down Expand Up @@ -33,10 +34,32 @@ def from_arg(cls, func: Any) -> ProtectedFunc:
full_path = f"{module}.{name}" if module else name
return cls(name=name, module_path=full_path, obj=func, _self_obj=self_obj, _module_name=module)

def resolve(self) -> ProtectedFunc:
"""Return a copy with the live function object imported and attached.

String targets stay unresolved dotted paths until this point so that
collecting tests never imports application code. A path that cannot
be resolved raises rather than silently protecting nothing.
"""
if self.obj is not None:
return self
try:
obj = pkgutil.resolve_name(self.module_path)
except (ImportError, AttributeError, ValueError) as exc:
raise DoNotMockError(f"\n@pytest.mark.do_not_mock cannot resolve '{self.module_path}': {exc}\n") from exc
return replace(self, obj=obj)

def matches_patch_target(self, target: Any, attribute: str) -> bool:
"""Return True if a patch on *target.attribute* would affect this function."""
"""Return True if a patch on *target.attribute* would affect this function.

The primary check is object identity, which catches the function under
any name it was imported as. The module-and-name check remains for
targets without a resolved object.
"""
if self._self_obj is not None:
return target is self._self_obj and attribute == self.name
if self.obj is not None and getattr(target, attribute, None) is self.obj:
return True
if self._module_name:
return target is sys.modules.get(self._module_name) and attribute == self.name
return False
Expand Down
Loading
Loading