From d500bbabfb716c7e71144f35fb662c7b62035946 Mon Sep 17 00:00:00 2001 From: Kit Choi Date: Sun, 19 Jan 2020 17:09:36 +0000 Subject: [PATCH 1/9] Add an assertNoLogs context manager to ensure no logs found --- Lib/unittest/case.py | 45 +++++++++++++++++++++- Lib/unittest/test/test_case.py | 70 ++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/Lib/unittest/case.py b/Lib/unittest/case.py index fa64a6ea2378c0..74b41534962d01 100644 --- a/Lib/unittest/case.py +++ b/Lib/unittest/case.py @@ -318,8 +318,8 @@ def emit(self, record): -class _AssertLogsContext(_BaseTestCaseContext): - """A context manager used to implement TestCase.assertLogs().""" +class _AssertLogsBaseContext(_BaseTestCaseContext): + """Base class for assertLogs() and assertNoLogs() """ LOGGING_FORMAT = "%(levelname)s:%(name)s:%(message)s" @@ -353,15 +353,48 @@ def __exit__(self, exc_type, exc_value, tb): self.logger.handlers = self.old_handlers self.logger.propagate = self.old_propagate self.logger.setLevel(self.old_level) + + +class _AssertLogsContext(_AssertLogsBaseContext): + """A context manager used to implement TestCase.assertLogs().""" + + def __exit__(self, exc_type, exc_value, tb): + _AssertLogsBaseContext.__exit__( + self, exc_type, exc_value, tb) + if exc_type is not None: # let unexpected exceptions pass through return False + if len(self.watcher.records) == 0: self._raiseFailure( "no logs of level {} or higher triggered on {}" .format(logging.getLevelName(self.level), self.logger.name)) +class _AssertNoLogsContext(_AssertLogsBaseContext): + """A context manager used to implement TestCase.assertNoLogs().""" + + def __enter__(self): + _AssertLogsBaseContext.__enter__(self) + return None + + def __exit__(self, exc_type, exc_value, tb): + _AssertLogsBaseContext.__exit__( + self, exc_type, exc_value, tb) + + if exc_type is not None: + # let unexpected exceptions pass through + return False + + if len(self.watcher.records) > 0: + self._raiseFailure( + "Logs unexpected found: {!r}".format( + self.watcher.output + ) + ) + + class _OrderedChainMap(collections.ChainMap): def __iter__(self): seen = set() @@ -854,6 +887,14 @@ def assertLogs(self, logger=None, level=None): """ return _AssertLogsContext(self, logger, level) + def assertNoLogs(self, logger=None, level=None): + """ Fail unless no log messages of level *level* or higher are emitted + on *logger_name* or its children. + + This method must be used as a context manager, and will yield nothing. + """ + return _AssertNoLogsContext(self, logger, level) + def _getAssertEqualityFunc(self, first, second): """Get a detailed comparison function for the types of the two args. diff --git a/Lib/unittest/test/test_case.py b/Lib/unittest/test/test_case.py index c2401c39b917e3..b55b0be0d3e06a 100644 --- a/Lib/unittest/test/test_case.py +++ b/Lib/unittest/test/test_case.py @@ -1657,6 +1657,76 @@ def testAssertLogsFailureMismatchingLogger(self): with self.assertLogs('foo'): log_quux.error("1") + def testAssertLogsUnexpectedException(self): + # Check unexpected exception will go through. + with self.assertRaises(ZeroDivisionError): + with self.assertLogs(): + raise ZeroDivisionError("Unexpected") + + def testAssertNoLogsDefault(self): + with self.assertRaises(self.failureException) as cm: + with self.assertNoLogs(): + log_foo.info("1") + log_foobar.debug("2") + self.assertEqual( + str(cm.exception), + "Logs unexpected found: ['INFO:foo:1']", + ) + + def testAssertNoLogsFailureFoundLogs(self): + with self.assertRaises(self.failureException) as cm: + with self.assertNoLogs(): + log_quux.error("1") + log_foo.error("foo") + + self.assertEqual( + str(cm.exception), + "Logs unexpected found: ['ERROR:quux:1', 'ERROR:foo:foo']", + ) + + def testAssertNoLogsPerLogger(self): + with self.assertNoStderr(): + with self.assertLogs(log_quux): + with self.assertNoLogs(logger=log_foo): + log_quux.error("1") + + def testAssertNoLogsFailurePerLogger(self): + # Failure due to unexpected logs for the given logger or its + # children. + with self.assertRaises(self.failureException) as cm: + with self.assertLogs(log_quux): + with self.assertNoLogs(logger=log_foo): + log_quux.error("1") + log_foobar.info("2") + self.assertEqual( + str(cm.exception), + "Logs unexpected found: ['INFO:foo.bar:2']", + ) + + def testAssertNoLogsPerLevel(self): + # Check per-level filtering + with self.assertNoStderr(): + with self.assertNoLogs(level="ERROR"): + log_foo.info("foo") + log_quux.debug("1") + + def testAssertNoLogsFailurePerLevel(self): + # Failure due to unexpected logs at the specified level. + with self.assertRaises(self.failureException) as cm: + with self.assertNoLogs(level="DEBUG"): + log_foo.debug("foo") + log_quux.debug("1") + self.assertEqual( + str(cm.exception), + "Logs unexpected found: ['DEBUG:foo:foo', 'DEBUG:quux:1']", + ) + + def testAssertNoLogsUnexpectedException(self): + # Check unexpected exception will go through. + with self.assertRaises(ZeroDivisionError): + with self.assertNoLogs(): + raise ZeroDivisionError("Unexpected") + def testDeprecatedMethodNames(self): """ Test that the deprecated methods raise a DeprecationWarning. See #9424. From 7ba7485af35e7945d98ac4185e34617c5caf2be8 Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2020 18:21:20 +0000 Subject: [PATCH 2/9] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20blu?= =?UTF-8?q?rb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../next/Library/2020-04-23-18-21-19.bpo-39385.MIAyS7.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2020-04-23-18-21-19.bpo-39385.MIAyS7.rst diff --git a/Misc/NEWS.d/next/Library/2020-04-23-18-21-19.bpo-39385.MIAyS7.rst b/Misc/NEWS.d/next/Library/2020-04-23-18-21-19.bpo-39385.MIAyS7.rst new file mode 100644 index 00000000000000..b1fe734c63d8a1 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2020-04-23-18-21-19.bpo-39385.MIAyS7.rst @@ -0,0 +1,3 @@ +A new test assertion context-manager, :func:`unittest.assertNoLogs` will +ensure a given block of code emits no log messages using the logging module. +(Contributed by Kit Yan Choi in bpo-39385.) \ No newline at end of file From a464bbf01b8d5b514e672dda9f88052a899b384d Mon Sep 17 00:00:00 2001 From: Kit Choi Date: Thu, 23 Apr 2020 19:23:51 +0100 Subject: [PATCH 3/9] Refactoring to remove duplication --- Lib/unittest/case.py | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/Lib/unittest/case.py b/Lib/unittest/case.py index 74b41534962d01..dd9d6f6f9ac605 100644 --- a/Lib/unittest/case.py +++ b/Lib/unittest/case.py @@ -354,18 +354,17 @@ def __exit__(self, exc_type, exc_value, tb): self.logger.propagate = self.old_propagate self.logger.setLevel(self.old_level) - -class _AssertLogsContext(_AssertLogsBaseContext): - """A context manager used to implement TestCase.assertLogs().""" - - def __exit__(self, exc_type, exc_value, tb): - _AssertLogsBaseContext.__exit__( - self, exc_type, exc_value, tb) - if exc_type is not None: # let unexpected exceptions pass through return False + self.check_records() + + +class _AssertLogsContext(_AssertLogsBaseContext): + """A context manager used to implement TestCase.assertLogs().""" + + def check_records(self): if len(self.watcher.records) == 0: self._raiseFailure( "no logs of level {} or higher triggered on {}" @@ -375,18 +374,7 @@ def __exit__(self, exc_type, exc_value, tb): class _AssertNoLogsContext(_AssertLogsBaseContext): """A context manager used to implement TestCase.assertNoLogs().""" - def __enter__(self): - _AssertLogsBaseContext.__enter__(self) - return None - - def __exit__(self, exc_type, exc_value, tb): - _AssertLogsBaseContext.__exit__( - self, exc_type, exc_value, tb) - - if exc_type is not None: - # let unexpected exceptions pass through - return False - + def check_records(self): if len(self.watcher.records) > 0: self._raiseFailure( "Logs unexpected found: {!r}".format( From 440dfdb0245bcec9f758297e98d3bb6d47febcf6 Mon Sep 17 00:00:00 2001 From: Kit Choi Date: Thu, 23 Apr 2020 19:33:42 +0100 Subject: [PATCH 4/9] Correct docstring given now assertNoLogs will return the watcher --- Lib/unittest/case.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/unittest/case.py b/Lib/unittest/case.py index dd9d6f6f9ac605..6c70cdb44a81c8 100644 --- a/Lib/unittest/case.py +++ b/Lib/unittest/case.py @@ -879,7 +879,8 @@ def assertNoLogs(self, logger=None, level=None): """ Fail unless no log messages of level *level* or higher are emitted on *logger_name* or its children. - This method must be used as a context manager, and will yield nothing. + This method must be used as a context manager, and will yield + a recording object expected to have recorded nothing. """ return _AssertNoLogsContext(self, logger, level) From 0aa8931c9c1c72a15ae11f4503b110188e53a900 Mon Sep 17 00:00:00 2001 From: Kit Choi Date: Thu, 23 Apr 2020 19:47:16 +0100 Subject: [PATCH 5/9] Update documentation to mention assertNoLogs --- Doc/library/unittest.rst | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Doc/library/unittest.rst b/Doc/library/unittest.rst index e2e4f2cdc220aa..0d04ad79b914c2 100644 --- a/Doc/library/unittest.rst +++ b/Doc/library/unittest.rst @@ -950,6 +950,9 @@ Test cases | :meth:`assertLogs(logger, level) | The ``with`` block logs on *logger* | 3.4 | | ` | with minimum *level* | | +---------------------------------------------------------+--------------------------------------+------------+ + | :meth:`assertNoLogs(logger, level) | The ``with`` block does not log on | 3.9 | + | ` | *logger* with minimum *level* | | + +---------------------------------------------------------+--------------------------------------+------------+ .. method:: assertRaises(exception, callable, *args, **kwds) assertRaises(exception, *, msg=None) @@ -1121,6 +1124,25 @@ Test cases .. versionadded:: 3.4 + .. method:: assertNoLogs(logger=None, level=None) + + A context manager to test that no messages are logged on + the *logger* or one of its children, with at least the given + *level*. + + If given, *logger* should be a :class:`logging.Logger` object or a + :class:`str` giving the name of a logger. The default is the root + logger, which will catch all messages. + + If given, *level* should be either a numeric logging level or + its string equivalent (for example either ``"ERROR"`` or + :attr:`logging.ERROR`). The default is :attr:`logging.INFO`. + + Like :meth:`assertLogs`, the object returned by the context manager + is a recording helper. It is expected to have recorded nothing if the + test passes. + + .. versionadded:: 3.9 There are also other methods used to perform more specific checks, such as: From 943b5d3ca889e0f1a4ec2e4f0d177614943d1e24 Mon Sep 17 00:00:00 2001 From: Kit Choi Date: Wed, 27 May 2020 19:34:29 +0100 Subject: [PATCH 6/9] Improve error message --- Lib/unittest/case.py | 2 +- Lib/unittest/test/test_case.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Lib/unittest/case.py b/Lib/unittest/case.py index 6c70cdb44a81c8..f595466e18d1ef 100644 --- a/Lib/unittest/case.py +++ b/Lib/unittest/case.py @@ -377,7 +377,7 @@ class _AssertNoLogsContext(_AssertLogsBaseContext): def check_records(self): if len(self.watcher.records) > 0: self._raiseFailure( - "Logs unexpected found: {!r}".format( + "Unexpected logs found: {!r}".format( self.watcher.output ) ) diff --git a/Lib/unittest/test/test_case.py b/Lib/unittest/test/test_case.py index b55b0be0d3e06a..f43da0c5e20066 100644 --- a/Lib/unittest/test/test_case.py +++ b/Lib/unittest/test/test_case.py @@ -1670,7 +1670,7 @@ def testAssertNoLogsDefault(self): log_foobar.debug("2") self.assertEqual( str(cm.exception), - "Logs unexpected found: ['INFO:foo:1']", + "Unexpected logs found: ['INFO:foo:1']", ) def testAssertNoLogsFailureFoundLogs(self): @@ -1681,7 +1681,7 @@ def testAssertNoLogsFailureFoundLogs(self): self.assertEqual( str(cm.exception), - "Logs unexpected found: ['ERROR:quux:1', 'ERROR:foo:foo']", + "Unexpected logs found: ['ERROR:quux:1', 'ERROR:foo:foo']", ) def testAssertNoLogsPerLogger(self): @@ -1700,7 +1700,7 @@ def testAssertNoLogsFailurePerLogger(self): log_foobar.info("2") self.assertEqual( str(cm.exception), - "Logs unexpected found: ['INFO:foo.bar:2']", + "Unexpected logs found: ['INFO:foo.bar:2']", ) def testAssertNoLogsPerLevel(self): @@ -1718,7 +1718,7 @@ def testAssertNoLogsFailurePerLevel(self): log_quux.debug("1") self.assertEqual( str(cm.exception), - "Logs unexpected found: ['DEBUG:foo:foo', 'DEBUG:quux:1']", + "Unexpected logs found: ['DEBUG:foo:foo', 'DEBUG:quux:1']", ) def testAssertNoLogsUnexpectedException(self): From 23b024b88f404ebffe264466363c28c3127c06e5 Mon Sep 17 00:00:00 2001 From: Kit Choi Date: Sat, 13 Jun 2020 10:06:23 +0100 Subject: [PATCH 7/9] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rémi Lapeyre --- Doc/library/unittest.rst | 4 ++-- .../next/Library/2020-04-23-18-21-19.bpo-39385.MIAyS7.rst | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Doc/library/unittest.rst b/Doc/library/unittest.rst index 0d04ad79b914c2..2f9beb92507a5a 100644 --- a/Doc/library/unittest.rst +++ b/Doc/library/unittest.rst @@ -950,7 +950,7 @@ Test cases | :meth:`assertLogs(logger, level) | The ``with`` block logs on *logger* | 3.4 | | ` | with minimum *level* | | +---------------------------------------------------------+--------------------------------------+------------+ - | :meth:`assertNoLogs(logger, level) | The ``with`` block does not log on | 3.9 | + | :meth:`assertNoLogs(logger, level) | The ``with`` block does not log on | 3.10 | | ` | *logger* with minimum *level* | | +---------------------------------------------------------+--------------------------------------+------------+ @@ -1142,7 +1142,7 @@ Test cases is a recording helper. It is expected to have recorded nothing if the test passes. - .. versionadded:: 3.9 + .. versionadded:: 3.10 There are also other methods used to perform more specific checks, such as: diff --git a/Misc/NEWS.d/next/Library/2020-04-23-18-21-19.bpo-39385.MIAyS7.rst b/Misc/NEWS.d/next/Library/2020-04-23-18-21-19.bpo-39385.MIAyS7.rst index b1fe734c63d8a1..e6c5c0dd4380b1 100644 --- a/Misc/NEWS.d/next/Library/2020-04-23-18-21-19.bpo-39385.MIAyS7.rst +++ b/Misc/NEWS.d/next/Library/2020-04-23-18-21-19.bpo-39385.MIAyS7.rst @@ -1,3 +1,3 @@ A new test assertion context-manager, :func:`unittest.assertNoLogs` will ensure a given block of code emits no log messages using the logging module. -(Contributed by Kit Yan Choi in bpo-39385.) \ No newline at end of file +Contributed by Kit Yan Choi. From e850ebe4a40892b7c93af2f58fbb45c6689a5642 Mon Sep 17 00:00:00 2001 From: Kit Choi Date: Mon, 15 Jun 2020 19:25:05 +0100 Subject: [PATCH 8/9] Combine two subclasses using a flag --- Lib/unittest/_log.py | 43 ++++++++++++++++++------------------------- Lib/unittest/case.py | 6 +++--- 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/Lib/unittest/_log.py b/Lib/unittest/_log.py index fb74aeefffa61c..cf762d59fef4f3 100644 --- a/Lib/unittest/_log.py +++ b/Lib/unittest/_log.py @@ -25,12 +25,12 @@ def emit(self, record): self.watcher.output.append(msg) -class _AssertLogsBaseContext(_BaseTestCaseContext): - """Base class for assertLogs() and assertNoLogs() """ +class _AssertLogsContext(_BaseTestCaseContext): + """A context manager for assertLogs() and assertNoLogs() """ LOGGING_FORMAT = "%(levelname)s:%(name)s:%(message)s" - def __init__(self, test_case, logger_name, level): + def __init__(self, test_case, logger_name, level, no_logs): _BaseTestCaseContext.__init__(self, test_case) self.logger_name = logger_name if level: @@ -38,6 +38,7 @@ def __init__(self, test_case, logger_name, level): else: self.level = logging.INFO self.msg = None + self.no_logs = no_logs def __enter__(self): if isinstance(self.logger_name, logging.Logger): @@ -65,26 +66,18 @@ def __exit__(self, exc_type, exc_value, tb): # let unexpected exceptions pass through return False - self.check_records() - - -class _AssertLogsContext(_AssertLogsBaseContext): - """A context manager used to implement TestCase.assertLogs().""" - - def check_records(self): - if len(self.watcher.records) == 0: - self._raiseFailure( - "no logs of level {} or higher triggered on {}" - .format(logging.getLevelName(self.level), self.logger.name)) - - -class _AssertNoLogsContext(_AssertLogsBaseContext): - """A context manager used to implement TestCase.assertNoLogs().""" - - def check_records(self): - if len(self.watcher.records) > 0: - self._raiseFailure( - "Unexpected logs found: {!r}".format( - self.watcher.output + if self.no_logs: + # assertNoLogs + if len(self.watcher.records) > 0: + self._raiseFailure( + "Unexpected logs found: {!r}".format( + self.watcher.output + ) ) - ) + + else: + # assertLogs + if len(self.watcher.records) == 0: + self._raiseFailure( + "no logs of level {} or higher triggered on {}" + .format(logging.getLevelName(self.level), self.logger.name)) diff --git a/Lib/unittest/case.py b/Lib/unittest/case.py index 6e87e2ec429e5a..116cfc2c98601d 100644 --- a/Lib/unittest/case.py +++ b/Lib/unittest/case.py @@ -787,7 +787,7 @@ def assertLogs(self, logger=None, level=None): """ # Lazy import to avoid importing logging if it is not needed. from ._log import _AssertLogsContext - return _AssertLogsContext(self, logger, level) + return _AssertLogsContext(self, logger, level, no_logs=False) def assertNoLogs(self, logger=None, level=None): """ Fail unless no log messages of level *level* or higher are emitted @@ -796,8 +796,8 @@ def assertNoLogs(self, logger=None, level=None): This method must be used as a context manager, and will yield a recording object expected to have recorded nothing. """ - from ._log import _AssertNoLogsContext - return _AssertNoLogsContext(self, logger, level) + from ._log import _AssertLogsContext + return _AssertLogsContext(self, logger, level, no_logs=True) def _getAssertEqualityFunc(self, first, second): """Get a detailed comparison function for the types of the two args. From 730b2c1b98704a188a1c10e78b79157b87a6e5f0 Mon Sep 17 00:00:00 2001 From: Kit Choi Date: Mon, 15 Jun 2020 19:43:43 +0100 Subject: [PATCH 9/9] assertNoLogs will yield None --- Doc/library/unittest.rst | 5 ++--- Lib/unittest/_log.py | 2 ++ Lib/unittest/case.py | 3 +-- Lib/unittest/test/test_case.py | 5 +++++ 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Doc/library/unittest.rst b/Doc/library/unittest.rst index 5def250c69505d..0dddbd25d991b5 100644 --- a/Doc/library/unittest.rst +++ b/Doc/library/unittest.rst @@ -1138,9 +1138,8 @@ Test cases its string equivalent (for example either ``"ERROR"`` or :attr:`logging.ERROR`). The default is :attr:`logging.INFO`. - Like :meth:`assertLogs`, the object returned by the context manager - is a recording helper. It is expected to have recorded nothing if the - test passes. + Unlike :meth:`assertLogs`, nothing will be returned by the context + manager. .. versionadded:: 3.10 diff --git a/Lib/unittest/_log.py b/Lib/unittest/_log.py index cf762d59fef4f3..961c448a7fb356 100644 --- a/Lib/unittest/_log.py +++ b/Lib/unittest/_log.py @@ -55,6 +55,8 @@ def __enter__(self): logger.handlers = [handler] logger.setLevel(self.level) logger.propagate = False + if self.no_logs: + return return handler.watcher def __exit__(self, exc_type, exc_value, tb): diff --git a/Lib/unittest/case.py b/Lib/unittest/case.py index 116cfc2c98601d..872f12112755e9 100644 --- a/Lib/unittest/case.py +++ b/Lib/unittest/case.py @@ -793,8 +793,7 @@ def assertNoLogs(self, logger=None, level=None): """ Fail unless no log messages of level *level* or higher are emitted on *logger_name* or its children. - This method must be used as a context manager, and will yield - a recording object expected to have recorded nothing. + This method must be used as a context manager. """ from ._log import _AssertLogsContext return _AssertLogsContext(self, logger, level, no_logs=True) diff --git a/Lib/unittest/test/test_case.py b/Lib/unittest/test/test_case.py index 3014aada9d589f..0e416967a30861 100644 --- a/Lib/unittest/test/test_case.py +++ b/Lib/unittest/test/test_case.py @@ -1751,6 +1751,11 @@ def testAssertNoLogsUnexpectedException(self): with self.assertNoLogs(): raise ZeroDivisionError("Unexpected") + def testAssertNoLogsYieldsNone(self): + with self.assertNoLogs() as value: + pass + self.assertIsNone(value) + def testDeprecatedMethodNames(self): """ Test that the deprecated methods raise a DeprecationWarning. See #9424.