-
-
Notifications
You must be signed in to change notification settings - Fork 32.2k
bpo-35226: Fix equality for nested unittest.mock.call objects. #10555
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Hello, and thanks for your contribution! I'm a bot set up to make sure that the project can legally accept your contribution by verifying you have signed the PSF contributor agreement (CLA). Unfortunately we couldn't find an account corresponding to your GitHub username on bugs.python.org (b.p.o) to verify you have signed the CLA (this might be simply due to a missing "GitHub Name" entry in your b.p.o account settings). This is necessary for legal reasons before we can look at your contribution. Please follow the steps outlined in the CPython devguide to rectify this issue. You can check yourself to see if the CLA has been received. Thanks again for your contribution, we look forward to reviewing it! |
36fdd3e
to
1732ed1
Compare
@@ -2054,6 +2054,10 @@ def __eq__(self, other): | |||
else: | |||
self_name, self_args, self_kwargs = self | |||
|
|||
if (getattr(self, 'parent', None) and getattr(other, 'parent', None) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
self
should always have a parent attribute, no need to use getattr, isn't it?
You are using gettattr to ignore the value and then compare it. What if it has a parent but it evaluates Falsy? Might be better to just use hasattr
.
if hasattr(other, 'parent') and self.parent != other.parent:
return False
I think it reads easier
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The implementation was suggested in the bug discussion, @voidspace can make a call here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think @mariocj89 has a point. On call()
the parent is None
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is hasattr
safe to use here or does it still risk swallowing exceptions other than AttributeError
on Python 3.6+? The grumpy old python dev in me still prefers getattr(other, 'parent', None)
out of paranoia...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hasattr
was fixed quite some time ago I believe :-)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Surprising indeed!
Look at this:
>>> from unittest.mock import Mock
>>> m = Mock()
>>> m.abc.cde()
<Mock name='mock.abc.cde()' id='139944355306408'>
>>> m.mock_calls
[call.abc.cde()]
>>> print(m.mock_calls[0].parent)
None
Seems parent is not set at all, which is causing a lot of tests to fail with my proposal :/
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wow. That seems like a bug too, let me add some cases and see about a fix.
@voidspace - open question: shall we keep getattr(other, 'parent', None)
to cover the porting-to-testing-cabal version issue?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(just as a note, we can follow this up indeed in another issue as I am not sure about the fix, seems we are not passing the parent here): https://github.com/python/cpython/blob/master/Lib/unittest/mock.py#L986)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm afraid it's intrinsically linked to this issue, call
children are designed to be compared to items in Mock().method_calls
and Mock().mock_calls
and I don't think that currently works safely... They'll generally always compare as equal, which isn't what we want with nested/generative calls...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where there's a single call there's no parent call. So having parent not set in that example looks correct.
@@ -269,6 +269,14 @@ def test_extended_call(self): | |||
self.assertEqual(mock.mock_calls, last_call.call_list()) | |||
|
|||
|
|||
def test_extended_not_equal(self): | |||
a = call(x=1).foo |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know it works due to the implementation, but I'd also validate this works recursively for future changes to the module with
a = call(x=1).foo().bar
b = call(x=2).foo().bar
91c1aed
to
d16ec07
Compare
d16ec07
to
c704bca
Compare
@mariocj89 - okay, so I finally got a chance to come back to this. @voidspace - I'm happy with this PR now, can we go for a merge? Then I'll get the backporting shenanigans done. |
Gah, finally found the case that was the problem :-( |
52fe7f5
to
74c23cd
Compare
@mariocj89 / @voidspace - okay, after a good few hours of tinkering and thinkering, I think this PR is good to go. State of play is as follows:
I don't believe the So, if you could review this PR on that basis, and add backport tags to all maintained versions since this is now just a bugfix change, I'd be very grateful. My plan is to start building a |
67d39fc
to
d807f86
Compare
@mariocj89 - if you're okay with this, can you approve it so it gets merged? I can then backport to https://github.com/testing-cabal/mock where I'm now a maintainer. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A bunch of comments. I'd personally prefer @vstinner or @voidspace to review this PR as I don't agree with it (now, good news, I am just a random reviewer).
When validating calls in mocks, AFAIK people are expected to use mock_calls
and call_list
, that works file, see the example of the warnings you put:
>>> m = Mock()
>>> m.factory(important=True).deliver()
<Mock name='mock.factory().deliver()' id='...'>
>>> m.mock_calls[-1] == call.factory(important=False).deliver()
True
I think this is just a wrong usage of call
, people should just always call call_list
if that is what they want.
This works as expected if the API is used as documetned:
>>> m = Mock()
>>> m.factory(important=True).deliver()
<Mock name='mock.factory().deliver()' id='...'>
>>> m.mock_calls == call.factory(important=False).deliver().call_list()
False
The way call
is done, allows you to also have the option of validating the call without caring about the parent argumetns if you wanted.
With that said about the API, I see three problems about this PR though:
-
It does not address the original concern about the usage (which I disagree with though), and with it the
call
__eq__
"feels" different. Right now__eq__
on a mock and a call should be done by using a method that returns you all the calls that happend (in case you want to validate all the arguments), this PR removes that option, and what is worse, people might be already using this API with that objective in their minds (which is what it seems to provde). I would not backport this to previous versions of Python. -
Adds warnings about the a usage of
call
that is not the intended per the documentation. -
Makes quite a refactor for a PR that plans to backport, I'd just "leave previous versions as they are" to minimize risks... :/ Maybe I am too paranoid.
TL;DR;
I would just change this whole PR for a warning in the call
class saying:
When used with nested
call
, make sure to usecall_list()
if you want to compare recursively with the value of the argumetns.
Real apologies if you feel I am being "hard on the PR" but I just find things are fine. call(x=1).foo == call(x=2).foo
because that is how the API is. If you want to check recursively you should do: call(x=1).foo.call_list() == call(x=2).foo.call_list()
. Anyway, as said, just a random reviewer, there are 3 core devs in this PR, one of them the original designer of this API :)
@@ -0,0 +1 @@ | |||
Fix equality for nested unittest.mock.call objects. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd try to be more explicit here.
Recursively check arguments when testing for equality of :class:`unittest.mock.call` objects.
.
As the parent names are already being checked via the name attribute.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Happy to polish this once we get some consensus on this PR.
Lib/unittest/mock.py
Outdated
@@ -2054,6 +2054,10 @@ def __eq__(self, other): | |||
else: | |||
self_name, self_args, self_kwargs = self | |||
|
|||
if (getattr(self, 'parent', None) and getattr(other, 'parent', None) | |||
and self.parent != other.parent): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this needs one more level of indentation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't understand what you'd like here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the indentation is at the same level as the inner block. PEP8 says it should have one more indentation (4 more spaces)
def test_mock_call_not_equal_leaf(self): | ||
m = Mock() | ||
m.foo().bar() | ||
self.assertNotEqual(m.mock_calls[1], call.foo().baz()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤣 I've been scratching my head for 3 minutes until I noticed the baz
rather than bar
else: | ||
dot = '.' | ||
is_a_call = _new_parent._mock_new_name == '()' | ||
mock_call_name = _new_parent._mock_new_name + dot + mock_call_name |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes.
dot = '' | ||
else: | ||
dot = '.' | ||
is_a_call = _new_parent._mock_new_name == '()' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this var used?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
indeed, I quickly overview it and it is used within the loop.
@@ -166,6 +166,15 @@ You use the :data:`call` object to construct lists for comparing with | |||
>>> mock.mock_calls == expected | |||
True | |||
|
|||
However, parameters to calls that return mocks are not recorded, which means tracking | |||
nested calls where the parameters used to create ancestors are important is not possible: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure what this means:
the parameters used to create ancestors are important is not possible
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"tracking nested calls where the parameters used to create ancestors are important is not possible"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, I see. But this can be done with mock_calls
or call_list
isn't it? It just cannot be all extracted from a single call
object within mock_calls
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, it cannot, if it could, I wouldn't have reported the bug, put together a PR and spent several hours trying to figure out a way to do this that was backwards compatible ;-)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, I am clearly missing something here.
>>> m = Mock()
>>> m.factory(important=True).deliver()
<Mock name='mock.factory().deliver()' id='139810163105576'>
>>> m.mock_calls == call.factory(important=False).deliver().call_list()
False
Note the false at the end. This works AFAIK.
Setting the expectation right works:
>>> m.mock_calls == call.factory(important=True).deliver().call_list()
True
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A call to any individual mock always returns the same mock (unless you're using a side_effect to return a different one). So you can't track the parent parameters in general as soon as there's more than one call. As noted the parameters to the original call will still be in the full call chain. I think the behaviour here is working as designed it just doesn't solve every use case.
@@ -512,16 +521,6 @@ call:: | |||
>>> something.backend = mock_backend | |||
>>> something.method() | |||
|
|||
Using :attr:`~Mock.mock_calls` we can check the chained call with a single |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do you remove this? This works fine.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
...because it's not safe to use: because the ancestor parameters are not compared, you can easily be fooled into thinking something is being called correctly when it is not.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe I am misunderstanding this whole thing, but what is not safe? How can you be fooled?
just try changing foobar here to foobaz
, the assert will fail because this is using call_list
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't mind a note being added, although perhaps just the note without the warning. call_list
is still useful so I don't want to see this removed from the docs.
@@ -1144,6 +1143,15 @@ the ``mock_calls`` attribute on the manager mock: | |||
>>> manager.mock_calls == expected_calls | |||
True | |||
|
|||
However, parameters to calls that return mocks are not recorded, which means tracking | |||
nested calls where the parameters used to create ancestors are important is not possible: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note this is possible, you just need to use call_list()
, as it was specified in the docs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not across mocks it's not.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you mean with across mocks?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will cover this in a non-in-line response...
.. warning:: | ||
|
||
The way :attr:`mock_calls` are recorded means that where nested | ||
calls are made, the parameters of ancestor calls are not recorded |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
They are recorded, just as different calls.. :/ I think this is going to be really confusing for the reader.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The parameters are absolutely not recorded in mock_calls
, this is the core problem that isn't solvable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
They are! just not in the last one (each call records only its own parameters), look:
>>> mock_backend.mock_calls
[call.get_endpoint('foobar'),
call.get_endpoint().create_call('spam', 'eggs'),
call.get_endpoint().create_call().start_call()]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure we're getting anywhere constructive with this, so let's wait for Michael to take a look.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
...except when get_endpoint
is called more than once.
else: | ||
dot = '.' | ||
is_a_call = _new_parent._mock_new_name == '()' | ||
mock_call_name = _new_parent._mock_new_name + dot + mock_call_name |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes.
@vstinner / @voidspace - sorry, I thought @mariocj89 was an owner of My recollection of I believe the intention was always for nested calls to not compare equal if their parameters were not the same, but since this current behaviour will silently have let this incorrectly pass for many years, I'm not sure anyone would have noticed. I only spotted it after 7 years as I was looking very closely in this area, and I've been a heavy user from this feature since it arrived! I've now put a great deal of time into thoroughly understanding the problem, and I've summarised the position above. I believe this PR represents the best that can reasonably be achieved without breaking backwards compatibility. |
Here's an example of why I think the current behaviour is dangerous, and needs fixing where possible (which turns out to be only on the
The intention here is to test that we're killing the |
I have been asked to review this change, but I see a lot of comments which are not marked as "resolved" and I don't see any approval, so I prefer to not review this change yet. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The doc changes are excessively dramatic. call is still useful, but because of the standard behaviour of mocks (repeated calls will always return the same mock) parent parameters aren't tracked. However the original call is still there in the chain.
Also refactor the call recording imolementation and add some notes about its limitations.
d807f86
to
7413043
Compare
@voidspace - I believe that addresses all the feedback, let me know your thoughts. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
@cjw296: Please replace |
Thanks @cjw296 for the PR 🌮🎉.. I'm working now to backport this PR to: 3.6. |
Thanks @cjw296 for the PR 🌮🎉.. I'm working now to backport this PR to: 3.7. |
GH-10878 is a backport of this pull request to the 3.6 branch. |
GH-10879 is a backport of this pull request to the 3.7 branch. |
…nGH-10555) Also refactor the call recording imolementation and add some notes about its limitations. (cherry picked from commit 8ca0fa9) Co-authored-by: Chris Withers <chris@withers.org>
…nGH-10555) Also refactor the call recording imolementation and add some notes about its limitations. (cherry picked from commit 8ca0fa9) Co-authored-by: Chris Withers <chris@withers.org>
) Also refactor the call recording implementation and add some notes about its limitations. (cherry picked from commit 8ca0fa9) Co-authored-by: Chris Withers <chris@withers.org>
) Also refactor the call recording imolementation and add some notes about its limitations. (cherry picked from commit 8ca0fa9) Co-authored-by: Chris Withers <chris@withers.org>
https://bugs.python.org/issue35226