From d23acf870c41bc8859dbf85fa2f4d4d28a693c3c Mon Sep 17 00:00:00 2001 From: merlinz01 <158784988+merlinz01@users.noreply.github.com> Date: Sat, 16 Nov 2024 20:23:18 -0500 Subject: [PATCH 1/9] Improve performance and error readability of unittest.TestCase.assertDictEqual The function previously used a simple difflib.ndiff on top of a pprint.pformat of each dict, which resulted in very bad performance on large dicts and unclear assertion error outputs in many cases. This change formats the diffs in a more readable manner by inspecting the differences between the dicts, truncating long keys and values, and justifying values in the various groups of lines. --- Lib/unittest/case.py | 52 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/Lib/unittest/case.py b/Lib/unittest/case.py index 55c79d353539ca..e81bac035cbb33 100644 --- a/Lib/unittest/case.py +++ b/Lib/unittest/case.py @@ -14,7 +14,8 @@ from . import result from .util import (strclass, safe_repr, _count_diff_all_purpose, - _count_diff_hashable, _common_shorten_repr) + _count_diff_hashable, _common_shorten_repr, + _shorten, _MIN_END_LEN, _MAX_LENGTH) __unittest = True @@ -1202,15 +1203,50 @@ def assertIsNot(self, expr1, expr2, msg=None): standardMsg = 'unexpectedly identical: %s' % (safe_repr(expr1),) self.fail(self._formatMessage(msg, standardMsg)) - def assertDictEqual(self, d1, d2, msg=None): - self.assertIsInstance(d1, dict, 'First argument is not a dictionary') - self.assertIsInstance(d2, dict, 'Second argument is not a dictionary') + def assertDictEqual(self, d1: dict, d2: dict, msg: str | None = None): + self.assertIsInstance(d1, dict, "First argument is not a dictionary") + self.assertIsInstance(d2, dict, "Second argument is not a dictionary") if d1 != d2: - standardMsg = '%s != %s' % _common_shorten_repr(d1, d2) - diff = ('\n' + '\n'.join(difflib.ndiff( - pprint.pformat(d1).splitlines(), - pprint.pformat(d2).splitlines()))) + standardMsg = "%s != %s" % _common_shorten_repr(d1, d2) + + d1keys = set(d1.keys()) + d2keys = set(d2.keys()) + d1extrakeys = d1keys - d2keys + d2extrakeys = d2keys - d1keys + commonkeys = d1keys & d2keys + lines = [] + def _value_repr(value): + return _shorten(safe_repr(value), _MAX_LENGTH//2-_MIN_END_LEN, _MIN_END_LEN) + def _justified_values(d, keys, prefix): + items = [(_value_repr(key), _value_repr(d[key])) for key in sorted(keys)] + justify_width = max(len(key) for key, value in items) + justify_width = max(min(justify_width, _MAX_LENGTH - _MIN_END_LEN - 2), 4) + return (" %s %s: %s," % (prefix, key.ljust(justify_width), value) for key, value in items) + if commonkeys: + commonvalues = [] + for key in sorted(commonkeys): + if d1[key] == d2[key]: + commonvalues.append(key) + commonkeys.remove(key) + if commonvalues: + lines.append(" Keys in both dicts with identical values:") + lines.extend(_justified_values(d1, commonvalues, " ")) + if commonkeys: + lines.append(" Keys in both dicts with differing values:") + for key in sorted(commonkeys): + key_repr = _value_repr(key) + lines.append(" - %s: %s," % (key_repr, _value_repr(d1[key]))) + lines.append(" + %s: %s," % (key_repr, _value_repr(d2[key]))) + if d1extrakeys: + lines.append(" Keys in the first dict but not the second:") + lines.extend(_justified_values(d1, d1extrakeys, "-")) + if d2extrakeys: + lines.append(" Keys in the second dict but not the first:") + lines.extend(_justified_values(d2, d2extrakeys, "+")) + + diff = "\n{\n%s\n}" % '\n'.join(lines) + standardMsg = self._truncateMessage(standardMsg, diff) self.fail(self._formatMessage(msg, standardMsg)) From 7fba9dcbf46103825aa976847ce224b34b6bdbc0 Mon Sep 17 00:00:00 2001 From: merlinz01 <158784988+merlinz01@users.noreply.github.com> Date: Sat, 16 Nov 2024 20:58:43 -0500 Subject: [PATCH 2/9] Update tests for new output of unittest.TestCase.assertDictEquals --- Lib/test/test_unittest/test_assertions.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_unittest/test_assertions.py b/Lib/test/test_unittest/test_assertions.py index 1dec947ea76d23..7672305584f49e 100644 --- a/Lib/test/test_unittest/test_assertions.py +++ b/Lib/test/test_unittest/test_assertions.py @@ -267,9 +267,10 @@ def testAssertNotIn(self): def testAssertDictEqual(self): self.assertMessages('assertDictEqual', ({}, {'key': 'value'}), - [r"\+ \{'key': 'value'\}$", "^oops$", - r"\+ \{'key': 'value'\}$", - r"\+ \{'key': 'value'\} : oops$"]) + [r"^\{\} != \{'key': 'value'\}\n\{\n Keys in the second dict but not the first:\n \+ 'key': 'value',\n\}$", + r"^oops$", + r"^\{\} != \{'key': 'value'\}\n\{\n Keys in the second dict but not the first:\n \+ 'key': 'value',\n\}$", + r"^\{\} != \{'key': 'value'\}\n\{\n Keys in the second dict but not the first:\n \+ 'key': 'value',\n\} : oops$"]) def testAssertMultiLineEqual(self): self.assertMessages('assertMultiLineEqual', ("", "foo"), From 002d692a381df13de9b88984c34829109bb1ee8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns=20=F0=9F=87=B5=F0=9F=87=B8?= Date: Sun, 17 Nov 2024 00:07:25 +0000 Subject: [PATCH 3/9] GH-126789: fix some sysconfig data on late site initializations --- Lib/sysconfig/__init__.py | 18 ++++- Lib/test/support/venv.py | 70 +++++++++++++++++ Lib/test/test_sysconfig.py | 75 +++++++++++++++++++ ...-11-13-22-25-57.gh-issue-126789.lKzlc7.rst | 4 + 4 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 Lib/test/support/venv.py create mode 100644 Misc/NEWS.d/next/Library/2024-11-13-22-25-57.gh-issue-126789.lKzlc7.rst diff --git a/Lib/sysconfig/__init__.py b/Lib/sysconfig/__init__.py index 43f9276799b848..ec3b638f00766d 100644 --- a/Lib/sysconfig/__init__.py +++ b/Lib/sysconfig/__init__.py @@ -173,9 +173,7 @@ def joinuser(*args): _PY_VERSION = sys.version.split()[0] _PY_VERSION_SHORT = f'{sys.version_info[0]}.{sys.version_info[1]}' _PY_VERSION_SHORT_NO_DOT = f'{sys.version_info[0]}{sys.version_info[1]}' -_PREFIX = os.path.normpath(sys.prefix) _BASE_PREFIX = os.path.normpath(sys.base_prefix) -_EXEC_PREFIX = os.path.normpath(sys.exec_prefix) _BASE_EXEC_PREFIX = os.path.normpath(sys.base_exec_prefix) # Mutex guarding initialization of _CONFIG_VARS. _CONFIG_VARS_LOCK = threading.RLock() @@ -466,8 +464,10 @@ def _init_config_vars(): # Normalized versions of prefix and exec_prefix are handy to have; # in fact, these are the standard versions used most places in the # Distutils. - _CONFIG_VARS['prefix'] = _PREFIX - _CONFIG_VARS['exec_prefix'] = _EXEC_PREFIX + _PREFIX = os.path.normpath(sys.prefix) + _EXEC_PREFIX = os.path.normpath(sys.exec_prefix) + _CONFIG_VARS['prefix'] = _PREFIX # FIXME: This gets overwriten by _init_posix. + _CONFIG_VARS['exec_prefix'] = _EXEC_PREFIX # FIXME: This gets overwriten by _init_posix. _CONFIG_VARS['py_version'] = _PY_VERSION _CONFIG_VARS['py_version_short'] = _PY_VERSION_SHORT _CONFIG_VARS['py_version_nodot'] = _PY_VERSION_SHORT_NO_DOT @@ -540,6 +540,7 @@ def get_config_vars(*args): With arguments, return a list of values that result from looking up each argument in the configuration variable dictionary. """ + global _CONFIG_VARS_INITIALIZED # Avoid claiming the lock once initialization is complete. if not _CONFIG_VARS_INITIALIZED: @@ -550,6 +551,15 @@ def get_config_vars(*args): # don't re-enter init_config_vars(). if _CONFIG_VARS is None: _init_config_vars() + else: + # If the site module initialization happened after _CONFIG_VARS was + # initialized, a virtual environment might have been activated, resulting in + # variables like sys.prefix changing their value, so we need to re-init the + # config vars (see GH-126789). + if _CONFIG_VARS['base'] != os.path.normpath(sys.prefix): + with _CONFIG_VARS_LOCK: + _CONFIG_VARS_INITIALIZED = False + _init_config_vars() if args: vals = [] diff --git a/Lib/test/support/venv.py b/Lib/test/support/venv.py new file mode 100644 index 00000000000000..78e6a51ec1815e --- /dev/null +++ b/Lib/test/support/venv.py @@ -0,0 +1,70 @@ +import contextlib +import logging +import os +import subprocess +import shlex +import sys +import sysconfig +import tempfile +import venv + + +class VirtualEnvironment: + def __init__(self, prefix, **venv_create_args): + self._logger = logging.getLogger(self.__class__.__name__) + venv.create(prefix, **venv_create_args) + self._prefix = prefix + self._paths = sysconfig.get_paths( + scheme='venv', + vars={'base': self.prefix}, + expand=True, + ) + + @classmethod + @contextlib.contextmanager + def from_tmpdir(cls, *, prefix=None, dir=None, **venv_create_args): + delete = not bool(os.environ.get('PYTHON_TESTS_KEEP_VENV')) + with tempfile.TemporaryDirectory(prefix=prefix, dir=dir, delete=delete) as tmpdir: + yield cls(tmpdir, **venv_create_args) + + @property + def prefix(self): + return self._prefix + + @property + def paths(self): + return self._paths + + @property + def interpreter(self): + return os.path.join(self.paths['scripts'], os.path.basename(sys.executable)) + + def _format_output(self, name, data, indent='\t'): + if not data: + return indent + f'{name}: (none)' + if len(data.splitlines()) == 1: + return indent + f'{name}: {data}' + else: + prefixed_lines = '\n'.join(indent + '> ' + line for line in data.splitlines()) + return indent + f'{name}:\n' + prefixed_lines + + def run(self, *args, **subprocess_args): + if subprocess_args.get('shell'): + raise ValueError('Running the subprocess in shell mode is not supported.') + default_args = { + 'capture_output': True, + 'check': True, + } + try: + result = subprocess.run([self.interpreter, *args], **default_args | subprocess_args) + except subprocess.CalledProcessError as e: + if e.returncode != 0: + self._logger.error( + f'Interpreter returned non-zero exit status {e.returncode}.\n' + + self._format_output('COMMAND', shlex.join(e.cmd)) + '\n' + + self._format_output('STDOUT', e.stdout.decode()) + '\n' + + self._format_output('STDERR', e.stderr.decode()) + '\n' + ) + raise + else: + return result diff --git a/Lib/test/test_sysconfig.py b/Lib/test/test_sysconfig.py index 1ade49281b4e26..4f9541b6a0b726 100644 --- a/Lib/test/test_sysconfig.py +++ b/Lib/test/test_sysconfig.py @@ -5,6 +5,8 @@ import os import subprocess import shutil +import json +import textwrap from copy import copy from test.support import ( @@ -17,6 +19,7 @@ from test.support.import_helper import import_module from test.support.os_helper import (TESTFN, unlink, skip_unless_symlink, change_cwd) +from test.support.venv import VirtualEnvironment import sysconfig from sysconfig import (get_paths, get_platform, get_config_vars, @@ -101,6 +104,12 @@ def _cleanup_testfn(self): elif os.path.isdir(path): shutil.rmtree(path) + def venv(self, **venv_create_args): + return VirtualEnvironment.from_tmpdir( + prefix=f'{self.id()}-venv-', + **venv_create_args, + ) + def test_get_path_names(self): self.assertEqual(get_path_names(), sysconfig._SCHEME_KEYS) @@ -582,6 +591,72 @@ def test_osx_ext_suffix(self): suffix = sysconfig.get_config_var('EXT_SUFFIX') self.assertTrue(suffix.endswith('-darwin.so'), suffix) + @unittest.skipIf(sys.platform == 'wasi', 'venv is unsupported on WASI') + def test_config_vars_depend_on_site_initialization(self): + script = textwrap.dedent(""" + import sysconfig + + config_vars = sysconfig.get_config_vars() + + import json + print(json.dumps(config_vars, indent=2)) + """) + + with self.venv() as venv: + site_config_vars = json.loads(venv.run('-c', script).stdout) + no_site_config_vars = json.loads(venv.run('-S', '-c', script).stdout) + + self.assertNotEqual(site_config_vars, no_site_config_vars) + # With the site initialization, the virtual environment should be enabled. + self.assertEqual(site_config_vars['base'], venv.prefix) + self.assertEqual(site_config_vars['platbase'], venv.prefix) + #self.assertEqual(site_config_vars['prefix'], venv.prefix) # # FIXME: prefix gets overwriten by _init_posix + # Without the site initialization, the virtual environment should be disabled. + self.assertEqual(no_site_config_vars['base'], site_config_vars['installed_base']) + self.assertEqual(no_site_config_vars['platbase'], site_config_vars['installed_platbase']) + + @unittest.skipIf(sys.platform == 'wasi', 'venv is unsupported on WASI') + def test_config_vars_recalculation_after_site_initialization(self): + script = textwrap.dedent(""" + import sysconfig + + before = sysconfig.get_config_vars() + + import site + site.main() + + after = sysconfig.get_config_vars() + + import json + print(json.dumps({'before': before, 'after': after}, indent=2)) + """) + + with self.venv() as venv: + config_vars = json.loads(venv.run('-S', '-c', script).stdout) + + self.assertNotEqual(config_vars['before'], config_vars['after']) + self.assertEqual(config_vars['after']['base'], venv.prefix) + #self.assertEqual(config_vars['after']['prefix'], venv.prefix) # FIXME: prefix gets overwriten by _init_posix + #self.assertEqual(config_vars['after']['exec_prefix'], venv.prefix) # FIXME: exec_prefix gets overwriten by _init_posix + + @unittest.skipIf(sys.platform == 'wasi', 'venv is unsupported on WASI') + def test_paths_depend_on_site_initialization(self): + script = textwrap.dedent(""" + import sysconfig + + paths = sysconfig.get_paths() + + import json + print(json.dumps(paths, indent=2)) + """) + + with self.venv() as venv: + site_paths = json.loads(venv.run('-c', script).stdout) + no_site_paths = json.loads(venv.run('-S', '-c', script).stdout) + + self.assertNotEqual(site_paths, no_site_paths) + + class MakefileTests(unittest.TestCase): @unittest.skipIf(sys.platform.startswith('win'), diff --git a/Misc/NEWS.d/next/Library/2024-11-13-22-25-57.gh-issue-126789.lKzlc7.rst b/Misc/NEWS.d/next/Library/2024-11-13-22-25-57.gh-issue-126789.lKzlc7.rst new file mode 100644 index 00000000000000..09d4d2e5ab9037 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-11-13-22-25-57.gh-issue-126789.lKzlc7.rst @@ -0,0 +1,4 @@ +Fixed the values of :py:func:`sysconfig.get_config_vars`, +:py:func:`sysconfig.get_paths`, and their siblings when the :py:mod:`site` +initialization happens after :py:mod:`sysconfig` has built a cache for +:py:func:`sysconfig.get_config_vars`. From db4104d6957140f4a7fb6eeb7a7d4330d6cd412a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns=20=F0=9F=87=B5=F0=9F=87=B8?= Date: Sun, 17 Nov 2024 01:56:01 +0000 Subject: [PATCH 4/9] GH-126920: fix Makefile overwriting sysconfig.get_config_vars --- Lib/sysconfig/__init__.py | 3 +- Lib/test/test_sysconfig.py | 32 +++++++++++++++++++ ...-11-17-01-14-59.gh-issue-126920.s8-f_L.rst | 5 +++ 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2024-11-17-01-14-59.gh-issue-126920.s8-f_L.rst diff --git a/Lib/sysconfig/__init__.py b/Lib/sysconfig/__init__.py index ec3b638f00766d..67a071963d8c7d 100644 --- a/Lib/sysconfig/__init__.py +++ b/Lib/sysconfig/__init__.py @@ -353,7 +353,8 @@ def _init_posix(vars): else: _temp = __import__(name, globals(), locals(), ['build_time_vars'], 0) build_time_vars = _temp.build_time_vars - vars.update(build_time_vars) + # GH-126920: Make sure we don't overwrite any of the keys already set + vars.update(build_time_vars | vars) def _init_non_posix(vars): """Initialize the module as appropriate for NT""" diff --git a/Lib/test/test_sysconfig.py b/Lib/test/test_sysconfig.py index 4f9541b6a0b726..c7acfe728bb664 100644 --- a/Lib/test/test_sysconfig.py +++ b/Lib/test/test_sysconfig.py @@ -656,6 +656,38 @@ def test_paths_depend_on_site_initialization(self): self.assertNotEqual(site_paths, no_site_paths) + @unittest.skipIf(sys.platform == 'wasi', 'venv is unsupported on WASI') + def test_makefile_overwrites_config_vars(self): + script = textwrap.dedent(""" + import sys, sysconfig + + data = { + 'prefix': sys.prefix, + 'exec_prefix': sys.exec_prefix, + 'base_prefix': sys.base_prefix, + 'base_exec_prefix': sys.base_exec_prefix, + 'config_vars': sysconfig.get_config_vars(), + } + + import json + print(json.dumps(data, indent=2)) + """) + + # We need to run the test inside a virtual environment so that + # sys.prefix/sys.exec_prefix have a different value from the + # prefix/exec_prefix Makefile variables. + with self.venv() as venv: + data = json.loads(venv.run('-c', script).stdout) + + # We expect sysconfig.get_config_vars to correctly reflect sys.prefix/sys.exec_prefix + self.assertEqual(data['prefix'], data['config_vars']['prefix']) + self.assertEqual(data['exec_prefix'], data['config_vars']['exec_prefix']) + # As a sanity check, just make sure sys.prefix/sys.exec_prefix really + # are different from the Makefile values. + # sys.base_prefix/sys.base_exec_prefix should reflect the value of the + # prefix/exec_prefix Makefile variables, so we use them in the comparison. + self.assertNotEqual(data['prefix'], data['base_prefix']) + self.assertNotEqual(data['exec_prefix'], data['base_exec_prefix']) class MakefileTests(unittest.TestCase): diff --git a/Misc/NEWS.d/next/Library/2024-11-17-01-14-59.gh-issue-126920.s8-f_L.rst b/Misc/NEWS.d/next/Library/2024-11-17-01-14-59.gh-issue-126920.s8-f_L.rst new file mode 100644 index 00000000000000..6966aec380fae9 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-11-17-01-14-59.gh-issue-126920.s8-f_L.rst @@ -0,0 +1,5 @@ +Fix the ``prefix`` and ``exec_prefix`` keys from +:py:func:`sysconfig.get_config_vars` incorrectly having the same value as +:py:const:`sys.base_prefix` and :py:const:`sys.base_exec_prefix`, +respectively, inside virtual environments. They now accurately reflect +:py:const:`sys.prefix` and :py:const:`sys.exec_prefix`. From 0c320e5e4205ff8c4643a11c1273a46d905bc741 Mon Sep 17 00:00:00 2001 From: merlinz01 <158784988+merlinz01@users.noreply.github.com> Date: Sat, 16 Nov 2024 21:04:45 -0500 Subject: [PATCH 5/9] Add NEWS.d entry for assertDictEqual changes --- .../next/Library/2024-11-16-21-04-16.gh-issue-99151.74rlUp.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2024-11-16-21-04-16.gh-issue-99151.74rlUp.rst diff --git a/Misc/NEWS.d/next/Library/2024-11-16-21-04-16.gh-issue-99151.74rlUp.rst b/Misc/NEWS.d/next/Library/2024-11-16-21-04-16.gh-issue-99151.74rlUp.rst new file mode 100644 index 00000000000000..efeee479a1d0ab --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-11-16-21-04-16.gh-issue-99151.74rlUp.rst @@ -0,0 +1,2 @@ +Improve performance and error readability of +unittest.TestCase.assertDictEqual From 80d2a776ea3cf30bc53bfe1600b0139a38f649b6 Mon Sep 17 00:00:00 2001 From: Merlin <158784988+merlinz01@users.noreply.github.com> Date: Mon, 18 Nov 2024 06:50:26 -0500 Subject: [PATCH 6/9] Update news entry Co-authored-by: Kirill Podoprigora --- .../next/Library/2024-11-16-21-04-16.gh-issue-99151.74rlUp.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2024-11-16-21-04-16.gh-issue-99151.74rlUp.rst b/Misc/NEWS.d/next/Library/2024-11-16-21-04-16.gh-issue-99151.74rlUp.rst index efeee479a1d0ab..a46986497cf7a5 100644 --- a/Misc/NEWS.d/next/Library/2024-11-16-21-04-16.gh-issue-99151.74rlUp.rst +++ b/Misc/NEWS.d/next/Library/2024-11-16-21-04-16.gh-issue-99151.74rlUp.rst @@ -1,2 +1,2 @@ Improve performance and error readability of -unittest.TestCase.assertDictEqual +:meth:`~unittest.TestCase.assertDictEqual`. From fcfdd9220f57b99faee01f603c1ea7eb3f4779ef Mon Sep 17 00:00:00 2001 From: merlinz01 <158784988+merlinz01@users.noreply.github.com> Date: Mon, 18 Nov 2024 14:01:45 -0500 Subject: [PATCH 7/9] Improve performance and error readability of unittest.TestCase.assertDictEqual --- Lib/test/test_unittest/test_assertions.py | 6 +++--- Lib/unittest/case.py | 7 +++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_unittest/test_assertions.py b/Lib/test/test_unittest/test_assertions.py index 7672305584f49e..50f5c6c12e2ae6 100644 --- a/Lib/test/test_unittest/test_assertions.py +++ b/Lib/test/test_unittest/test_assertions.py @@ -267,10 +267,10 @@ def testAssertNotIn(self): def testAssertDictEqual(self): self.assertMessages('assertDictEqual', ({}, {'key': 'value'}), - [r"^\{\} != \{'key': 'value'\}\n\{\n Keys in the second dict but not the first:\n \+ 'key': 'value',\n\}$", + [r"^\{\} != \{'key': 'value'\}\n\{\nKeys in the second dict but not the first:\n \+ 'key': 'value',\n\}$", r"^oops$", - r"^\{\} != \{'key': 'value'\}\n\{\n Keys in the second dict but not the first:\n \+ 'key': 'value',\n\}$", - r"^\{\} != \{'key': 'value'\}\n\{\n Keys in the second dict but not the first:\n \+ 'key': 'value',\n\} : oops$"]) + r"^\{\} != \{'key': 'value'\}\n\{\nKeys in the second dict but not the first:\n \+ 'key': 'value',\n\}$", + r"^\{\} != \{'key': 'value'\}\n\{\nKeys in the second dict but not the first:\n \+ 'key': 'value',\n\} : oops$"]) def testAssertMultiLineEqual(self): self.assertMessages('assertMultiLineEqual', ("", "foo"), diff --git a/Lib/unittest/case.py b/Lib/unittest/case.py index e81bac035cbb33..bf82e3f1b929bf 100644 --- a/Lib/unittest/case.py +++ b/Lib/unittest/case.py @@ -1230,19 +1230,18 @@ def _justified_values(d, keys, prefix): commonvalues.append(key) commonkeys.remove(key) if commonvalues: - lines.append(" Keys in both dicts with identical values:") lines.extend(_justified_values(d1, commonvalues, " ")) if commonkeys: - lines.append(" Keys in both dicts with differing values:") + lines.append("Keys in both dicts with differing values:") for key in sorted(commonkeys): key_repr = _value_repr(key) lines.append(" - %s: %s," % (key_repr, _value_repr(d1[key]))) lines.append(" + %s: %s," % (key_repr, _value_repr(d2[key]))) if d1extrakeys: - lines.append(" Keys in the first dict but not the second:") + lines.append("Keys in the first dict but not the second:") lines.extend(_justified_values(d1, d1extrakeys, "-")) if d2extrakeys: - lines.append(" Keys in the second dict but not the first:") + lines.append("Keys in the second dict but not the first:") lines.extend(_justified_values(d2, d2extrakeys, "+")) diff = "\n{\n%s\n}" % '\n'.join(lines) From 88293fed1445710aede0518a7cbc8a1f0f3b629c Mon Sep 17 00:00:00 2001 From: merlinz01 <158784988+merlinz01@users.noreply.github.com> Date: Tue, 19 Nov 2024 12:19:32 -0500 Subject: [PATCH 8/9] Remove type hints --- Lib/unittest/case.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/unittest/case.py b/Lib/unittest/case.py index bf82e3f1b929bf..6b708090d1166f 100644 --- a/Lib/unittest/case.py +++ b/Lib/unittest/case.py @@ -1203,7 +1203,7 @@ def assertIsNot(self, expr1, expr2, msg=None): standardMsg = 'unexpectedly identical: %s' % (safe_repr(expr1),) self.fail(self._formatMessage(msg, standardMsg)) - def assertDictEqual(self, d1: dict, d2: dict, msg: str | None = None): + def assertDictEqual(self, d1, d2, msg=None): self.assertIsInstance(d1, dict, "First argument is not a dictionary") self.assertIsInstance(d2, dict, "Second argument is not a dictionary") From 50e413cb9dcdb20399edac2258adbcde0fcf3ac2 Mon Sep 17 00:00:00 2001 From: merlinz01 <158784988+merlinz01@users.noreply.github.com> Date: Tue, 19 Nov 2024 12:42:53 -0500 Subject: [PATCH 9/9] Add additional tests for new output format --- Lib/test/test_unittest/test_assertions.py | 43 +++++++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_unittest/test_assertions.py b/Lib/test/test_unittest/test_assertions.py index 50f5c6c12e2ae6..6b071ef0ed0452 100644 --- a/Lib/test/test_unittest/test_assertions.py +++ b/Lib/test/test_unittest/test_assertions.py @@ -267,10 +267,47 @@ def testAssertNotIn(self): def testAssertDictEqual(self): self.assertMessages('assertDictEqual', ({}, {'key': 'value'}), - [r"^\{\} != \{'key': 'value'\}\n\{\nKeys in the second dict but not the first:\n \+ 'key': 'value',\n\}$", + [r"^\{\} != \{'key': 'value'\}\n\{\nKeys in the second " + r"dict but not the first:\n \+ 'key': 'value',\n\}$", r"^oops$", - r"^\{\} != \{'key': 'value'\}\n\{\nKeys in the second dict but not the first:\n \+ 'key': 'value',\n\}$", - r"^\{\} != \{'key': 'value'\}\n\{\nKeys in the second dict but not the first:\n \+ 'key': 'value',\n\} : oops$"]) + r"^\{\} != \{'key': 'value'\}\n\{\nKeys in the second " + r"dict but not the first:\n \+ 'key': 'value',\n\}$", + r"^\{\} != \{'key': 'value'\}\n\{\nKeys in the second " + r"dict but not the first:\n \+ 'key': 'value',\n\} : oops$"]) + self.assertDictEqual({}, {}) + self.assertDictEqual({'key': 'value'}, {'key': 'value'}) + self.assertRaisesRegex( + AssertionError, + r"^\{\} != \{'key': 'value'\}\n{\nKeys in the second " + r"dict but not the first:\n \+ 'key': 'value',\n}$", + lambda: self.assertDictEqual({}, {'key': 'value'})) + self.assertRaisesRegex( + AssertionError, + r"^\{'key': 'value'\} != \{\}\n{\nKeys in the first " + r"dict but not the second:\n - 'key': 'value',\n}$", + lambda: self.assertDictEqual({'key': 'value'}, {})) + self.assertRaisesRegex( + AssertionError, + r"^\{'key': 'value'\} != \{'key': 'othervalue'\}\n{\nKeys in both dicts " + r"with differing values:\n - 'key': 'value',\n \+ 'key': 'othervalue',\n}$", + lambda: self.assertDictEqual({'key': 'value'}, {'key': 'othervalue'})) + self.assertRaisesRegex( + AssertionError, + r"^\{'same': 'same', 'samekey': 'onevalue', 'otherkey': 'othervalue'\} " + r"!= \{'same': 'same', 'samekey': 'twovalue', 'somekey': 'somevalue'\}\n{\n" + r" 'same': 'same',\n" + r"Keys in both dicts with differing values:\n" + r" - 'samekey': 'onevalue',\n" + r" \+ 'samekey': 'twovalue',\n" + r"Keys in the first dict but not the second:\n" + r" - 'otherkey': 'othervalue',\n" + r"Keys in the second dict but not the first:\n" + r" \+ 'somekey': 'somevalue',\n" + r"\}$", + lambda: self.assertDictEqual( + {'same': 'same', 'samekey': 'onevalue', 'otherkey': 'othervalue'}, + {'same': 'same', 'samekey': 'twovalue', 'somekey': 'somevalue'})) + def testAssertMultiLineEqual(self): self.assertMessages('assertMultiLineEqual', ("", "foo"),