Skip to content
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

gh-100690: Prevent prefix "called_" for attributes of safe mock objects #100691

Merged
merged 6 commits into from
Jan 6, 2023

Conversation

cklein
Copy link
Contributor

@cklein cklein commented Jan 2, 2023

Prevent prefix "called_" for mocks in safe mode

@bedevere-bot
Copy link

Most changes to Python require a NEWS entry.

Please add it using the blurb_it web app or the blurb command-line tool.

@cklein cklein changed the title Prevent prefix "called_" for attributes of safe mock objects gh-100690: Prevent prefix "called_" for attributes of safe mock objects Jan 2, 2023
@cklein cklein force-pushed the disallow_mock_method_prefix branch 2 times, most recently from 25e26d8 to 7bc8c46 Compare January 2, 2023 16:25
Copy link
Contributor

@cjw296 cjw296 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm -0 on this change, feels like we're into the diminishing realms where someone out there is going to have a legitimate use case for called_name or some such and this then prevents them using a safe mock.

Who else is hitting this?

@cklein cklein force-pushed the disallow_mock_method_prefix branch from 7bc8c46 to b84bc75 Compare January 2, 2023 16:46
@cklein
Copy link
Contributor Author

cklein commented Jan 2, 2023

@cjw296, the same applies to any of the forbidden prefixes. This could be solved by allowing those prefixes with a spec. Currently, even if the name appears in _mock_methods (https://github.com/python/cpython/blob/main/Lib/unittest/mock.py#L651 evaluates to true), an error will be raised. But I guess this could be a different and more fundamental discussion.

@cklein
Copy link
Contributor Author

cklein commented Jan 2, 2023

I have been thinking a bit more about the whole problem. It seems the current behavior does not match the error message:

>>> class Foo:
...     def assert_bar(self):
...         pass
...
>>> from unittest.mock import Mock
>>> m = Mock(spec=Foo)
>>> m.assert_bar()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/opt/local/Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/unittest/mock.py", line 648, in __getattr__
    raise AttributeError(
AttributeError: 'assert_bar' is not a valid assertion. Use a spec for the mock if 'assert_bar' is meant to be an attribute.

The error message says "Use a spec for the mock if 'assert_bar' is meant to be an attribute.", but this example should pass according to the error message. There's a spec and the name assert_bar should be supported.

With cklein@37aa291 applied, it behaves like described:

>>> class Foo:
...     def assert_bar(self):
...         pass
...
>>> from unittest.mock import Mock
>>> m = Mock(spec=Foo)
>>> m.assert_bar()
<Mock name='mock.assert_bar()' id='4384202080'>
>>> m.assert_frobnicated()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/cklein/github/cklein/cpython/Lib/unittest/mock.py", line 652, in __getattr__
    raise AttributeError("Mock object has no attribute %r" % name)
AttributeError: Mock object has no attribute 'assert_frobnicated'

@merwok
Copy link
Member

merwok commented Jan 3, 2023

The prefixes checked could be more specific like called_once_with, called_once, called_with to alleviate the concern about forbidding too much.

@cjw296
Copy link
Contributor

cjw296 commented Jan 3, 2023

@cklein - I think you're right about this being a complicated issue, perhaps a PR isn't the best place to discuss it? Could you maybe close this PR an open an issue explaining your concerns and some possible ways forward?

@AlexWaygood
Copy link
Member

Could you maybe close this PR an open an issue explaining your concerns and some possible ways forward?

OP has already done so:

@cklein
Copy link
Contributor Author

cklein commented Jan 3, 2023

I can also create another issue for my second branch about the spec not being properly checked, if that helps

Edit: See https://discuss.python.org/t/check-for-assert-prefixes-doesnt-respect-spec/22388

… mode

Raise an AttributeError when calling e.g. `mock_obj.called_once` instead of
`mock_obj.assert_called_once`.
@cklein
Copy link
Contributor Author

cklein commented Jan 3, 2023

I'm aware that the documentation is now off. I'd like to get some feedback on the change first, if that's OK

Lib/unittest/mock.py Outdated Show resolved Hide resolved
@@ -1062,6 +1062,10 @@ def _calls_repr(self, prefix="Calls"):
return f"\n{prefix}: {safe_repr(self.mock_calls)}."


# gh-100690 Denylist for forbidden attribute names in safe mode
ATTRIB_DENY_LIST = {name.removeprefix("assert_") for name in dir(NonCallableMock) if name.startswith("assert_")}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Much prefer this approach! 🎉

@cjw296
Copy link
Contributor

cjw296 commented Jan 5, 2023

Approach is good, so once the docs are fixed up, should be good to land.
Would a backport release once that's done be helpful? (I mean, I'll probably crank the handle anyway, but thought I'd ask!)

@cklein
Copy link
Contributor Author

cklein commented Jan 5, 2023

I'm struggling a bit with documentation. What about the following:

    * `unsafe`: By default, accessing any attribute whose name starts with
      *assert*, *assret*, *asert*, *aseert*, or *assrt* raises an AttributeError.
      Additionally, an AttributeError is raised when accessing
      attributes that match the name of 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.

@merwok merwok added type-feature A feature request or enhancement stdlib Python modules in the Lib dir labels Jan 5, 2023
@cklein
Copy link
Contributor Author

cklein commented Jan 6, 2023

Would a backport release once that's done be helpful?

Personally I'd like to see it in earlier versions of Python.
But then I'm a bit torn because on the one hand this will definitely make some tests fail, on the other hand those tests will finally be properly executed and some unwanted behavior might be uncovered.

@cjw296
Copy link
Contributor

cjw296 commented Jan 6, 2023

@cklein - 3.10 and 3.11 are open for bug fixes so their next third point release would contain this once its landed.
...and to be clear: I consider this a bug in that there are checks for these problems but they are insufficient.

@cjw296 cjw296 added needs backport to 3.10 only security fixes needs backport to 3.11 only security fixes type-bug An unexpected behavior, bug, or error and removed DO-NOT-MERGE type-feature A feature request or enhancement labels Jan 6, 2023
@cjw296 cjw296 removed DO-NOT-MERGE needs backport to 3.10 only security fixes needs backport to 3.11 only security fixes labels Jan 6, 2023
@cjw296
Copy link
Contributor

cjw296 commented Jan 6, 2023

The Azure Pipelines failure seems unrelated.

@cjw296 cjw296 merged commit 1d4d677 into python:main Jan 6, 2023
@AlexWaygood
Copy link
Member

The Azure Pipelines failure seems unrelated.

Yes, that failure happens intermittently on a lot of PRs

@bedevere-bot
Copy link

⚠️⚠️⚠️ Buildbot failure ⚠️⚠️⚠️

Hi! The buildbot PPC64LE RHEL7 LTO 3.x has failed when building commit 1d4d677.

What do you need to do:

  1. Don't panic.
  2. Check the buildbot page in the devguide if you don't know what the buildbots are or how they work.
  3. Go to the page of the buildbot that failed (https://buildbot.python.org/all/#builders/503/builds/2845) and take a look at the build logs.
  4. Check if the failure is related to this commit (1d4d677) or if it is a false positive.
  5. If the failure is related to this commit, please, reflect that on the issue and make a new Pull Request with a fix.

You can take a look at the buildbot page here:

https://buildbot.python.org/all/#builders/503/builds/2845

Failed tests:

  • test_nntplib

Failed subtests:

  • tearDownClass - test.test_nntplib.NetworkedNNTPTests
  • test_with_statement - test.test_nntplib.NetworkedNNTPTests.test_with_statement

Summary of the results of the build (if available):

== Tests result: FAILURE then FAILURE ==

408 tests OK.

10 slowest tests:

  • test_concurrent_futures: 2 min 46 sec
  • test_multiprocessing_spawn: 1 min 45 sec
  • test_multiprocessing_forkserver: 1 min 20 sec
  • test_multiprocessing_fork: 1 min 9 sec
  • test_tokenize: 1 min 7 sec
  • test_asyncio: 1 min 2 sec
  • test_logging: 54.8 sec
  • test_signal: 48.0 sec
  • test_venv: 42.2 sec
  • test_io: 38.8 sec

1 test failed:
test_nntplib

24 tests skipped:
test_check_c_globals test_devpoll test_gdb test_idle test_ioctl
test_kqueue test_launcher test_msilib test_peg_generator
test_perf_profiler test_smtpnet test_ssl test_startfile test_tcl
test_tix test_tkinter test_ttk test_ttk_textonly test_turtle
test_winconsoleio test_winreg test_winsound test_wmi
test_zipfile64

1 re-run test:
test_nntplib

Total duration: 7 min 7 sec

Click to see traceback logs
Traceback (most recent call last):
  File "/home/buildbot/buildarea/3.x.cstratak-RHEL7-ppc64le.lto/build/Lib/test/test_nntplib.py", line 252, in wrapped
    meth(self)
  File "/home/buildbot/buildarea/3.x.cstratak-RHEL7-ppc64le.lto/build/Lib/test/test_nntplib.py", line 286, in test_with_statement
    server = self.NNTP_CLASS(self.NNTP_HOST, **kwargs)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/buildbot/buildarea/3.x.cstratak-RHEL7-ppc64le.lto/build/Lib/nntplib.py", line 341, in __init__
    self._base_init(readermode)
  File "/home/buildbot/buildarea/3.x.cstratak-RHEL7-ppc64le.lto/build/Lib/nntplib.py", line 359, in _base_init
    self.getcapabilities()
  File "/home/buildbot/buildarea/3.x.cstratak-RHEL7-ppc64le.lto/build/Lib/nntplib.py", line 421, in getcapabilities
    resp, caps = self.capabilities()
                 ^^^^^^^^^^^^^^^^^^^
  File "/home/buildbot/buildarea/3.x.cstratak-RHEL7-ppc64le.lto/build/Lib/nntplib.py", line 590, in capabilities
    resp, lines = self._longcmdstring("CAPABILITIES")
                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/buildbot/buildarea/3.x.cstratak-RHEL7-ppc64le.lto/build/Lib/nntplib.py", line 557, in _longcmdstring
    resp, list = self._getlongresp(file)
                 ^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/buildbot/buildarea/3.x.cstratak-RHEL7-ppc64le.lto/build/Lib/nntplib.py", line 508, in _getlongresp
    resp = self._getresp()
           ^^^^^^^^^^^^^^^
  File "/home/buildbot/buildarea/3.x.cstratak-RHEL7-ppc64le.lto/build/Lib/nntplib.py", line 481, in _getresp
    resp = self._getline()
           ^^^^^^^^^^^^^^^
  File "/home/buildbot/buildarea/3.x.cstratak-RHEL7-ppc64le.lto/build/Lib/nntplib.py", line 469, in _getline
    if not line: raise EOFError
                 ^^^^^^^^^^^^^^
EOFError


Traceback (most recent call last):
  File "/home/buildbot/buildarea/3.x.cstratak-RHEL7-ppc64le.lto/build/Lib/test/test_nntplib.py", line 347, in tearDownClass
    cls.server.quit()
  File "/home/buildbot/buildarea/3.x.cstratak-RHEL7-ppc64le.lto/build/Lib/nntplib.py", line 933, in quit
    resp = self._shortcmd('QUIT')
           ^^^^^^^^^^^^^^^^^^^^^^
  File "/home/buildbot/buildarea/3.x.cstratak-RHEL7-ppc64le.lto/build/Lib/nntplib.py", line 543, in _shortcmd
    return self._getresp()
           ^^^^^^^^^^^^^^^
  File "/home/buildbot/buildarea/3.x.cstratak-RHEL7-ppc64le.lto/build/Lib/nntplib.py", line 481, in _getresp
    resp = self._getline()
           ^^^^^^^^^^^^^^^
  File "/home/buildbot/buildarea/3.x.cstratak-RHEL7-ppc64le.lto/build/Lib/nntplib.py", line 464, in _getline
    line = self.file.readline(_MAXLINE +1)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/buildbot/buildarea/3.x.cstratak-RHEL7-ppc64le.lto/build/Lib/socket.py", line 707, in readinto
    return self._sock.recv_into(b)
           ^^^^^^^^^^^^^^^^^^^^^^^
TimeoutError: timed out

@@ -1062,6 +1062,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_")}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for being late to the party, but anyways :)

I think that ATTRIB_DENY_LIST is an implementation detail, so it should be _ATTRIB_DENY_LIST. And also, it is not suited for mutation, isn't it? So, it should be a frozenset instead :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this PR is already merged, I've opened #100819 with this proposal. Feel free to close if it should be public and mutable!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is already non-public by not being included in __all__, and the mutability is not really an issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
stdlib Python modules in the Lib dir type-bug An unexpected behavior, bug, or error
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants