← oracle_refix

rich_4070

resolved RESOLVED UNSUBMITTED PASS · None tool calls · 0 s · Textualize/rich

Task input

perf: reduce Console and RichHandler import time by deferring unused imports

## E2E Results

| Import | master | This PR | Speedup |
|---|---|---|---|
| `from rich.console import Console` | 78.1ms | 52.2ms | **1.50x faster** |
| `from rich.logging import RichHandler` | 99.4ms | 56.9ms | **1.75x faster** |
| `import rich` | 18.2ms | 18.3ms | (already lean) |

## Summary

Defer module-level imports that are only needed in specific code paths, move annotation-only imports to `TYPE_CHECKING`, and remove dead code:

| File | Change | Savings |
|---|---|---|
| `logging.py` | Defer `Traceback` to `emit()` (only when `rich_tracebacks=True`) | ~20ms |
| `logging.py` | `from __future__ import annotations` + `TYPE_CHECKING` for `Console`, `ConsoleRenderable`, `Highlighter`, `FormatTimeCallable` | ~6ms |
| `logging.py` | Replace `pathlib.Path` → `os.path.basename` (also a minor runtime win) | ~4-5ms |
| `console.py` | Eliminate `import inspect`; replace `isclass` → `isinstance(x, type)`, `currentframe` → `sys._getframe` | ~10ms |
| `console.py` | Defer `pretty` to `Console.print()` | ~3-5ms |
| `console.py` | Defer `scope` to `Console.log()` (only when `log_locals=True`) | ~3-5ms |
| `console.py` | Defer `getpass` to `Console.input()` (only when `password=True`) | ~2ms |
| `console.py` | Defer `html.escape` and `zlib` to export methods | ~2.3ms |
| `console.py` | Remove dead `_svg_hash` function (unused since 113997ac, fixes latent NameError) | cleanup |
| `segment.py` | Remove dead `logging` import (`getLogger` assigned but never used) | ~2-3ms |
| `theme.py` | Defer `configparser` to `Theme.from_file()` | ~1.5ms |
| `syntax.py` | Move `Console`, `ConsoleOptions`, `JustifyMethod`, `RenderResult` to `TYPE_CHECKING` | eliminates console.py from syntax import chain |
| `protocol.py` | Replace `from inspect import isclass` → `isinstance(x, type)` | prepares for dataclasses removal |
| `repr.py` | Defer `import inspect` to `auto_rich_repr()` | prepares for dataclasses removal |

All deferred imports are still available at runtime — they're loaded when the code path that needs them is actually hit. The `TYPE_CHECKING` imports are annotation-only and never needed at runtime (files use `from __future__ import annotations`).

## Benchmarks

Environment: Standard_D2s_v5 (non-burstable), CPython 3.13.13, hyperfine --min-runs 30

### Per-module breakdown (this PR)

| Module | Time |
|---|---|
| `import rich` | 18.2ms |
| `Console` | 52.5ms |
| `RichHandler` | 56.4ms |
| `Syntax` | 64.7ms |
| `Traceback` | 92.5ms |
| `Markdown` | 103.0ms |

## Motivation

pip vendors Rich, and `Console`/`RichHandler` are imported on every `pip` invocation. These savings directly improve startup time for pip and every other tool that imports Rich.

## Testing

- `pytest tests/` — 952 passed, 25 skipped
- `mypy -p rich --no-incremental` — no issues
- `black --check` — passes

## Disclosure

This PR was developed with AI assistance (codeflash-agent). Benchmarked in a controlled environment (dedicated non-burstable Azure VM). All tests verified across multiple Python implementations.

Tool calls (0)

#ToolArgumentsResult
No trace captured.

Patch

--- a/rich/_emoji_replace.py
+++ b/rich/_emoji_replace.py
@@ -1,8 +1,5 @@
-from typing import Callable, Match, Optional
 import re
-
-from ._emoji_codes import EMOJI
-
+from typing import Callable, Match, Optional
 
 _ReStringMatch = Match[str]  # regex match object
 _ReSubCallable = Callable[[_ReStringMatch], str]  # Callable invoked by re.sub
@@ -15,8 +12,10 @@ def _emoji_replace(
     _emoji_sub: _EmojiSubMethod = re.compile(r"(:(\S*?)(?:(?:\-)(emoji|text))?:)").sub,
 ) -> str:
     """Replace emoji code in text."""
+    from ._emoji_codes import EMOJI
+
     get_emoji = EMOJI.__getitem__
-    variants = {"text": "\uFE0E", "emoji": "\uFE0F"}
+    variants = {"text": "\ufe0e", "emoji": "\ufe0f"}
     get_variant = variants.get
     default_variant_code = variants.get(default_variant, "") if default_variant else ""
 
--- a/rich/console.py
+++ b/rich/console.py
@@ -1,15 +1,10 @@
-import inspect
 import os
 import sys
 import threading
-import zlib
 from abc import ABC, abstractmethod
 from dataclasses import dataclass, field
 from datetime import datetime
 from functools import wraps
-from getpass import getpass
-from html import escape
-from inspect import isclass
 from itertools import islice
 from math import ceil
 from time import monotonic
@@ -50,10 +45,8 @@
 from .markup import render as render_markup
 from .measure import Measurement, measure_renderables
 from .pager import Pager, SystemPager
-from .pretty import Pretty, is_expandable
 from .protocol import rich_cast
 from .region import Region
-from .scope import render_scope
 from .screen import Screen
 from .segment import Segment
 from .style import Style, StyleType
@@ -1321,7 +1314,7 @@ def render(
         render_iterable: RenderResult
 
         renderable = rich_cast(renderable)
-        if hasattr(renderable, "__rich_console__") and not isclass(renderable):
+        if hasattr(renderable, "__rich_console__") and not isinstance(renderable, type):
             render_iterable = renderable.__rich_console__(self, _options)
         elif isinstance(renderable, str):
             text_renderable = self.render_str(
@@ -1528,6 +1521,14 @@ def _collect_renderables(
         Returns:
             List[ConsoleRenderable]: A list of things to render.
         """
+
+        def is_expandable(obj: object) -> bool:
+            """Check if an object is expandable by pretty printer."""
+            # Permit lazy loading
+            from .pretty import is_expandable as _is_expandable
+
+            return _is_expandable(obj)
+
         renderables: List[ConsoleRenderable] = []
         _append = renderables.append
         text: List[Text] = []
@@ -1570,6 +1571,8 @@ def check_text() -> None:
                 append(renderable)
             elif is_expandable(renderable):
                 check_text()
+                from .pretty import Pretty
+
                 append(Pretty(renderable, highlighter=_highlighter))
             else:
                 append_text(_highlighter(str(renderable)))
@@ -1900,15 +1903,14 @@ def print_exception(
 
     @staticmethod
     def _caller_frame_info(
-        offset: int,
-        currentframe: Callable[[], Optional[FrameType]] = inspect.currentframe,
+        offset: int, currentframe: Optional[Callable[[], Optional[FrameType]]] = None
     ) -> Tuple[str, int, Dict[str, Any]]:
         """Get caller frame information.
 
         Args:
             offset (int): the caller offset within the current frame stack.
             currentframe (Callable[[], Optional[FrameType]], optional): the callable to use to
-                retrieve the current frame. Defaults to ``inspect.currentframe``.
+                retrieve the current frame. Defaults to None, which will use ``inspect.currentframe()``.
 
         Returns:
             Tuple[str, int, Dict[str, Any]]: A tuple containing the filename, the line number and
@@ -1920,17 +1922,22 @@ def _caller_frame_info(
         # Ignore the frame of this local helper
         offset += 1
 
-        frame = currentframe()
+        if currentframe is None:
+            import inspect
+
+            frame = inspect.currentframe()
+        else:
+            frame = currentframe()
         if frame is not None:
-            # Use the faster currentframe where implemented
             while offset and frame is not None:
                 frame = frame.f_back
                 offset -= 1
             assert frame is not None
             return frame.f_code.co_filename, frame.f_lineno, frame.f_locals
         else:
-            # Fallback to the slower stack
-            frame_info = inspect.stack()[offset]
+            from inspect import stack
+
+            frame_info = stack()[offset]
             return frame_info.filename, frame_info.lineno, frame_info.frame.f_locals
 
     def log(
@@ -1983,6 +1990,8 @@ def log(
             link_path = None if filename.startswith("<") else os.path.abspath(filename)
             path = filename.rpartition(os.sep)[-1]
             if log_locals:
+                from .scope import render_scope
+
                 locals_map = {
                     key: value
                     for key, value in locals.items()
@@ -2166,7 +2175,9 @@ def input(
         if prompt:
             self.print(prompt, markup=markup, emoji=emoji, end="")
         if password:
-            result = getpass("", stream=stream)
+            import getpass as _getpass_mod
+
+            result = _getpass_mod.getpass("", stream=stream)
         else:
             if stream:
                 result = stream.readline()
@@ -2242,6 +2253,8 @@ def export_html(
         Returns:
             str: String containing console contents as HTML.
         """
+        from html import escape
+
         assert (
             self.record
         ), "To export console contents set record=True in the constructor or instance"
@@ -2353,6 +2366,9 @@ def export_svg(
                 ids). If not set, this defaults to a computed value based on the recorded content.
         """
 
+        import zlib
+        from html import escape
+
         from rich.cells import cell_len
 
         style_cache: Dict[Style, str] = {}
@@ -2616,18 +2632,6 @@ def save_svg(
             write_file.write(svg)
 
 
-def _svg_hash(svg_main_code: str) -> str:
-    """Returns a unique hash for the given SVG main code.
-
-    Args:
-        svg_main_code (str): The content we're going to inject in the SVG envelope.
-
-    Returns:
-        str: a hash of the given content
-    """
-    return str(zlib.adler32(svg_main_code.encode()))
-
-
 if __name__ == "__main__":  # pragma: no cover
     console = Console(record=True)
 
--- a/rich/emoji.py
+++ b/rich/emoji.py
@@ -1,12 +1,10 @@
 import sys
-from typing import TYPE_CHECKING, Optional, Union, Literal
+from typing import TYPE_CHECKING, Literal, Optional, Union
 
+from ._emoji_replace import _emoji_replace
 from .jupyter import JupyterMixin
 from .segment import Segment
 from .style import Style
-from ._emoji_codes import EMOJI
-from ._emoji_replace import _emoji_replace
-
 
 if TYPE_CHECKING:
     from .console import Console, ConsoleOptions, RenderResult
@@ -22,7 +20,7 @@ class NoEmoji(Exception):
 class Emoji(JupyterMixin):
     __slots__ = ["name", "style", "_char", "variant"]
 
-    VARIANTS = {"text": "\uFE0E", "emoji": "\uFE0F"}
+    VARIANTS = {"text": "\ufe0e", "emoji": "\ufe0f"}
 
     def __init__(
         self,
@@ -39,6 +37,8 @@ def __init__(
         Raises:
             NoEmoji: If the emoji doesn't exist.
         """
+        from ._emoji_codes import EMOJI
+
         self.name = name
         self.style = style
         self.variant = variant
@@ -81,8 +81,10 @@ def __rich_console__(
 
     console = Console(record=True)
 
+    from ._emoji_codes import EMOJI
+
     columns = Columns(
-        (f":{name}: {name}" for name in sorted(EMOJI.keys()) if "\u200D" not in name),
+        (f":{name}: {name}" for name in sorted(EMOJI.keys()) if "\u200d" not in name),
         column_first=True,
     )
 
--- a/rich/logging.py
+++ b/rich/logging.py
@@ -1,18 +1,24 @@
+from __future__ import annotations
+
 import logging
+import os
 from datetime import datetime
 from logging import Handler, LogRecord
-from pathlib import Path
 from types import ModuleType
-from typing import ClassVar, Iterable, List, Optional, Type, Union
+from typing import TYPE_CHECKING, ClassVar, Iterable, List, Optional, Type, Union
+
+if TYPE_CHECKING:
+    from ._log_render import FormatTimeCallable
+    from .console import Console, ConsoleRenderable
+    from .highlighter import Highlighter
+    from .traceback import Traceback
 
 from rich._null_file import NullFile
 
 from . import get_console
-from ._log_render import FormatTimeCallable, LogRender
-from .console import Console, ConsoleRenderable
-from .highlighter import Highlighter, ReprHighlighter
+from ._log_render import LogRender
+from .highlighter import ReprHighlighter
 from .text import Text
-from .traceback import Traceback
 
 
 class RichHandler(Handler):
@@ -141,6 +147,8 @@ def emit(self, record: LogRecord) -> None:
             exc_type, exc_value, exc_traceback = record.exc_info
             assert exc_type is not None
             assert exc_value is not None
+            from .traceback import Traceback
+
             traceback = Traceback.from_exception(
                 exc_type,
                 exc_value,
@@ -179,7 +187,7 @@ def emit(self, record: LogRecord) -> None:
             except Exception:
                 self.handleError(record)
 
-    def render_message(self, record: LogRecord, message: str) -> "ConsoleRenderable":
+    def render_message(self, record: LogRecord, message: str) -> ConsoleRenderable:
         """Render message text in to Text.
 
         Args:
@@ -209,8 +217,8 @@ def render(
         *,
         record: LogRecord,
         traceback: Optional[Traceback],
-        message_renderable: "ConsoleRenderable",
-    ) -> "ConsoleRenderable":
+        message_renderable: ConsoleRenderable,
+    ) -> ConsoleRenderable:
         """Render log for display.
 
         Args:
@@ -221,7 +229,7 @@ def render(
         Returns:
             ConsoleRenderable: Renderable to display log.
         """
-        path = Path(record.pathname).name
+        path = os.path.basename(record.pathname)
         level = self.get_level_text(record)
         time_format = None if self.formatter is None else self.formatter.datefmt
         log_time = datetime.fromtimestamp(record.created)
--- a/rich/protocol.py
+++ b/rich/protocol.py
@@ -1,5 +1,4 @@
 from typing import Any, cast, Set, TYPE_CHECKING
-from inspect import isclass
 
 if TYPE_CHECKING:
     from rich.console import RenderableType
@@ -28,7 +27,7 @@ def rich_cast(renderable: object) -> "RenderableType":
     from rich.console import RenderableType
 
     rich_visited_set: Set[type] = set()  # Prevent potential infinite loop
-    while hasattr(renderable, "__rich__") and not isclass(renderable):
+    while hasattr(renderable, "__rich__") and not isinstance(renderable, type):
         # Detect object which claim to have all the attributes
         if hasattr(renderable, _GIBBERISH):
             return repr(renderable)
--- a/rich/repr.py
+++ b/rich/repr.py
@@ -1,4 +1,3 @@
-import inspect
 from functools import partial
 from typing import (
     Any,
@@ -68,6 +67,8 @@ def auto_repr(self: T) -> str:
         def auto_rich_repr(self: Type[T]) -> Result:
             """Auto generate __rich_rep__ from signature of __init__"""
             try:
+                import inspect
+
                 signature = inspect.signature(self.__init__)
                 for name, param in signature.parameters.items():
                     if param.kind == param.POSITIONAL_ONLY:
--- a/rich/segment.py
+++ b/rich/segment.py
@@ -1,7 +1,6 @@
 from enum import IntEnum
 from functools import lru_cache
 from itertools import filterfalse
-from logging import getLogger
 from operator import attrgetter
 from typing import (
     TYPE_CHECKING,
@@ -29,8 +28,6 @@
 if TYPE_CHECKING:
     from .console import Console, ConsoleOptions, RenderResult
 
-log = getLogger("rich")
-
 
 class ControlType(IntEnum):
     """Non-printable control codes which typically translate to ANSI codes."""
--- a/rich/syntax.py
+++ b/rich/syntax.py
@@ -7,6 +7,7 @@
 from abc import ABC, abstractmethod
 from pathlib import Path
 from typing import (
+    TYPE_CHECKING,
     Any,
     Dict,
     Iterable,
@@ -38,13 +39,15 @@
 )
 from pygments.util import ClassNotFound
 
+if TYPE_CHECKING:
+    from .console import Console, ConsoleOptions, JustifyMethod, RenderResult
+
 from rich.containers import Lines
 from rich.padding import Padding, PaddingDimensions
 
 from ._loop import loop_first
 from .cells import cell_len
 from .color import Color, blend_rgb
-from .console import Console, ConsoleOptions, JustifyMethod, RenderResult
 from .jupyter import JupyterMixin
 from .measure import Measurement
 from .segment import Segment, Segments
--- a/rich/theme.py
+++ b/rich/theme.py
@@ -1,4 +1,3 @@
-import configparser
 from typing import IO, Dict, List, Mapping, Optional
 
 from .default_styles import DEFAULT_STYLES
@@ -49,6 +48,8 @@ def from_file(
         Returns:
             Theme: A New theme instance.
         """
+        import configparser
+
         config = configparser.ConfigParser()
         config.read_file(config_file, source=source)
         styles = {name: Style.parse(value) for name, value in config.items("styles")}

Test output

show
........................................................................ [ 58%]
....................................................                     [100%]
124 passed in 1.32s