diff --git a/NEWS.d/2023-01-02-16-59-49.gh-issue-100690.2EgWPS.rst b/NEWS.d/2023-01-02-16-59-49.gh-issue-100690.2EgWPS.rst new file mode 100644 index 00000000..3796772a --- /dev/null +++ b/NEWS.d/2023-01-02-16-59-49.gh-issue-100690.2EgWPS.rst @@ -0,0 +1,7 @@ +``Mock`` objects which are not unsafe will now raise an +``AttributeError`` when accessing an attribute that matches the name +of an assertion but without the prefix ``assert_``, e.g. accessing +``called_once`` instead of ``assert_called_once``. +This is in addition to this already happening for accessing attributes +with prefixes ``assert``, ``assret``, ``asert``, ``aseert``, +and ``assrt``. diff --git a/mock/mock.py b/mock/mock.py index 040dd770..0b4551bb 100644 --- a/mock/mock.py +++ b/mock/mock.py @@ -656,7 +656,7 @@ def __getattr__(self, name): elif _is_magic(name): raise AttributeError(name) if not self._mock_unsafe and (not self._mock_methods or name not in self._mock_methods): - if name.startswith(('assert', 'assret', 'asert', 'aseert', 'assrt')): + if name.startswith(('assert', 'assret', 'asert', 'aseert', 'assrt')) or name in ATTRIB_DENY_LIST: raise AttributeError( f"{name!r} is not a valid assertion. Use a spec " f"for the mock if {name!r} is meant to be an attribute.") @@ -1070,6 +1070,10 @@ def _calls_repr(self, prefix="Calls"): return f"\n{prefix}: {safe_repr(self.mock_calls)}." +# Denylist for forbidden attribute names in safe mode +ATTRIB_DENY_LIST = {name.removeprefix("assert_") for name in dir(NonCallableMock) if name.startswith("assert_")} + + class _AnyComparer(list): """A list which checks if it contains a call which may have an argument of ANY, flipping the components of item and self from @@ -1241,9 +1245,11 @@ class or instance) that acts as the specification for the mock object. If `return_value` attribute. * `unsafe`: By default, accessing any attribute whose name starts with - *assert*, *assret*, *asert*, *aseert* or *assrt* will raise an - AttributeError. Passing `unsafe=True` will allow access to - these attributes. + *assert*, *assret*, *asert*, *aseert*, or *assrt* raises an AttributeError. + Additionally, an AttributeError is raised when accessing + attributes that match the name of an assertion method without the prefix + `assert_`, e.g. accessing `called_once` instead of `assert_called_once`. + Passing `unsafe=True` will allow access to these attributes. * `wraps`: Item for the mock object to wrap. If `wraps` is not None then calling the Mock will pass the call through to the wrapped object diff --git a/mock/tests/testmock.py b/mock/tests/testmock.py index 3770e202..41b0ab8f 100644 --- a/mock/tests/testmock.py +++ b/mock/tests/testmock.py @@ -1648,12 +1648,36 @@ def test_mock_unsafe(self): m.aseert_foo_call() with self.assertRaisesRegex(AttributeError, msg): m.assrt_foo_call() + with self.assertRaisesRegex(AttributeError, msg): + m.called_once_with() + with self.assertRaisesRegex(AttributeError, msg): + m.called_once() + with self.assertRaisesRegex(AttributeError, msg): + m.has_calls() + + class Foo(object): + def called_once(self): + pass + + def has_calls(self): + pass + + m = Mock(spec=Foo) + m.called_once() + m.has_calls() + + m.called_once.assert_called_once() + m.has_calls.assert_called_once() + m = Mock(unsafe=True) m.assert_foo_call() m.assret_foo_call() m.asert_foo_call() m.aseert_foo_call() m.assrt_foo_call() + m.called_once() + m.called_once_with() + m.has_calls() # gh-100739 def test_mock_safe_with_spec(self):