Repository: Textualize/rich (python)

Issue:
duplicated plots in jupyter
How can we avoid having duplicated plots on jupyter?

When I run

```
import gdsfactory as gf
gf.components.bend_circular()
```

I get one plot in jupyter, thanks to  defining `def _repr_html_(self)`

However when I run 
```
from rich import pretty

pretty.install()
gf.components.bend_circular()
```

I get two plots of the same component.


Pull request description:
match expected IPython formatter spec in rich.pretty
## Type of changes

- [x] Bug fix
- [ ] New feature
- [ ] Documentation / docstrings
- [ ] Tests
- [ ] Other

## Checklist

- [x] I've run the latest [black](https://github.com/psf/black) with default args on new code.
- [ ] I've updated CHANGELOG.md and CONTRIBUTORS.md where appropriate.
- [x] I've added tests for new code.
- [x] I accept that @willmcgugan may be pedantic in the code review.

## Description

More context [in #2803](https://github.com/Textualize/rich/issues/2803#issuecomment-1427886362), but the IPython formatter spec expects slightly different behavior than the hook currently in `rich.pretty`. In particular:

1. formatters should _return_ their output, not publish it on a side channel, and
2. registered formatters should not gate their behavior on what other formatters may be registered

From looking through the code, I think these two are related, because when multiple formats are produced in a mimebundle, a Jupyter frontend displays only one. But because the IPython hook _printed_ output instead of returning it, it was always _in addition to_ other reprs - making it necessary to check if it was probably going to be the only one. Following the spec and returning output removes the need to check if it's likely to be the only output.

This PR changes the registered formatter to match IPython's expectations - _return_ the text representation instead of publishing it separately, and remove checks for possible other formatters. This will fix all cases where duplicate output was produced because the checks were incomplete (not accounting for `Formatter.for_type`, for instance). The only downside is one of size/performance if rich text output is expensive and/or large, but might not be seen because. This is standard for IPython and Jupyter output, however, and Rich shouldn't be blamed for it.

closes #2803



Patch:
diff --git a/rich/pretty.py b/rich/pretty.py
index 83c6c1e6..366837ba 100644
--- a/rich/pretty.py
+++ b/rich/pretty.py
@@ -55,13 +55,6 @@ if TYPE_CHECKING:
     )
 
 
-JUPYTER_CLASSES_TO_NOT_RENDER = {
-    # Matplotlib "Artists" manage their own rendering in a Jupyter notebook, and we should not try to render them too.
-    # "Typically, all [Matplotlib] visible elements in a figure are subclasses of Artist."
-    "matplotlib.artist.Artist",
-}
-
-
 def _is_attr_object(obj: Any) -> bool:
     """Check if an object was created with attrs module."""
     return _has_attrs and _attr_module.has(type(obj))
@@ -122,69 +115,40 @@ def _ipy_display_hook(
     max_string: Optional[int] = None,
     max_depth: Optional[int] = None,
     expand_all: bool = False,
-) -> None:
+) -> Union[str, None]:
     # needed here to prevent circular import:
-    from ._inspect import is_object_one_of_types
     from .console import ConsoleRenderable
 
     # always skip rich generated jupyter renderables or None values
     if _safe_isinstance(value, JupyterRenderable) or value is None:
-        return
+        return None
 
     console = console or get_console()
-    if console.is_jupyter:
-        # Delegate rendering to IPython if the object (and IPython) supports it
-        #  https://ipython.readthedocs.io/en/stable/config/integrating.html#rich-display
-        ipython_repr_methods = [
-            "_repr_html_",
-            "_repr_markdown_",
-            "_repr_json_",
-            "_repr_latex_",
-            "_repr_jpeg_",
-            "_repr_png_",
-            "_repr_svg_",
-            "_repr_mimebundle_",
-        ]
-        for repr_method in ipython_repr_methods:
-            method = getattr(value, repr_method, None)
-            if inspect.ismethod(method):
-                # Calling the method ourselves isn't ideal. The interface for the `_repr_*_` methods
-                #  specifies that if they return None, then they should not be rendered
-                #  by the notebook.
-                try:
-                    repr_result = method()
-                except Exception:
-                    continue  # If the method raises, treat it as if it doesn't exist, try any others
-                if repr_result is not None:
-                    return  # Delegate rendering to IPython
-
-        # When in a Jupyter notebook let's avoid the display of some specific classes,
-        # as they result in the rendering of useless and noisy lines such as `<Figure size 432x288 with 1 Axes>`.
-        # What does this do?
-        # --> if the class has "matplotlib.artist.Artist" in its hierarchy for example, we don't render it.
-        if is_object_one_of_types(value, JUPYTER_CLASSES_TO_NOT_RENDER):
-            return
-
-    # certain renderables should start on a new line
-    if _safe_isinstance(value, ConsoleRenderable):
-        console.line()
-
-    console.print(
-        value
-        if _safe_isinstance(value, RichRenderable)
-        else Pretty(
-            value,
-            overflow=overflow,
-            indent_guides=indent_guides,
-            max_length=max_length,
-            max_string=max_string,
-            max_depth=max_depth,
-            expand_all=expand_all,
-            margin=12,
-        ),
-        crop=crop,
-        new_line_start=True,
-    )
+
+    with console.capture() as capture:
+        # certain renderables should start on a new line
+        if _safe_isinstance(value, ConsoleRenderable):
+            console.line()
+        console.print(
+            value
+            if _safe_isinstance(value, RichRenderable)
+            else Pretty(
+                value,
+                overflow=overflow,
+                indent_guides=indent_guides,
+                max_length=max_length,
+                max_string=max_string,
+                max_depth=max_depth,
+                expand_all=expand_all,
+                margin=12,
+            ),
+            crop=crop,
+            new_line_start=True,
+            end="",
+        )
+    # strip trailing newline, not usually part of a text repr
+    # I'm not sure if this should be prevented at a lower level
+    return capture.get().rstrip("\n")
 
 
 def _safe_isinstance(


Test patch:
diff --git a/tests/test_pretty.py b/tests/test_pretty.py
index 53256379..c10f9467 100644
--- a/tests/test_pretty.py
+++ b/tests/test_pretty.py
@@ -5,7 +5,6 @@ from array import array
 from collections import UserDict, defaultdict
 from dataclasses import dataclass, field
 from typing import Any, List, NamedTuple
-from unittest.mock import patch
 
 import attr
 import pytest
@@ -78,16 +77,17 @@ def test_ipy_display_hook__multiple_special_reprs():
     console = Console(file=io.StringIO(), force_jupyter=True)
 
     class Thing:
+        def __repr__(self):
+            return "A Thing"
+
         def _repr_latex_(self):
             return None
 
         def _repr_html_(self):
             return "hello"
 
-    console.begin_capture()
-    _ipy_display_hook(Thing(), console=console)
-
-    assert console.end_capture() == ""
+    result = _ipy_display_hook(Thing(), console=console)
+    assert result == "A Thing"
 
 
 def test_ipy_display_hook__no_special_repr_methods():
@@ -97,11 +97,9 @@ def test_ipy_display_hook__no_special_repr_methods():
         def __repr__(self) -> str:
             return "hello"
 
-    console.begin_capture()
-    _ipy_display_hook(Thing(), console=console)
-
-    # No IPython special repr methods, so printed by Rich
-    assert console.end_capture() == "hello\n"
+    result = _ipy_display_hook(Thing(), console=console)
+    # should be repr as-is
+    assert result == "hello"
 
 
 def test_ipy_display_hook__special_repr_raises_exception():
@@ -121,33 +119,18 @@ def test_ipy_display_hook__special_repr_raises_exception():
         def _repr_html_(self):
             return "hello"
 
-    console.begin_capture()
-    _ipy_display_hook(Thing(), console=console)
+        def __repr__(self):
+            return "therepr"
 
-    assert console.end_capture() == ""
+    result = _ipy_display_hook(Thing(), console=console)
+    assert result == "therepr"
 
 
 def test_ipy_display_hook__console_renderables_on_newline():
     console = Console(file=io.StringIO(), force_jupyter=True)
     console.begin_capture()
-    _ipy_display_hook(Text("hello"), console=console)
-    assert console.end_capture() == "\nhello\n"
-
-
-def test_ipy_display_hook__classes_to_not_render():
-    console = Console(file=io.StringIO(), force_jupyter=True)
-    console.begin_capture()
-
-    class Thing:
-        def __repr__(self) -> str:
-            return "hello"
-
-    class_fully_qualified_name = f"{__name__}.{Thing.__qualname__}"
-    with patch(
-        "rich.pretty.JUPYTER_CLASSES_TO_NOT_RENDER", {class_fully_qualified_name}
-    ):
-        _ipy_display_hook(Thing(), console=console)
-    assert console.end_capture() == ""
+    result = _ipy_display_hook(Text("hello"), console=console)
+    assert result == "\nhello"
 
 
 def test_pretty():