From 69790a90a9388184132f4203f2a86059c263bab5 Mon Sep 17 00:00:00 2001 From: Kevin Turcios Date: Thu, 9 Apr 2026 02:21:08 -0500 Subject: [PATCH 1/3] perf: defer colorsys, _palettes, terminal_theme imports in color.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `from __future__ import annotations` to color.py - Move `from colorsys import rgb_to_hls` to inline in downgrade() - Move `from ._palettes import ...` to inline in get_truecolor() and downgrade() — palette data only needed at runtime, not import time - Move `from .terminal_theme import DEFAULT_TERMINAL_THEME` to inline in get_truecolor() — only needed as default arg fallback - Move `Result` to TYPE_CHECKING (annotation-only with future annotations) These are all loaded on cache misses in LRU-cached methods, so the inline import overhead is negligible after warmup. --- rich/color.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/rich/color.py b/rich/color.py index e2c23a6a91..41e7c315db 100644 --- a/rich/color.py +++ b/rich/color.py @@ -1,14 +1,18 @@ +from __future__ import annotations + import re import sys -from colorsys import rgb_to_hls from enum import IntEnum from functools import lru_cache from typing import TYPE_CHECKING, NamedTuple, Optional, Tuple -from ._palettes import EIGHT_BIT_PALETTE, STANDARD_PALETTE, WINDOWS_PALETTE from .color_triplet import ColorTriplet -from .repr import Result, rich_repr -from .terminal_theme import DEFAULT_TERMINAL_THEME +from .repr import rich_repr + +if TYPE_CHECKING: + from .repr import Result + from .terminal_theme import TerminalTheme + from .text import Text if TYPE_CHECKING: # pragma: no cover from .terminal_theme import TerminalTheme @@ -360,7 +364,11 @@ def get_truecolor( """ if theme is None: + from .terminal_theme import DEFAULT_TERMINAL_THEME + theme = DEFAULT_TERMINAL_THEME + from ._palettes import EIGHT_BIT_PALETTE, WINDOWS_PALETTE + if self.type == ColorType.TRUECOLOR: assert self.triplet is not None return self.triplet @@ -515,6 +523,10 @@ def downgrade(self, system: ColorSystem) -> "Color": if self.type in (ColorType.DEFAULT, system): return self + from colorsys import rgb_to_hls + + from ._palettes import EIGHT_BIT_PALETTE, STANDARD_PALETTE, WINDOWS_PALETTE + # Convert to 8-bit color from truecolor color if system == ColorSystem.EIGHT_BIT and self.system == ColorSystem.TRUECOLOR: assert self.triplet is not None @@ -613,6 +625,8 @@ def blend_rgb( if color_number < 16: table.add_row(color_cell, f"{color_number}", Text(f'"{name}"')) else: + from ._palettes import EIGHT_BIT_PALETTE + color = EIGHT_BIT_PALETTE[color_number] # type: ignore[has-type] table.add_row( color_cell, str(color_number), Text(f'"{name}"'), color.hex, color.rgb From 118efe2156585337292ecae5a7564586daabfe50 Mon Sep 17 00:00:00 2001 From: Kevin Turcios Date: Thu, 9 Apr 2026 02:42:30 -0500 Subject: [PATCH 2/3] perf: defer `re` module from Console import chain Remove `re` from module-level imports in all files on the Console import path (color.py, text.py, markup.py, _wrap.py, _emoji_replace.py, highlighter.py). Regex patterns are now compiled lazily on first use via module-level sentinels. Also move `typing.Match` and `typing.Pattern` (which trigger `re` import) to TYPE_CHECKING blocks, and replace three `rgb(...)` color strings in default_styles.py with `Color.from_rgb()` to avoid regex-based Color.parse(). This eliminates `re` (and its `_sre`, `re._compiler`, `re._parser`, `re._constants` dependencies) from the `from rich.console import Console` import chain entirely. --- rich/_emoji_replace.py | 21 ++++++++++------- rich/_wrap.py | 12 ++++++---- rich/color.py | 24 +++++++++++--------- rich/default_styles.py | 7 +++--- rich/highlighter.py | 3 ++- rich/markup.py | 51 ++++++++++++++++++++++++++++++------------ rich/text.py | 20 +++++++++++++---- tests/test_markup.py | 32 +++++++++++++------------- 8 files changed, 110 insertions(+), 60 deletions(-) diff --git a/rich/_emoji_replace.py b/rich/_emoji_replace.py index 9f0900b101..49613157ef 100644 --- a/rich/_emoji_replace.py +++ b/rich/_emoji_replace.py @@ -1,14 +1,16 @@ from __future__ import annotations -from typing import Any, Callable, Dict, Match, Optional -import re +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional +if TYPE_CHECKING: + from typing import Match -_ReStringMatch = Match[str] # regex match object -_ReSubCallable = Callable[[_ReStringMatch], str] # Callable invoked by re.sub -_EmojiSubMethod = Callable[[_ReSubCallable, str], str] # Sub method of a compiled re +_ReStringMatch = "Match[str]" # regex match object +_ReSubCallable = "Callable[[_ReStringMatch], str]" # Callable invoked by re.sub +_EmojiSubMethod = "Callable[[_ReSubCallable, str], str]" # Sub method of a compiled re _EMOJI = None +_EMOJI_SUB = None _VARIANTS = {"text": "\uFE0E", "emoji": "\uFE0F"} @@ -31,14 +33,17 @@ def __getattr__(name: str) -> Any: def _emoji_replace( text: str, default_variant: Optional[str] = None, - _emoji_sub: _EmojiSubMethod = re.compile(r"(:(\S*?)(?:(?:\-)(emoji|text))?:)").sub, ) -> str: """Replace emoji code in text.""" - global _EMOJI + global _EMOJI, _EMOJI_SUB if _EMOJI is None: from ._emoji_codes import EMOJI _EMOJI = EMOJI + if _EMOJI_SUB is None: + import re + + _EMOJI_SUB = re.compile(r"(:(\S*?)(?:(?:\-)(emoji|text))?:)").sub get_emoji = _EMOJI.__getitem__ get_variant = _VARIANTS.get default_variant_code = _VARIANTS.get(default_variant, "") if default_variant else "" @@ -52,4 +57,4 @@ def do_replace(match: Match[str]) -> str: except KeyError: return emoji_code - return _emoji_sub(do_replace, text) + return _EMOJI_SUB(do_replace, text) diff --git a/rich/_wrap.py b/rich/_wrap.py index 2e94ff6f43..3d781f7fea 100644 --- a/rich/_wrap.py +++ b/rich/_wrap.py @@ -1,12 +1,11 @@ from __future__ import annotations -import re from typing import Iterable from ._loop import loop_last from .cells import cell_len, chop_cells -re_word = re.compile(r"\s*\S+\s*") +_re_word = None def words(text: str) -> Iterable[tuple[int, int, str]]: @@ -14,13 +13,18 @@ def words(text: str) -> Iterable[tuple[int, int, str]]: containing (start_index, end_index, word). A "word" in this context may include the actual word and any whitespace to the right. """ + global _re_word + if _re_word is None: + import re + + _re_word = re.compile(r"\s*\S+\s*") position = 0 - word_match = re_word.match(text, position) + word_match = _re_word.match(text, position) while word_match is not None: start, end = word_match.span() word = word_match.group(0) yield start, end, word - word_match = re_word.match(text, end) + word_match = _re_word.match(text, end) def divide_line(text: str, width: int, fold: bool = True) -> list[int]: diff --git a/rich/color.py b/rich/color.py index 41e7c315db..8cec75c90b 100644 --- a/rich/color.py +++ b/rich/color.py @@ -1,6 +1,5 @@ from __future__ import annotations -import re import sys from enum import IntEnum from functools import lru_cache @@ -293,14 +292,7 @@ class ColorParseError(Exception): """The color could not be parsed.""" -RE_COLOR = re.compile( - r"""^ -\#([0-9a-f]{6})$| -color\(([0-9]{1,3})\)$| -rgb\(([\d\s,]+)\)$ -""", - re.VERBOSE, -) +_RE_COLOR = None @rich_repr @@ -454,7 +446,19 @@ def parse(cls, color: str) -> "Color": number=color_number, ) - color_match = RE_COLOR.match(color) + global _RE_COLOR + if _RE_COLOR is None: + import re + + _RE_COLOR = re.compile( + r"""^ +\#([0-9a-f]{6})$| +color\(([0-9]{1,3})\)$| +rgb\(([\d\s,]+)\)$ +""", + re.VERBOSE, + ) + color_match = _RE_COLOR.match(color) if color_match is None: raise ColorParseError(f"{original_color!r} is not a valid color") diff --git a/rich/default_styles.py b/rich/default_styles.py index c18b6095e4..bf2ec7001b 100644 --- a/rich/default_styles.py +++ b/rich/default_styles.py @@ -1,5 +1,6 @@ from typing import Dict +from .color import Color from .style import Style DEFAULT_STYLES: Dict[str, Style] = { @@ -124,9 +125,9 @@ "traceback.note": Style(color="green", bold=True), "traceback.group.border": Style(color="magenta"), "bar.back": Style(color="grey23"), - "bar.complete": Style(color="rgb(249,38,114)"), - "bar.finished": Style(color="rgb(114,156,31)"), - "bar.pulse": Style(color="rgb(249,38,114)"), + "bar.complete": Style(color=Color.from_rgb(249, 38, 114)), + "bar.finished": Style(color=Color.from_rgb(114, 156, 31)), + "bar.pulse": Style(color=Color.from_rgb(249, 38, 114)), "progress.description": Style.null(), "progress.filesize": Style(color="green"), "progress.filesize.total": Style(color="green"), diff --git a/rich/highlighter.py b/rich/highlighter.py index df28048f88..2df6c0f9a9 100644 --- a/rich/highlighter.py +++ b/rich/highlighter.py @@ -1,4 +1,3 @@ -import re from abc import ABC, abstractmethod from typing import ClassVar, Sequence, Union @@ -123,6 +122,8 @@ class JSONHighlighter(RegexHighlighter): def highlight(self, text: Text) -> None: super().highlight(text) + import re + # Additional work to handle highlighting JSON keys plain = text.plain append = text.spans.append diff --git a/rich/markup.py b/rich/markup.py index 3e6f012a8d..994e770101 100644 --- a/rich/markup.py +++ b/rich/markup.py @@ -1,6 +1,10 @@ -import re +from __future__ import annotations + from operator import attrgetter -from typing import Callable, Iterable, List, Match, NamedTuple, Optional, Tuple, Union +from typing import TYPE_CHECKING, Callable, Iterable, List, NamedTuple, Optional, Tuple, Union + +if TYPE_CHECKING: + from typing import Match from ._emoji_replace import _emoji_replace from .emoji import EmojiVariant @@ -8,12 +12,20 @@ from .style import Style from .text import Span, Text -RE_TAGS = re.compile( - r"""((\\*)\[([a-z#/@][^[]*?)])""", - re.VERBOSE, -) +_RE_TAGS = None +_RE_HANDLER = None + + +def _compile_tags(): + global _RE_TAGS + if _RE_TAGS is None: + import re -RE_HANDLER = re.compile(r"^([\w.]*?)(\(.*?\))?$") + _RE_TAGS = re.compile( + r"""((\\*)\[([a-z#/@][^[]*?)])""", + re.VERBOSE, + ) + return _RE_TAGS class Tag(NamedTuple): @@ -39,14 +51,15 @@ def markup(self) -> str: ) -_ReStringMatch = Match[str] # regex match object -_ReSubCallable = Callable[[_ReStringMatch], str] # Callable invoked by re.sub -_EscapeSubMethod = Callable[[_ReSubCallable, str], str] # Sub method of a compiled re +_ReStringMatch = "Match[str]" # regex match object +_ReSubCallable = "Callable[[_ReStringMatch], str]" # Callable invoked by re.sub +_EscapeSubMethod = "Callable[[_ReSubCallable, str], str]" # Sub method of a compiled re + +_RE_ESCAPE = None def escape( markup: str, - _escape: _EscapeSubMethod = re.compile(r"(\\*)(\[[a-z#/@][^[]*?])").sub, ) -> str: """Escapes text so that it won't be interpreted as markup. @@ -56,13 +69,18 @@ def escape( Returns: str: Markup with square brackets escaped. """ + global _RE_ESCAPE + if _RE_ESCAPE is None: + import re + + _RE_ESCAPE = re.compile(r"(\\*)(\[[a-z#/@][^[]*?])").sub def escape_backslashes(match: Match[str]) -> str: """Called by re.sub replace matches.""" backslashes, text = match.groups() return f"{backslashes}{backslashes}\\{text}" - markup = _escape(escape_backslashes, markup) + markup = _RE_ESCAPE(escape_backslashes, markup) if markup.endswith("\\") and not markup.endswith("\\\\"): return markup + "\\" @@ -79,7 +97,7 @@ def _parse(markup: str) -> Iterable[Tuple[int, Optional[str], Optional[Tag]]]: position = 0 _divmod = divmod _Tag = Tag - for match in RE_TAGS.finditer(markup): + for match in _compile_tags().finditer(markup): full_text, escapes, tag_text = match.groups() start, end = match.span() if start > position: @@ -178,7 +196,12 @@ def pop_style(style_name: str) -> Tuple[int, Tag]: if open_tag.parameters: handler_name = "" parameters = open_tag.parameters.strip() - handler_match = RE_HANDLER.match(parameters) + global _RE_HANDLER + if _RE_HANDLER is None: + import re + + _RE_HANDLER = re.compile(r"^([\w.]*?)(\(.*?\))?$") + handler_match = _RE_HANDLER.match(parameters) if handler_match is not None: handler_name, match_parameters = handler_match.groups() parameters = ( diff --git a/rich/text.py b/rich/text.py index fa6732c6d3..fbeca2e386 100644 --- a/rich/text.py +++ b/rich/text.py @@ -1,4 +1,3 @@ -import re from functools import partial, reduce from math import gcd from operator import itemgetter @@ -11,7 +10,6 @@ List, NamedTuple, Optional, - Pattern, Tuple, Union, ) @@ -36,7 +34,7 @@ DEFAULT_OVERFLOW: "OverflowMethod" = "fold" -_re_whitespace = re.compile(r"\s+$") +_re_whitespace = None TextType = Union[str, "Text"] """A plain string or a :class:`Text` instance.""" @@ -620,7 +618,7 @@ def extend_style(self, spaces: int) -> None: def highlight_regex( self, - re_highlight: Union[Pattern[str], str], + re_highlight: "Union[re.Pattern[str], str]", style: Optional[Union[GetStyleCallable, StyleType]] = None, *, style_prefix: str = "", @@ -642,6 +640,8 @@ def highlight_regex( _Span = Span plain = self.plain if isinstance(re_highlight, str): + import re + re_highlight = re.compile(re_highlight) for match in re_highlight.finditer(plain): get_span = match.span @@ -675,6 +675,8 @@ def highlight_words( Returns: int: Number of words highlighted. """ + import re + re_words = "|".join(re.escape(word) for word in words) add_span = self._spans.append count = 0 @@ -699,6 +701,11 @@ def rstrip_end(self, size: int) -> None: """ text_length = len(self) if text_length > size: + global _re_whitespace + if _re_whitespace is None: + import re + + _re_whitespace = re.compile(r"\s+$") excess = text_length - size whitespace_match = _re_whitespace.search(self.plain) if whitespace_match is not None: @@ -1125,6 +1132,7 @@ def split( List[RichText]: A list of rich text, one per line of the original. """ assert separator, "separator must not be empty" + import re text = self.plain if separator not in text: @@ -1320,6 +1328,8 @@ def detect_indentation(self) -> int: int: Number of spaces used to indent code. """ + import re + _indentations = { len(match.group(1)) for match in re.finditer(r"^( *)(.*)$", self.plain, flags=re.MULTILINE) @@ -1358,6 +1368,8 @@ def with_indent_guides( text.expand_tabs() indent_line = f"{character}{' ' * (_indent_size - 1)}" + import re + re_indent = re.compile(r"^( *)(.*)$") new_lines: List[Text] = [] add_line = new_lines.append diff --git a/tests/test_markup.py b/tests/test_markup.py index 13835faca0..10c17ce4ab 100644 --- a/tests/test_markup.py +++ b/tests/test_markup.py @@ -2,29 +2,29 @@ from rich.console import Console from rich.errors import MarkupError -from rich.markup import RE_TAGS, Tag, _parse, escape, render +from rich.markup import _compile_tags, Tag, _parse, escape, render from rich.text import Span, Text def test_re_no_match(): - assert RE_TAGS.match("[True]") == None - assert RE_TAGS.match("[False]") == None - assert RE_TAGS.match("[None]") == None - assert RE_TAGS.match("[1]") == None - assert RE_TAGS.match("[2]") == None - assert RE_TAGS.match("[]") == None + assert _compile_tags().match("[True]") == None + assert _compile_tags().match("[False]") == None + assert _compile_tags().match("[None]") == None + assert _compile_tags().match("[1]") == None + assert _compile_tags().match("[2]") == None + assert _compile_tags().match("[]") == None def test_re_match(): - assert RE_TAGS.match("[true]") - assert RE_TAGS.match("[false]") - assert RE_TAGS.match("[none]") - assert RE_TAGS.match("[color(1)]") - assert RE_TAGS.match("[#ff00ff]") - assert RE_TAGS.match("[/]") - assert RE_TAGS.match("[@]") - assert RE_TAGS.match("[@foo]") - assert RE_TAGS.match("[@foo=bar]") + assert _compile_tags().match("[true]") + assert _compile_tags().match("[false]") + assert _compile_tags().match("[none]") + assert _compile_tags().match("[color(1)]") + assert _compile_tags().match("[#ff00ff]") + assert _compile_tags().match("[/]") + assert _compile_tags().match("[@]") + assert _compile_tags().match("[@foo]") + assert _compile_tags().match("[@foo=bar]") def test_escape(): From 6b3541598f80ad5e90dad77bae678d2566ede35a Mon Sep 17 00:00:00 2001 From: Kevin Turcios Date: Thu, 9 Apr 2026 02:58:07 -0500 Subject: [PATCH 3/3] perf: runtime micro-optimizations in Style and Segment hot paths - Style.__eq__/__ne__: add identity shortcut (`is`) before hash comparison - Style.combine/chain: use _add (LRU-cached) directly instead of sum() which goes through __add__ + redundant .copy() check per iteration - Segment.simplify: check `is` before `==` for style comparison since adjacent segments very often share the exact same Style object --- rich/segment.py | 2 +- rich/style.py | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/rich/segment.py b/rich/segment.py index 5a76d45ae2..08fe53edf1 100644 --- a/rich/segment.py +++ b/rich/segment.py @@ -565,7 +565,7 @@ def simplify(cls, segments: Iterable["Segment"]) -> Iterable["Segment"]: _Segment = Segment for segment in iter_segments: - if last_segment.style == segment.style and not segment.control: + if (last_segment.style is segment.style or last_segment.style == segment.style) and not segment.control: last_segment = _Segment( last_segment.text + segment.text, last_segment.style ) diff --git a/rich/style.py b/rich/style.py index 9ca332b617..b309b6630b 100644 --- a/rich/style.py +++ b/rich/style.py @@ -438,12 +438,12 @@ def __rich_repr__(self) -> Result: def __eq__(self, other: Any) -> bool: if not isinstance(other, Style): return NotImplemented - return self.__hash__() == other.__hash__() + return self is other or self.__hash__() == other.__hash__() def __ne__(self, other: Any) -> bool: if not isinstance(other, Style): return NotImplemented - return self.__hash__() != other.__hash__() + return self is not other and self.__hash__() != other.__hash__() def __hash__(self) -> int: if self._hash is not None: @@ -624,7 +624,10 @@ def combine(cls, styles: Iterable["Style"]) -> "Style": Style: A new style instance. """ iter_styles = iter(styles) - return sum(iter_styles, next(iter_styles)) + combined = next(iter_styles) + for style in iter_styles: + combined = combined._add(style) + return combined @classmethod def chain(cls, *styles: "Style") -> "Style": @@ -637,7 +640,10 @@ def chain(cls, *styles: "Style") -> "Style": Style: A new style instance. """ iter_styles = iter(styles) - return sum(iter_styles, next(iter_styles)) + combined = next(iter_styles) + for style in iter_styles: + combined = combined._add(style) + return combined def copy(self) -> "Style": """Get a copy of this style.