diff --git a/Doc/c-api/object.rst b/Doc/c-api/object.rst index 56d2c76ab5c9ee..2066dbb5762158 100644 --- a/Doc/c-api/object.rst +++ b/Doc/c-api/object.rst @@ -397,6 +397,24 @@ Object Protocol This function now includes a debug assertion to help ensure that it does not silently discard an active exception. +.. c:function:: PyObject* PyObject_Pretty(PyObject *o, PyObject *printer) + + Pretty print *o* with *printer*, and return the result as a string. This + is the equivalent of the Python expression ``f'{o!p:{printer}}'``, or + ``f'{o!p}'`` when *printer* is ``NULL``. + + *printer* is called with *o*, and must return an iterable of strings, one + for each line of output. The lines are joined with ``'\n'``, with no + trailing newline. If *printer* is ``NULL``, a default printer is used. It + calls *o*'s ``__pprint__()`` method if there is one, and interprets the + description that it returns. Otherwise it uses :c:func:`PyObject_Repr`. + + Raise :exc:`TypeError` if *printer* returns a :class:`str` rather than an + iterable of lines, or if any line is not a :class:`str`. + + .. versionadded:: next + + .. c:function:: PyObject* PyObject_Bytes(PyObject *o) .. index:: pair: built-in function; bytes diff --git a/Doc/data/refcounts.dat b/Doc/data/refcounts.dat index c19a8a3083a430..5a1a008c08ddab 100644 --- a/Doc/data/refcounts.dat +++ b/Doc/data/refcounts.dat @@ -1916,6 +1916,10 @@ PyObject_Size:PyObject*:o:0: PyObject_Str:PyObject*::+1: PyObject_Str:PyObject*:o:0: +PyObject_Pretty:PyObject*::+1: +PyObject_Pretty:PyObject*:o:0: +PyObject_Pretty:PyObject*:printer:0: + Py_TYPE:PyObject*::0: Py_TYPE:PyObject*:ob:0: diff --git a/Doc/data/stable_abi.dat b/Doc/data/stable_abi.dat index 3a6832c1ff72e1..11d4b9a1225455 100644 --- a/Doc/data/stable_abi.dat +++ b/Doc/data/stable_abi.dat @@ -629,6 +629,7 @@ function,PyObject_IsTrue,3.2,, function,PyObject_Length,3.2,, function,PyObject_Malloc,3.2,, function,PyObject_Not,3.2,, +function,PyObject_Pretty,3.16,, function,PyObject_Realloc,3.2,, function,PyObject_Repr,3.2,, function,PyObject_RichCompare,3.2,, diff --git a/Include/internal/pycore_object.h b/Include/internal/pycore_object.h index 2d0a39361b86e7..f722a4cc16a364 100644 --- a/Include/internal/pycore_object.h +++ b/Include/internal/pycore_object.h @@ -44,8 +44,6 @@ extern "C" { assert(_PyObject_CheckConsistency(obj, 1)); */ extern int _PyObject_CheckConsistency(PyObject *op, int check_content); -// Export for shared _testinternalcapi extension -PyAPI_FUNC(PyObject *) _PyObject_Pretty(PyObject *obj, PyObject *printer); extern PyObject *_PyObject_CallPprintHelper(PyObject *obj, const char *name); #define _PyObject_PPRINT_METHOD(func, helper) \ static PyObject *func(PyObject *self, PyObject *Py_UNUSED(ignored)) \ diff --git a/Include/object.h b/Include/object.h index 549b91ebecf4f2..4c96c9ea655fc1 100644 --- a/Include/object.h +++ b/Include/object.h @@ -394,6 +394,7 @@ PyAPI_FUNC(void) PyType_Modified(PyTypeObject *); /* Generic operations on objects */ PyAPI_FUNC(PyObject *) PyObject_Repr(PyObject *); PyAPI_FUNC(PyObject *) PyObject_Str(PyObject *); +PyAPI_FUNC(PyObject *) PyObject_Pretty(PyObject *, PyObject *); PyAPI_FUNC(PyObject *) PyObject_ASCII(PyObject *); PyAPI_FUNC(PyObject *) PyObject_Bytes(PyObject *); PyAPI_FUNC(PyObject *) PyObject_RichCompare(PyObject *, PyObject *, int); diff --git a/Lib/_pprint.py b/Lib/_pprint.py index 1f3d63131a68d3..aa132dbd1103da 100644 --- a/Lib/_pprint.py +++ b/Lib/_pprint.py @@ -15,7 +15,7 @@ import sys import types as _types -from collections.abc import Mapping, Set +from collections.abc import Iterable, Mapping, Set from dataclasses import dataclass, field from itertools import chain from string.templatelib import Interpolation as _Interpolation @@ -30,8 +30,11 @@ KWARGS = sentinel("KWARGS", repr="pprint.KWARGS") ITEMS = sentinel("ITEMS", repr="pprint.ITEMS") DELEGATE = sentinel("DELEGATE", repr="pprint.DELEGATE") +LEAF = sentinel("LEAF", repr="pprint.LEAF") _MISSING = sentinel("_MISSING") +type DescriptionType = Iterable[object] + ######################################################################## # Object descriptions and structural documents. Nothing in this section @@ -56,6 +59,7 @@ class Layout: single_close: str | None = None recursion: str = "..." sortable: bool = False + hug: bool = False @dataclass(frozen=True) @@ -89,6 +93,13 @@ class BreakableBytes: value: bytes +@dataclass(frozen=True) +class FormattableNumber: + """A number that printers may format according to their settings.""" + + value: object + + class _ComparisonError: """Stand in for a keyword value whose comparison to its default failed.""" @@ -126,13 +137,13 @@ def decompose(obj): """Read one level of an object's pretty-print description stream.""" method = _find_pprint(type(obj)) if method is None: - return None, () + return LEAF, None stream = iter(method(obj)) first = next(stream, _MISSING) if first is DELEGATE: value = next(stream, _MISSING) - return (DELEGATE, value) if value is not _MISSING else (None, ()) + return (DELEGATE, value) if value is not _MISSING else (LEAF, None) if isinstance(first, Layout): layout = first @@ -183,6 +194,7 @@ def decompose(obj): single_close=close if layout.single_close is None else layout.single_close, recursion=layout.recursion, sortable=layout.sortable, + hug=layout.hug, ) return filled, items @@ -202,6 +214,11 @@ class _BreakableBytesDocument: value: bytes +@dataclass(frozen=True) +class _FormattableNumberDocument: + value: object + + @dataclass(frozen=True) class _LazyDocument: value: object @@ -249,9 +266,11 @@ def _build_document(obj, ancestors=()): return _BreakableStringDocument(value) case BreakableBytes(value=value): return _BreakableBytesDocument(value) + case FormattableNumber(value=value): + return _FormattableNumberDocument(value) header, items = decompose(obj) - if header is None: + if header is LEAF: return _ValueDocument(obj) if header is DELEGATE: if id(obj) in ancestor_ids: @@ -335,6 +354,20 @@ class _KeyValue: hang: int | None +@dataclass(frozen=True) +class _Breakable: + """A str or bytes literal that the renderer may split across lines. + + The split depends on the column where the literal starts, which is only + known while rendering. + """ + + value: object + top: bool + expand: bool + indent: int + + _SOFTLINE = _Line(_EMPTY) _COLON_SPACE = _Text(": ") @@ -367,6 +400,7 @@ class _FormatOptions: compact: bool sort_dicts: bool expand: Expand + number_format: str | None class Printer: @@ -379,11 +413,12 @@ def __init__( depth=None, *, compact=False, - sort_dicts=True, expand=Expand.FIT, + sort_dicts=True, + number_format=None, ): self._options = _FormatOptions( - indent, width, depth, compact, sort_dicts, expand + indent, width, depth, compact, sort_dicts, expand, number_format ) def __call__(self, obj): @@ -394,74 +429,102 @@ def __call__(self, obj): yield from _render_lines(layout_document, self._options.width) -def _literal_parts(parts, indent): - docs = [_Text(part) for part in parts] - if len(docs) == 1: - return docs[0] - body = _join(_Line(), docs) - return _Group( - _Concat( - ( - _Text("("), - _Nest(indent, _Concat((_SOFTLINE, body))), - _SOFTLINE, - _Text(")"), - ) - ) - ) - - -def _string_document(obj, level, width, indent): - if not obj: - return _Text(repr(obj)) +def _wrap_string(text, max_width, allowance): + """Split a string's repr into chunks, as PrettyPrinter does.""" import re - width = max(1, width - level - indent) chunks = [] - for line in obj.splitlines(True): - if len(repr(line)) <= width: - chunks.append(repr(line)) + lines = text.splitlines(True) + max_width1 = max_width + for i, line in enumerate(lines): + rep = repr(line) + if i == len(lines) - 1: + max_width1 -= allowance + if len(rep) <= max_width1: + chunks.append(rep) continue - pieces = re.findall(r"\S*\s*", line) - if pieces and not pieces[-1]: - pieces.pop() - current = "" - for part in pieces: + # A list of alternating (non-space, space) strings, ending with an + # empty string. + parts = re.findall(r"\S*\s*", line) + parts.pop() + max_width2 = max_width + current = _EMPTY + for j, part in enumerate(parts): candidate = current + part - if current and len(repr(candidate)) > width: - chunks.append(repr(current)) + if j == len(parts) - 1 and i == len(lines) - 1: + max_width2 -= allowance + if len(repr(candidate)) > max_width2: + if current: + chunks.append(repr(current)) current = part else: current = candidate if current: chunks.append(repr(current)) - if len(chunks) <= 1: - return _Text(repr(obj)) - return _literal_parts(chunks, indent) + return chunks -def _bytes_document(obj, level, width, indent): - if len(obj) <= 4: - return _Text(repr(obj)) - width = max(1, width - level - indent) +def _wrap_bytes(data, max_width, allowance): + """Split a bytes value's repr into chunks, as PrettyPrinter does.""" chunks = [] current = b"" - for index in range(0, len(obj), 4): - part = obj[index:index + 4] + last = len(data) // 4 * 4 + for i in range(0, len(data), 4): + part = data[i:i + 4] candidate = current + part - if len(repr(candidate)) > width and current: - chunks.append(repr(current)) + if i == last: + max_width -= allowance + if len(repr(candidate)) > max_width: + if current: + chunks.append(repr(current)) current = part else: current = candidate if current: chunks.append(repr(current)) - if len(chunks) == 1: - doc = _Text(repr(obj)) - if level == 0: - return _Concat((_Text("("), doc, _Text(")"))) - return doc - return _literal_parts(chunks, indent) + return chunks + + +def _break_literal(node, column, width, allowance): + """Return the output lines for a literal that starts at *column*. + + Every line after the first includes its leading spaces. + """ + rep = repr(node.value) + if column + len(rep) + allowance <= width or len(node.value) == 0: + return [rep] + if node.top: + # The literal is the whole object, so it is parenthesized. + indent = column + (node.indent if node.expand else 1) + allowance += 1 + else: + indent = column + if isinstance(node.value, bytes): + chunks = _wrap_bytes(node.value, width - indent, allowance) + else: + chunks = _wrap_string(node.value, width - indent, allowance) + if len(chunks) <= 1: + return [rep] + + pad = _SPACE * indent + if not node.top: + return [chunks[0], *(pad + chunk for chunk in chunks[1:])] + if node.expand: + return ["(", *(pad + chunk for chunk in chunks), _SPACE * column + ")"] + return [ + "(" + chunks[0], + *(pad + chunk for chunk in chunks[1:-1]), + pad + chunks[-1] + ")", + ] + + +def _format_number(value, number_format): + if number_format is None: + return repr(value) + try: + return format(value, number_format) + except Exception: + return repr(value) def _layout_document(document, level, options): @@ -473,10 +536,15 @@ def _layout_document(document, level, options): ) case _TextDocument(text=text): return _Text(text) - case _BreakableStringDocument(value=value): - return _string_document(value, level, options.width, options.indent) - case _BreakableBytesDocument(value=value): - return _bytes_document(value, level, options.width, options.indent) + case _BreakableStringDocument(value=value) | _BreakableBytesDocument(value=value): + return _Breakable( + value, + level == 0, + options.expand is not Expand.NEVER, + options.indent, + ) + case _FormattableNumberDocument(value=value): + return _Text(_format_number(value, options.number_format)) case _ValueDocument(value=value): return _Text(repr(value)) case _ContainerDocument(layout=header, items=items): @@ -487,7 +555,13 @@ def _layout_document(document, level, options): if header.sortable and options.sort_dicts: items = _sorted_documents(items, level, options) + hugged = None + if header.hug and options.expand is not Expand.NEVER: + items = tuple(_resolve_item(item) for item in items) + hugged = _hugged_index(items) parts = [_item_document(item, level + 1, options) for item in items] + if hugged is not None: + return _hugged_document(header, parts) positional = not any( isinstance(item, (_EntryDocument, _KeywordDocument)) for item in items ) @@ -504,6 +578,8 @@ def _document_source(document): return value case _BreakableBytesDocument(value=value): return value + case _FormattableNumberDocument(value=value): + return value case _ContainerDocument(source=value): return value case _LazyDocument(value=value): @@ -562,6 +638,44 @@ def _item_document(item, level, options): return _layout_document(item, level, options) +def _resolve_item(item): + """Build the documents of an item's lazy parts.""" + match item: + case _LazyDocument(value=value, ancestors=ancestors): + return _build_document(value, ancestors) + case _KeywordDocument(value=_LazyDocument(value=value, ancestors=ancestors)): + return _KeywordDocument( + item.name, _build_document(value, ancestors), item.source + ) + case _: + return item + + +def _hugged_index(items): + """Return the index of the only item that is a non-empty container, or None.""" + found = None + for index, item in enumerate(items): + document = item.value if isinstance(item, _KeywordDocument) else item + if isinstance(document, _ContainerDocument) and len(document.items) > 0: + if found is not None: + return None + found = index + return found + + +def _hugged_document(header, parts): + """Keep every item on the opening and closing lines. + + Only the one container among the items breaks across lines, so its + brackets hug the object's own, as in ``defaultdict(, {``. + """ + close = header.single_close if len(parts) == 1 else header.close + separator = _Text(header.sep + _SPACE) + return _Group( + _Concat((_Text(header.open), _join(separator, parts), _Text(close))) + ) + + def _container_document(header, parts, positional, level, options): close = header.single_close if len(parts) == 1 else header.close separator = _Text(header.sep) @@ -629,6 +743,20 @@ def _render_lines(doc, width): text = flat_text if flat else broken out.append(text) column += len(text) + case _Breakable(value=value): + if flat: + lines = [repr(value)] + else: + lines = _break_literal( + node, column, width, _trailing_width(stack) + ) + out.append(lines[0]) + column += len(lines[0]) + for line in lines[1:]: + yield _EMPTY.join(out) + out.clear() + out.append(line) + column = len(line) case _KeyValue(key=key, value=value, hang=hang): if flat or hang is None: stack.append((indent, flat, value)) @@ -677,8 +805,51 @@ def _fits(remaining, first, rest): remaining -= len(flat_text if flat else broken) case _KeyValue(key=key, value=value): local.append((indent, flat, _Concat((key, _COLON_SPACE, value)))) + case _Breakable(value=value): + if not flat: + # It can break, so the rest goes on later lines. + return True + remaining -= len(repr(value)) return False + +def _trailing_width(stack): + """Return the width of the text that follows on the current line. + + The scan stops at the first possible line break, including any group, + since whether a group breaks is not known yet. This is the allowance + that PrettyPrinter leaves for closing brackets and separators. + """ + total = 0 + local = [] + index = len(stack) + while True: + if local: + indent, flat, node = local.pop() + elif index: + index -= 1 + indent, flat, node = stack[index] + else: + return total + + match node: + case _Text(text=text): + if _NL in text: + return total + text.index(_NL) + total += len(text) + case _Line(flat=flat_text): + if not flat: + return total + total += len(flat_text) + case _Concat(parts=parts): + local.extend((indent, flat, part) for part in reversed(parts)) + case _Nest(doc=inner) | _Align(doc=inner): + local.append((indent, flat, inner)) + case _IfBroken(broken=broken, flat=flat_text): + total += len(flat_text if flat else broken) + case _: + return total + # End of printer formatting and rendering. ######################################################################## @@ -706,8 +877,8 @@ def _frozendict_pprint(obj): def _ordered_dict_pprint(obj): - name = type(obj).__name__ - yield Layout(open=f"{name}([", close="])", empty=f"{name}()") + yield Layout(empty=f"{type(obj).__name__}()") + yield ITEMS yield from obj.items() @@ -718,12 +889,12 @@ def _dict_view_pprint(obj): def _mapping_view_pprint(obj): - yield Layout(open=f"{type(obj).__name__}(", close=")") + yield Layout(open=f"{type(obj).__name__}(", close=")", hug=True) yield obj._mapping def _tuple_pprint(obj): - yield Layout(open="(", close=")", single_close=",)") + yield Layout(open="(", close=")", single_close=",)", recursion="(...)") yield from obj @@ -756,13 +927,25 @@ def _bytes_pprint(obj): yield BreakableBytes(obj) +def _int_pprint(obj): + yield DELEGATE + yield FormattableNumber(obj) + + +def _float_pprint(obj): + yield DELEGATE + yield FormattableNumber(obj) + + def _bytearray_pprint(obj): - yield Layout(open="bytearray(", close=")") + # There is only one item, so an empty separator leaves out the trailing + # comma when it breaks. + yield Layout(open="bytearray(", close=")", sep=_EMPTY) yield bytes(obj) def _mappingproxy_pprint(obj): - yield Layout(open="mappingproxy(", close=")") + yield Layout(open="mappingproxy(", close=")", hug=True) yield dict(obj) @@ -774,7 +957,7 @@ def _namespace_pprint(obj): def _defaultdict_pprint(obj): - yield Layout(open=f"{type(obj).__name__}(", close=")") + yield Layout(open=f"{type(obj).__name__}(", close=")", hug=True) yield obj.default_factory yield dict(obj) @@ -792,9 +975,11 @@ def _chainmap_pprint(obj): def _deque_pprint(obj): - close = "])" if obj.maxlen is None else f"], maxlen={obj.maxlen!r})" - yield Layout(open=f"{type(obj).__name__}([", close=close) - yield from obj + yield Layout(recursion="[...]", hug=True) + yield list(obj) + if obj.maxlen is not None: + yield KWARGS + yield "maxlen", obj.maxlen def _user_container_pprint(obj): @@ -811,12 +996,12 @@ def _template_pprint(obj): def _interpolation_pprint(obj): + # Positional, to agree with repr(). yield Layout(open=f"{type(obj).__name__}(", close=")") - yield KWARGS - yield "value", obj.value - yield "expression", obj.expression - yield "conversion", obj.conversion - yield "format_spec", obj.format_spec + yield obj.value + yield obj.expression + yield obj.conversion + yield obj.format_spec # End of __pprint__ implementations. diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 3af4d4fa77b406..4aa73b0e2a5fda 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -1189,7 +1189,9 @@ def _process_class(cls, init, repr, eq, order, unsafe_hash, frozen, locals={'__dataclasses_recursive_repr': recursive_repr}, decorator="@__dataclasses_recursive_repr()") - if '__pprint__' not in cls.__dict__: + # A hand-written __repr__() may show something other than the + # fields, so only describe the fields when __repr__() is generated. + if '__pprint__' not in cls.__dict__ and '__repr__' not in cls.__dict__: field_names = tuple(f.name for f in flds) func_builder.add_fn('__pprint__', ('self',), diff --git a/Lib/pprint.py b/Lib/pprint.py index 6b574218277a57..4e07abc4fecabb 100644 --- a/Lib/pprint.py +++ b/Lib/pprint.py @@ -39,8 +39,30 @@ import types as _types from io import StringIO as _StringIO +# The pretty print protocol of PEP 813. These names are defined in _pprint, +# which is imported the first time one of them is used, so that importing +# pprint stays cheap. +_PROTOCOL_NAMES = ( + "VALUES", "KWARGS", "ITEMS", "DELEGATE", "LEAF", "Layout", "Keyword", + "Entry", "BreakableString", "BreakableBytes", "FormattableNumber", + "DescriptionType", "decompose", "Printer", "Expand", +) + __all__ = ["pprint","pformat","isreadable","isrecursive","saferepr", - "PrettyPrinter", "pp"] + "PrettyPrinter", "pp", *_PROTOCOL_NAMES] + + +def __getattr__(name): + if name in _PROTOCOL_NAMES: + import _pprint + value = getattr(_pprint, name) + globals()[name] = value + return value + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__(): + return sorted({*globals(), *_PROTOCOL_NAMES}) def pprint(object, stream=None, indent=1, width=80, depth=None, *, diff --git a/Lib/test/test__pprint.py b/Lib/test/test__pprint.py index 164bf14af7cd41..847fb50d7ff9db 100644 --- a/Lib/test/test__pprint.py +++ b/Lib/test/test__pprint.py @@ -1,10 +1,28 @@ +import _pprint import collections +import enum +import pprint import types import unittest from dataclasses import dataclass +from string.templatelib import Interpolation from typing import NamedTuple -from _pprint import KWARGS, Layout, Printer, decompose +from _pprint import ( + DELEGATE, + KWARGS, + LEAF, + BreakableString, + Expand, + FormattableNumber, + Layout, + Printer, + decompose, +) + + +def pformat(obj, **options): + return "\n".join(Printer(**options)(obj)) class PprintProtocolTests(unittest.TestCase): @@ -65,7 +83,8 @@ class View(collections.abc.MappingView): def test_string_and_bytes_wrap(self): text = "one two three four five six seven eight nine ten eleven twelve" self.assertIn("\n", "\n".join(Printer(width=20)(text))) - self.assertTrue("\n".join(Printer()(b"some bytes")).startswith("(")) + # Like PrettyPrinter, only a bytes value that is split is parenthesized. + self.assertEqual("\n".join(Printer()(b"some bytes")), "b'some bytes'") def test_printer_call_yields_lines(self): printer = Printer(width=20) @@ -101,5 +120,265 @@ def __pprint__(self): ) +class LeafTests(unittest.TestCase): + def test_leaf(self): + self.assertEqual(decompose(object()), (LEAF, None)) + self.assertEqual(repr(LEAF), "pprint.LEAF") + + def test_delegate_without_value_is_a_leaf(self): + class OnlyDelegate: + def __pprint__(self): + yield DELEGATE + + def __repr__(self): + return "OnlyDelegate()" + + self.assertEqual(decompose(OnlyDelegate()), (LEAF, None)) + self.assertEqual(pformat(OnlyDelegate()), "OnlyDelegate()") + + def test_empty_container_is_not_a_leaf(self): + layout, items = decompose([]) + self.assertIsInstance(layout, Layout) + self.assertEqual(items, []) + + +class PublicNamesTests(unittest.TestCase): + def test_reexported_from_pprint(self): + for name in pprint._PROTOCOL_NAMES: + with self.subTest(name=name): + self.assertIn(name, pprint.__all__) + self.assertIs(getattr(pprint, name), getattr(_pprint, name)) + + def test_description_type(self): + def method(self) -> pprint.DescriptionType: + yield 1 + + self.assertIs(method.__annotations__["return"], pprint.DescriptionType) + + +class NumberTests(unittest.TestCase): + def test_int_and_float_delegate(self): + self.assertEqual(decompose(1), (DELEGATE, FormattableNumber(1))) + self.assertEqual(decompose(1.5), (DELEGATE, FormattableNumber(1.5))) + + def test_default_is_repr(self): + self.assertEqual(pformat([10**9, 1234567.125]), "[1000000000, 1234567.125]") + + def test_number_format(self): + self.assertEqual( + pformat([10**9, 1234567.125], number_format="_"), + "[1_000_000_000, 1_234_567.125]", + ) + self.assertEqual( + pformat([10**9, 1234567.125], number_format=","), + "[1,000,000,000, 1,234,567.125]", + ) + + def test_number_format_error_uses_repr(self): + # 'x' is valid for an int, but not for a float. + self.assertEqual(pformat([255, 1.5], number_format="x"), "[ff, 1.5]") + self.assertEqual(pformat(FormattableNumber("text"), number_format=","), "'text'") + + def test_subclasses_that_override_repr_are_leaves(self): + class Color(enum.IntEnum): + RED = 1000 + + self.assertEqual(decompose(True), (LEAF, None)) + self.assertEqual(decompose(Color.RED), (LEAF, None)) + self.assertEqual( + pformat([True, Color.RED], number_format=","), + "[True, ]", + ) + + def test_plain_subclass_is_formatted(self): + class MyInt(int): + pass + + self.assertEqual(pformat(MyInt(1000), number_format=","), "1,000") + + +class LiteralTests(unittest.TestCase): + TEXT = "word " * 12 + DATA = b"0123456789" * 6 + + def test_short_literals(self): + self.assertEqual(pformat("abc"), "'abc'") + self.assertEqual(pformat(b"some bytes"), "b'some bytes'") + self.assertEqual(pformat(bytearray(b"ab")), "bytearray(b'ab')") + + def test_top_level_string(self): + self.assertEqual( + pformat(self.TEXT, width=40, indent=1, expand=Expand.NEVER), + "('word word word word word word word '\n" + " 'word word word word word ')", + ) + self.assertEqual( + pformat(self.TEXT, width=40), + "(\n" + " 'word word word word word word '\n" + " 'word word word word word word '\n" + ")", + ) + + def test_string_in_container_is_not_parenthesized(self): + self.assertEqual( + pformat([self.TEXT, 1], width=40, indent=1, expand=Expand.NEVER), + "['word word word word word word word '\n" + " 'word word word word word ',\n" + " 1]", + ) + self.assertEqual( + pformat([self.TEXT, 1], width=40), + "[\n" + " 'word word word word word word '\n" + " 'word word word word word word ',\n" + " 1,\n" + "]", + ) + + def test_dict_value_continuation_is_aligned(self): + self.assertEqual( + pformat({"key": self.TEXT}, width=40), + "{\n" + " 'key': 'word word word word word '\n" + " 'word word word word word '\n" + " 'word word ',\n" + "}", + ) + + def test_matches_pretty_printer(self): + for obj in (self.TEXT, [self.TEXT, 1], {"k": [self.TEXT]}, self.DATA, [self.DATA]): + with self.subTest(obj=obj): + self.assertEqual( + pformat(obj, width=40, indent=1, expand=Expand.NEVER), + pprint.pformat(obj, width=40), + ) + self.assertEqual( + pformat(obj, width=40), + pprint.pformat(obj, width=40, indent=4, expand=True), + ) + + def test_bytearray(self): + data = bytearray(b"0123456789" * 8) + self.assertEqual( + pformat(data, width=40, indent=1, expand=Expand.NEVER), + pprint.pformat(data, width=40), + ) + self.assertEqual( + pformat(data, width=40), + "bytearray(\n" + " b'01234567890123456789012345678901'\n" + " b'23456789012345678901234567890123'\n" + " b'4567890123456789'\n" + ")", + ) + + def test_breakable_string_from_user_class(self): + class Text: + def __init__(self, text): + self.text = text + + def __pprint__(self): + yield DELEGATE + yield BreakableString(self.text) + + self.assertEqual(pformat(Text(self.TEXT), width=40), pformat(self.TEXT, width=40)) + + +class HugTests(unittest.TestCase): + WORDS = ["precision", "jazz", "stingray", "thunderbird", "rickenbacker"] + + def test_defaultdict(self): + obj = collections.defaultdict(list, {w: [1] for w in self.WORDS[:3]}) + self.assertEqual( + pformat(obj, width=40), + "defaultdict(, {\n" + " 'jazz': [1],\n" + " 'precision': [1],\n" + " 'stingray': [1],\n" + "})", + ) + self.assertEqual( + pformat(obj, width=40, indent=1, expand=Expand.NEVER), + pprint.pformat(obj, width=40), + ) + + def test_matches_pretty_printer(self): + mapping = {w: len(w) for w in self.WORDS} + objects = [ + collections.defaultdict(list, {w: [1] for w in self.WORDS}), + types.MappingProxyType(mapping), + collections.abc.KeysView(mapping), + collections.deque(self.WORDS), + collections.deque(self.WORDS, maxlen=10), + ] + for obj in objects: + with self.subTest(type=type(obj).__name__): + self.assertEqual( + pformat(obj, width=40), + pprint.pformat(obj, width=40, indent=4, expand=True), + ) + + def test_deque_maxlen_never(self): + obj = collections.deque(self.WORDS, maxlen=10) + self.assertEqual( + pformat(obj, width=40, indent=1, expand=Expand.NEVER), + pprint.pformat(obj, width=40), + ) + + def test_fits_on_one_line(self): + obj = collections.deque([1, 2], maxlen=3) + self.assertEqual(pformat(obj), repr(obj)) + + def test_no_hug_with_two_containers(self): + class Pair: + def __pprint__(self): + yield Layout(hug=True) + yield ["precision", "jazz"] + yield ["stingray", "thunderbird"] + + self.assertEqual( + pformat(Pair(), width=40), + "Pair(\n" + " ['precision', 'jazz'],\n" + " ['stingray', 'thunderbird'],\n" + ")", + ) + + def test_hug_ignored_when_never(self): + obj = collections.defaultdict(list, {w: [1] for w in self.WORDS}) + self.assertTrue(pformat(obj, width=40, expand=Expand.NEVER).startswith( + "defaultdict(,\n" + )) + + +class DescriptionTests(unittest.TestCase): + def test_ordered_dict(self): + obj = collections.OrderedDict(b=2, a=1) + self.assertEqual(pformat(obj), repr(obj)) + self.assertEqual( + pformat(obj, width=10), + "OrderedDict({\n" + " 'b': 2,\n" + " 'a': 1,\n" + "})", + ) + + def test_tuple_recursion(self): + items = [] + obj = (items,) + items.append(obj) + self.assertEqual(pformat(obj), repr(obj)) + + def test_deque_recursion(self): + obj = collections.deque() + obj.append(obj) + self.assertEqual(pformat(obj), repr(obj)) + + def test_interpolation_agrees_with_repr(self): + value = Interpolation([1, 2], "x", "r", "") + self.assertEqual(pformat(value), repr(value)) + + if __name__ == "__main__": unittest.main() diff --git a/Lib/test/test_dataclasses/__init__.py b/Lib/test/test_dataclasses/__init__.py index 60cc257914dedb..5f5b16cfb34794 100644 --- a/Lib/test/test_dataclasses/__init__.py +++ b/Lib/test/test_dataclasses/__init__.py @@ -2720,6 +2720,22 @@ class C: self.assertIs(C.__dict__['__pprint__'], custom_pprint) + def test_pprint_not_generated_if_repr_defined(self): + # A hand-written __repr__() may show something other than the fields, + # such as a field with a secret value. A generated __pprint__() would + # take precedence over it, so none is generated. + from _pprint import Printer + + @dataclass + class C: + password: str + + def __repr__(self): + return 'C(password=...)' + + self.assertNotIn('__pprint__', C.__dict__) + self.assertEqual("\n".join(Printer()(C('secret'))), 'C(password=...)') + def test_pprint_not_generated_if_repr_false(self): @dataclass(repr=False) class C: diff --git a/Lib/test/test_fstring.py b/Lib/test/test_fstring.py index c446a59363f169..7fc7d5c2485ac7 100644 --- a/Lib/test/test_fstring.py +++ b/Lib/test/test_fstring.py @@ -1467,6 +1467,29 @@ def __call__(self, obj): self.assertEqual(f'{1!p:{Printer()}}', 'printed\n1') + # A printer returns an iterable of str lines. A str is rejected, + # since its items are characters, not lines. + def str_printer(obj): + return 'abc' + + def int_printer(obj): + return 42 + + def int_lines_printer(obj): + return [1, 2] + + def newline_printer(obj): + return ['a\nb', 'c'] + + with self.assertRaises(TypeError): + f'{1!p:{str_printer}}' + with self.assertRaises(TypeError): + f'{1!p:{int_printer}}' + with self.assertRaises(TypeError): + f'{1!p:{int_lines_printer}}' + # Lines are joined as they are, even if they contain newlines. + self.assertEqual(f'{1!p:{newline_printer}}', 'a\nb\nc') + # Conversions can have trailing whitespace after them since it # does not provide any significance self.assertEqual(f"{3!s }", "3") diff --git a/Lib/test/test_stable_abi_ctypes.py b/Lib/test/test_stable_abi_ctypes.py index aed7451e89b156..e70677a682961c 100644 --- a/Lib/test/test_stable_abi_ctypes.py +++ b/Lib/test/test_stable_abi_ctypes.py @@ -614,6 +614,7 @@ def test_windows_feature_macros(self): "PyObject_Length", "PyObject_Malloc", "PyObject_Not", + "PyObject_Pretty", "PyObject_Realloc", "PyObject_Repr", "PyObject_RichCompare", diff --git a/Misc/stable_abi.toml b/Misc/stable_abi.toml index e79fa33319a39b..e709f0232ee07d 100644 --- a/Misc/stable_abi.toml +++ b/Misc/stable_abi.toml @@ -1240,6 +1240,8 @@ added = '3.2' [function.PyObject_Str] added = '3.2' +[function.PyObject_Pretty] + added = '3.16' [function.PyObject_Type] added = '3.2' [data.PyProperty_Type] diff --git a/Modules/_testinternalcapi/test_cases.c.h b/Modules/_testinternalcapi/test_cases.c.h index 4c08a1dd0cdd45..c5cccc29763774 100644 --- a/Modules/_testinternalcapi/test_cases.c.h +++ b/Modules/_testinternalcapi/test_cases.c.h @@ -6254,7 +6254,7 @@ assert(oparg <= 1); _PyFrame_SetStackPointer(frame, stack_pointer); _PyFrame_StackPointerValidate(frame); - PyObject *lines = _PyObject_Pretty( + PyObject *res_o = PyObject_Pretty( PyStackRef_AsPyObjectBorrow(value), oparg ? NULL : PyStackRef_AsPyObjectBorrow(printer)); _PyFrame_StackPointerInvalidate(frame); @@ -6274,17 +6274,6 @@ _PyFrame_StackPointerInvalidate(frame); stack_pointer += -2; ASSERT_WITHIN_STACK_BOUNDS(__FILE__, __LINE__); - if (lines == NULL) { - JUMP_TO_LABEL(error); - } - _PyFrame_SetStackPointer(frame, stack_pointer); - _PyFrame_StackPointerValidate(frame); - PyObject *res_o = PyUnicode_Join(_Py_LATIN1_CHR('\n'), lines); - _PyFrame_StackPointerInvalidate(frame); - assert(stack_pointer == _PyFrame_GetStackPointer(frame)); - _PyFrame_StackPointerValidate(frame); - Py_DECREF(lines); - _PyFrame_StackPointerInvalidate(frame); if (res_o == NULL) { JUMP_TO_LABEL(error); } diff --git a/Objects/floatobject.c b/Objects/floatobject.c index d5a879cdd53ad6..7bf5c6759ef43e 100644 --- a/Objects/floatobject.c +++ b/Objects/floatobject.c @@ -12,7 +12,7 @@ #include "pycore_initconfig.h" // _PyStatus_OK() #include "pycore_long.h" // _PyLong_GetOne() #include "pycore_modsupport.h" // _PyArg_NoKwnames() -#include "pycore_object.h" // _PyObject_Init(), _PyDebugAllocatorStats() +#include "pycore_object.h" // _PyObject_Init(), _PyDebugAllocatorStats(), _PyObject_PPRINT_METHOD #include "pycore_pymath.h" // _PY_SHORT_FLOAT_REPR #include "pycore_pystate.h" // _PyInterpreterState_GET() #include "pycore_stackref.h" // PyStackRef_AsPyObjectBorrow() @@ -1725,7 +1725,11 @@ float___format___impl(PyObject *self, PyObject *format_spec) return _PyUnicodeWriter_Finish(&writer); } +_PyObject_PPRINT_METHOD(_pprint_bridge_float_pprint, "_float_pprint") + static PyMethodDef float_methods[] = { + {"__pprint__", _pprint_bridge_float_pprint, METH_NOARGS, PyDoc_STR("Describe object for pretty printing.")}, + FLOAT_FROM_NUMBER_METHODDEF FLOAT_CONJUGATE_METHODDEF FLOAT___TRUNC___METHODDEF diff --git a/Objects/longobject.c b/Objects/longobject.c index 9577c8ef4d6ed0..9e3e5b77659742 100644 --- a/Objects/longobject.c +++ b/Objects/longobject.c @@ -8,7 +8,7 @@ #include "pycore_call.h" // _PyObject_MakeTpCall #include "pycore_freelist.h" // _Py_FREELIST_FREE, _Py_FREELIST_POP #include "pycore_long.h" // _Py_SmallInts -#include "pycore_object.h" // _PyObject_Init() +#include "pycore_object.h" // _PyObject_Init(), _PyObject_PPRINT_METHOD #include "pycore_runtime.h" // _PY_NSMALLPOSINTS #include "pycore_stackref.h" #include "pycore_structseq.h" // _PyStructSequence_FiniBuiltin() @@ -6548,7 +6548,11 @@ long_vectorcall(PyObject *type, PyObject * const*args, } } +_PyObject_PPRINT_METHOD(_pprint_bridge_int_pprint, "_int_pprint") + static PyMethodDef long_methods[] = { + {"__pprint__", _pprint_bridge_int_pprint, METH_NOARGS, PyDoc_STR("Describe object for pretty printing.")}, + {"conjugate", long_long_meth, METH_NOARGS, "Returns self, the complex conjugate of any int."}, INT_BIT_LENGTH_METHODDEF diff --git a/Objects/object.c b/Objects/object.c index 4e034d7410bc28..a3ebe969cd530b 100644 --- a/Objects/object.c +++ b/Objects/object.c @@ -841,7 +841,7 @@ PyObject_Str(PyObject *v) } PyObject * -_PyObject_Pretty(PyObject *v, PyObject *printer) +PyObject_Pretty(PyObject *v, PyObject *printer) { PyObject *default_printer = NULL; if (printer == NULL) { @@ -861,14 +861,21 @@ _PyObject_Pretty(PyObject *v, PyObject *printer) } printer = default_printer; } - PyObject *result = PyObject_CallOneArg(printer, v); + PyObject *lines = PyObject_CallOneArg(printer, v); Py_XDECREF(default_printer); - if (result == NULL) { + if (lines == NULL) { return NULL; } - PyObject *iterator = PyObject_GetIter(result); - Py_DECREF(result); - return iterator; + /* A str is iterable, but its items are characters, not lines. */ + if (PyUnicode_Check(lines)) { + PyErr_SetString(PyExc_TypeError, + "printer must return an iterable of lines, not str"); + Py_DECREF(lines); + return NULL; + } + PyObject *result = PyUnicode_Join(_Py_LATIN1_CHR('\n'), lines); + Py_DECREF(lines); + return result; } PyObject * diff --git a/Objects/stringlib/unicode_format.h b/Objects/stringlib/unicode_format.h index 2a168dfa2974f6..5707dbd994f5a7 100644 --- a/Objects/stringlib/unicode_format.h +++ b/Objects/stringlib/unicode_format.h @@ -775,15 +775,7 @@ do_conversion(PyObject *obj, Py_UCS4 conversion) case 'a': return PyObject_ASCII(obj); case 'p': - { - PyObject *lines = _PyObject_Pretty(obj, NULL); - if (lines == NULL) { - return NULL; - } - PyObject *result = PyUnicode_Join(_Py_LATIN1_CHR('\n'), lines); - Py_DECREF(lines); - return result; - } + return PyObject_Pretty(obj, NULL); default: if (conversion > 32 && conversion < 127) { /* It's the ASCII subrange; casting to char is safe diff --git a/PC/python3dll.c b/PC/python3dll.c index d1b7bafe6abb3c..d516d23ff3778a 100755 --- a/PC/python3dll.c +++ b/PC/python3dll.c @@ -552,6 +552,7 @@ EXPORT_FUNC(PyObject_IsTrue) EXPORT_FUNC(PyObject_Length) EXPORT_FUNC(PyObject_Malloc) EXPORT_FUNC(PyObject_Not) +EXPORT_FUNC(PyObject_Pretty) EXPORT_FUNC(PyObject_Realloc) EXPORT_FUNC(PyObject_Repr) EXPORT_FUNC(PyObject_RichCompare) diff --git a/Programs/test_frozenmain.h b/Programs/test_frozenmain.h index ebcb38b18dec90..20201f7ec2d99e 100644 --- a/Programs/test_frozenmain.h +++ b/Programs/test_frozenmain.h @@ -1,19 +1,19 @@ // Auto-generated by Programs/freeze_test_frozenmain.py unsigned char M_test_frozenmain[] = { 99,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0, - 0,0,0,0,0,115,188,0,0,0,128,0,0,0,90,0, - 77,7,69,0,112,0,90,0,77,7,69,4,112,1,89,2, - 31,0,78,1,50,1,0,0,0,0,0,0,29,0,89,2, - 31,0,78,2,89,0,76,6,0,0,0,0,0,0,0,0, + 0,0,0,0,0,115,188,0,0,0,128,0,0,0,91,0, + 78,7,70,0,113,0,91,0,78,7,70,4,113,1,90,2, + 31,0,79,1,50,1,0,0,0,0,0,0,29,0,90,2, + 31,0,79,2,90,0,77,6,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,50,2,0,0,0,0, - 0,0,29,0,89,1,76,8,0,0,0,0,0,0,0,0, + 0,0,29,0,90,1,77,8,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,31,0,50,0,0,0, - 0,0,0,0,78,3,42,26,0,0,0,0,0,0,0,0, - 0,0,112,5,78,6,67,0,0,0,65,24,0,0,112,6, - 89,2,31,0,78,4,89,6,12,0,78,5,89,5,89,6, + 0,0,0,0,79,3,42,26,0,0,0,0,0,0,0,0, + 0,0,113,5,79,6,68,0,0,0,66,24,0,0,113,6, + 90,2,31,0,79,4,90,6,12,0,79,5,90,5,90,6, 42,26,0,0,0,0,0,0,0,0,0,0,12,0,48,4, - 50,1,0,0,0,0,0,0,29,0,71,26,0,0,9,0, - 28,0,77,7,33,0,41,7,105,0,0,0,0,122,18,70, + 50,1,0,0,0,0,0,0,29,0,72,26,0,0,9,0, + 28,0,78,7,33,0,41,7,105,0,0,0,0,122,18,70, 114,111,122,101,110,32,72,101,108,108,111,32,87,111,114,108, 100,122,8,115,121,115,46,97,114,103,118,218,6,99,111,110, 102,105,103,122,7,99,111,110,102,105,103,32,122,2,58,32, diff --git a/Python/bytecodes.c b/Python/bytecodes.c index 7fa2dc7c6b5ccb..ecabeead269c65 100644 --- a/Python/bytecodes.c +++ b/Python/bytecodes.c @@ -5871,13 +5871,10 @@ dummy_func( inst(FORMAT_PRETTY, (value, printer -- res)) { assert(oparg <= 1); - PyObject *lines = _PyObject_Pretty( + PyObject *res_o = PyObject_Pretty( PyStackRef_AsPyObjectBorrow(value), oparg ? NULL : PyStackRef_AsPyObjectBorrow(printer)); DECREF_INPUTS(); - ERROR_IF(lines == NULL); - PyObject *res_o = PyUnicode_Join(_Py_LATIN1_CHR('\n'), lines); - Py_DECREF(lines); ERROR_IF(res_o == NULL); res = PyStackRef_FromPyObjectSteal(res_o); } diff --git a/Python/executor_cases.c.h b/Python/executor_cases.c.h index b17ac90f4a7034..b8d1e232dfa7f2 100644 --- a/Python/executor_cases.c.h +++ b/Python/executor_cases.c.h @@ -20877,7 +20877,7 @@ ASSERT_WITHIN_STACK_BOUNDS(__FILE__, __LINE__); _PyFrame_SetStackPointer(frame, stack_pointer); _PyFrame_StackPointerValidate(frame); - PyObject *lines = _PyObject_Pretty( + PyObject *res_o = PyObject_Pretty( PyStackRef_AsPyObjectBorrow(value), oparg ? NULL : PyStackRef_AsPyObjectBorrow(printer)); _PyFrame_StackPointerInvalidate(frame); @@ -20897,18 +20897,6 @@ _PyFrame_StackPointerInvalidate(frame); stack_pointer += -2; ASSERT_WITHIN_STACK_BOUNDS(__FILE__, __LINE__); - if (lines == NULL) { - SET_CURRENT_CACHED_VALUES(0); - JUMP_TO_ERROR(); - } - _PyFrame_SetStackPointer(frame, stack_pointer); - _PyFrame_StackPointerValidate(frame); - PyObject *res_o = PyUnicode_Join(_Py_LATIN1_CHR('\n'), lines); - _PyFrame_StackPointerInvalidate(frame); - assert(stack_pointer == _PyFrame_GetStackPointer(frame)); - _PyFrame_StackPointerValidate(frame); - Py_DECREF(lines); - _PyFrame_StackPointerInvalidate(frame); if (res_o == NULL) { SET_CURRENT_CACHED_VALUES(0); JUMP_TO_ERROR(); diff --git a/Python/generated_cases.c.h b/Python/generated_cases.c.h index d7d82f7f69c049..31682776a92ce9 100644 --- a/Python/generated_cases.c.h +++ b/Python/generated_cases.c.h @@ -6254,7 +6254,7 @@ assert(oparg <= 1); _PyFrame_SetStackPointer(frame, stack_pointer); _PyFrame_StackPointerValidate(frame); - PyObject *lines = _PyObject_Pretty( + PyObject *res_o = PyObject_Pretty( PyStackRef_AsPyObjectBorrow(value), oparg ? NULL : PyStackRef_AsPyObjectBorrow(printer)); _PyFrame_StackPointerInvalidate(frame); @@ -6274,17 +6274,6 @@ _PyFrame_StackPointerInvalidate(frame); stack_pointer += -2; ASSERT_WITHIN_STACK_BOUNDS(__FILE__, __LINE__); - if (lines == NULL) { - JUMP_TO_LABEL(error); - } - _PyFrame_SetStackPointer(frame, stack_pointer); - _PyFrame_StackPointerValidate(frame); - PyObject *res_o = PyUnicode_Join(_Py_LATIN1_CHR('\n'), lines); - _PyFrame_StackPointerInvalidate(frame); - assert(stack_pointer == _PyFrame_GetStackPointer(frame)); - _PyFrame_StackPointerValidate(frame); - Py_DECREF(lines); - _PyFrame_StackPointerInvalidate(frame); if (res_o == NULL) { JUMP_TO_LABEL(error); }