diff --git a/src/ptpython/printer.py b/src/ptpython/printer.py index a3578de..5b5ae45 100644 --- a/src/ptpython/printer.py +++ b/src/ptpython/printer.py @@ -152,8 +152,19 @@ def _format_result_output( formatted_result_repr = to_formatted_text( getattr(result, "__pt_repr__")() ) - yield from formatted_result_repr - return + # Only trust the result if every fragment is a well-formed + # ``(style, text)`` tuple. A `unittest.mock.MagicMock` (and + # similar auto-attribute objects) fabricates a `__pt_repr__` + # that resolves to more mocks, so `to_formatted_text` can yield + # malformed fragments such as an empty tuple, which would later + # crash `split_lines` with a "not enough values to unpack" + # `ValueError`. Fall back to the normal `repr()` path instead. + if all( + isinstance(fragment, tuple) and len(fragment) >= 2 + for fragment in formatted_result_repr + ): + yield from formatted_result_repr + return except (GeneratorExit, KeyboardInterrupt): raise # Don't catch here. except: diff --git a/tests/test_printer.py b/tests/test_printer.py new file mode 100644 index 0000000..358e3f2 --- /dev/null +++ b/tests/test_printer.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +from prompt_toolkit.formatted_text.utils import split_lines + +from ptpython.printer import OutputPrinter + + +def test_format_result_output_handles_mock_with_fabricated_pt_repr() -> None: + """A ``unittest.mock.MagicMock`` fabricates any attribute, including + ``__pt_repr__``, which resolves to more mocks. ``to_formatted_text`` then + yields malformed fragments (e.g. an empty tuple) that used to crash + ``split_lines`` with ``ValueError: not enough values to unpack``. The + printer must fall back to the normal ``repr()`` path instead. See #610. + """ + mock = MagicMock() + mock.get() + call = mock.method_calls[0] + + printer = OutputPrinter.__new__(OutputPrinter) + fragments = list( + printer._format_result_output( + call, + reformat=False, + highlight=True, + line_length=80, + paginate=False, + ) + ) + + # Every fragment must be a well-formed (style, text) tuple, so splitting + # into lines does not raise. + for fragment in fragments: + assert isinstance(fragment, tuple) + assert len(fragment) >= 2 + list(split_lines(fragments)) + + # The fallback renders the object's ``repr`` rather than the mock's + # fabricated formatted text. + rendered = "".join(text for _, text, *_ in fragments) + assert "call" in rendered