Skip to content

Commit e76dbad

Browse files
committed
Reduced amount of style characters carried over from previous lines when aligning text.
Also reduced amount of style characters appended to truncated text. These changes were made to reduce memory usage in certain use cases of tables (e.g. nested colored tables).
1 parent 4621b05 commit e76dbad

File tree

8 files changed

+335
-61
lines changed

8 files changed

+335
-61
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
## 2.4.0 (TBD, 2021)
1+
## 2.4.0 (TBD, 2022)
22
* Bug Fixes
33
* Fixed issue in `ansi.async_alert_str()` which would raise `IndexError` if prompt was blank.
44
* Fixed issue where tab completion was quoting argparse flags in some cases.
55
* Enhancements
66
* Added broader exception handling when enabling clipboard functionality via `pyperclip`.
77
* Added `PassThroughException` to `__init__.py` imports.
88
* cmd2 now uses pyreadline3 when running any version of Python on Windows
9+
* Improved memory usage in certain use cases of tables (e.g. nested colored tables)
910
* Deletions (potentially breaking changes)
1011
* Deleted `cmd2.fg` and `cmd2.bg` which were deprecated in 2.3.0. Use `cmd2.Fg` and `cmd2.Bg` instead.
1112

cmd2/ansi.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@
2323
#######################################################
2424
# Common ANSI escape sequence constants
2525
#######################################################
26-
CSI = '\033['
27-
OSC = '\033]'
26+
ESC = '\x1b'
27+
CSI = f'{ESC}['
28+
OSC = f'{ESC}]'
2829
BEL = '\a'
2930

3031

@@ -60,8 +61,26 @@ def __repr__(self) -> str:
6061
The default is ``AllowStyle.TERMINAL``.
6162
"""
6263

63-
# Regular expression to match ANSI style sequences (including 8-bit and 24-bit colors)
64-
ANSI_STYLE_RE = re.compile(r'\x1b\[[^m]*m')
64+
# Regular expression to match ANSI style sequence
65+
ANSI_STYLE_RE = re.compile(fr'{ESC}\[[^m]*m')
66+
67+
# Matches standard foreground colors: CSI(30-37|90-97|39)m
68+
STD_FG_RE = re.compile(fr'{ESC}\[(?:[39][0-7]|39)m')
69+
70+
# Matches standard background colors: CSI(40-47|100-107|49)m
71+
STD_BG_RE = re.compile(fr'{ESC}\[(?:(?:4|10)[0-7]|49)m')
72+
73+
# Matches eight-bit foreground colors: CSI38;5;(0-255)m
74+
EIGHT_BIT_FG_RE = re.compile(fr'{ESC}\[38;5;(?:1?[0-9]?[0-9]?|2[0-4][0-9]|25[0-5])m')
75+
76+
# Matches eight-bit background colors: CSI48;5;(0-255)m
77+
EIGHT_BIT_BG_RE = re.compile(fr'{ESC}\[48;5;(?:1?[0-9]?[0-9]?|2[0-4][0-9]|25[0-5])m')
78+
79+
# Matches RGB foreground colors: CSI38;2;(0-255);(0-255);(0-255)m
80+
RGB_FG_RE = re.compile(fr'{ESC}\[38;2(?:;(?:1?[0-9]?[0-9]?|2[0-4][0-9]|25[0-5])){{3}}m')
81+
82+
# Matches RGB background colors: CSI48;2;(0-255);(0-255);(0-255)m
83+
RGB_BG_RE = re.compile(fr'{ESC}\[48;2(?:;(?:1?[0-9]?[0-9]?|2[0-4][0-9]|25[0-5])){{3}}m')
6584

6685

6786
def strip_style(text: str) -> str:
@@ -240,6 +259,7 @@ class TextStyle(AnsiSequence, Enum):
240259

241260
# Resets all styles and colors of text
242261
RESET_ALL = 0
262+
ALT_RESET_ALL = ''
243263

244264
INTENSITY_BOLD = 1
245265
INTENSITY_DIM = 2
@@ -606,7 +626,7 @@ def __str__(self) -> str:
606626
This is helpful when using an EightBitFg in an f-string or format() call
607627
e.g. my_str = f"{EightBitFg.SLATE_BLUE_1}hello{Fg.RESET}"
608628
"""
609-
return f"{CSI}{38};5;{self.value}m"
629+
return f"{CSI}38;5;{self.value}m"
610630

611631

612632
class EightBitBg(BgColor, Enum):
@@ -879,7 +899,7 @@ def __str__(self) -> str:
879899
This is helpful when using an EightBitBg in an f-string or format() call
880900
e.g. my_str = f"{EightBitBg.KHAKI_3}hello{Bg.RESET}"
881901
"""
882-
return f"{CSI}{48};5;{self.value}m"
902+
return f"{CSI}48;5;{self.value}m"
883903

884904

885905
class RgbFg(FgColor):
@@ -900,7 +920,7 @@ def __init__(self, r: int, g: int, b: int) -> None:
900920
if any(c < 0 or c > 255 for c in [r, g, b]):
901921
raise ValueError("RGB values must be integers in the range of 0 to 255")
902922

903-
self._sequence = f"{CSI}{38};2;{r};{g};{b}m"
923+
self._sequence = f"{CSI}38;2;{r};{g};{b}m"
904924

905925
def __str__(self) -> str:
906926
"""
@@ -929,7 +949,7 @@ def __init__(self, r: int, g: int, b: int) -> None:
929949
if any(c < 0 or c > 255 for c in [r, g, b]):
930950
raise ValueError("RGB values must be integers in the range of 0 to 255")
931951

932-
self._sequence = f"{CSI}{48};2;{r};{g};{b}m"
952+
self._sequence = f"{CSI}48;2;{r};{g};{b}m"
933953

934954
def __str__(self) -> str:
935955
"""

cmd2/decorators.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,13 +238,13 @@ def _set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None:
238238
break
239239

240240

241-
#: Function signature for an Command Function that uses an argparse.ArgumentParser to process user input
241+
#: Function signature for a Command Function that uses an argparse.ArgumentParser to process user input
242242
#: and optionally returns a boolean
243243
ArgparseCommandFuncOptionalBoolReturn = Union[
244244
Callable[['cmd2.Cmd', argparse.Namespace], Optional[bool]],
245245
Callable[[CommandSet, argparse.Namespace], Optional[bool]],
246246
]
247-
#: Function signature for an Command Function that uses an argparse.ArgumentParser to process user input
247+
#: Function signature for a Command Function that uses an argparse.ArgumentParser to process user input
248248
#: and returns a boolean
249249
ArgparseCommandFuncBoolReturn = Union[
250250
Callable[['cmd2.Cmd', argparse.Namespace], bool],

cmd2/table_creator.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ def _wrap_long_word(word: str, max_width: int, max_lines: Union[int, float], is_
165165
:param is_last_word: True if this is the last word of the total text being wrapped
166166
:return: Tuple(wrapped text, lines used, display width of last line)
167167
"""
168-
styles = utils.get_styles_in_text(word)
168+
styles_dict = utils.get_styles_dict(word)
169169
wrapped_buf = io.StringIO()
170170

171171
# How many lines we've used
@@ -190,9 +190,9 @@ def _wrap_long_word(word: str, max_width: int, max_lines: Union[int, float], is_
190190
break
191191

192192
# Check if we're at a style sequence. These don't count toward display width.
193-
if char_index in styles:
194-
wrapped_buf.write(styles[char_index])
195-
char_index += len(styles[char_index])
193+
if char_index in styles_dict:
194+
wrapped_buf.write(styles_dict[char_index])
195+
char_index += len(styles_dict[char_index])
196196
continue
197197

198198
cur_char = word[char_index]
@@ -330,7 +330,7 @@ def add_word(word_to_add: str, is_last_word: bool) -> None:
330330
break
331331

332332
# Locate the styles in this line
333-
styles = utils.get_styles_in_text(data_line)
333+
styles_dict = utils.get_styles_dict(data_line)
334334

335335
# Display width of the current line we are building
336336
cur_line_width = 0
@@ -344,9 +344,9 @@ def add_word(word_to_add: str, is_last_word: bool) -> None:
344344
break
345345

346346
# Check if we're at a style sequence. These don't count toward display width.
347-
if char_index in styles:
348-
cur_word_buf.write(styles[char_index])
349-
char_index += len(styles[char_index])
347+
if char_index in styles_dict:
348+
cur_word_buf.write(styles_dict[char_index])
349+
char_index += len(styles_dict[char_index])
350350
continue
351351

352352
cur_char = data_line[char_index]
@@ -391,7 +391,7 @@ def _generate_cell_lines(self, cell_data: Any, is_header: bool, col: Column, fil
391391
:param col: Column definition for this cell
392392
:param fill_char: character that fills remaining space in a cell. If your text has a background color,
393393
then give fill_char the same background color. (Cannot be a line breaking character)
394-
:return: Tuple of cell lines deque and the display width of the cell
394+
:return: Tuple(deque of cell lines, display width of the cell)
395395
"""
396396
# Convert data to string and replace tabs with spaces
397397
data_str = str(cell_data).replace('\t', SPACE * self.tab_width)
@@ -411,8 +411,10 @@ def _generate_cell_lines(self, cell_data: Any, is_header: bool, col: Column, fil
411411

412412
aligned_text = utils.align_text(wrapped_text, fill_char=fill_char, width=col.width, alignment=text_alignment)
413413

414-
lines = deque(aligned_text.splitlines())
414+
# Calculate cell_width first to avoid having 2 copies of aligned_text.splitlines() in memory
415415
cell_width = ansi.widest_line(aligned_text)
416+
lines = deque(aligned_text.splitlines())
417+
416418
return lines, cell_width
417419

418420
def generate_row(

cmd2/utils.py

Lines changed: 105 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,87 @@ def __init__(
737737
self.saved_redirecting = saved_redirecting
738738

739739

740+
def _remove_overridden_styles(styles_to_parse: List[str]) -> List[str]:
741+
"""
742+
Utility function for align_text() / truncate_line() which filters a style list down
743+
to only those which would still be in effect if all were processed in order.
744+
745+
This is mainly used to reduce how many style strings are stored in memory when
746+
building large multiline strings with ANSI styles. We only need to carry over
747+
styles from previous lines that are still in effect.
748+
749+
:param styles_to_parse: list of styles to evaluate.
750+
:return: list of styles that are still in effect.
751+
"""
752+
from . import (
753+
ansi,
754+
)
755+
756+
class StyleState:
757+
"""Keeps track of what text styles are enabled"""
758+
759+
def __init__(self) -> None:
760+
# Contains styles still in effect, keyed by their index in styles_to_parse
761+
self.style_dict: Dict[int, str] = dict()
762+
763+
# Indexes into style_dict
764+
self.reset_all: Optional[int] = None
765+
self.fg: Optional[int] = None
766+
self.bg: Optional[int] = None
767+
self.intensity: Optional[int] = None
768+
self.italic: Optional[int] = None
769+
self.overline: Optional[int] = None
770+
self.strikethrough: Optional[int] = None
771+
self.underline: Optional[int] = None
772+
773+
# Read the previous styles in order and keep track of their states
774+
style_state = StyleState()
775+
776+
for index, style in enumerate(styles_to_parse):
777+
# For styles types that we recognize, only keep their latest value from styles_to_parse.
778+
# All unrecognized style types will be retained and their order preserved.
779+
if style in (str(ansi.TextStyle.RESET_ALL), str(ansi.TextStyle.ALT_RESET_ALL)):
780+
style_state = StyleState()
781+
style_state.reset_all = index
782+
elif ansi.STD_FG_RE.match(style) or ansi.EIGHT_BIT_FG_RE.match(style) or ansi.RGB_FG_RE.match(style):
783+
if style_state.fg is not None:
784+
style_state.style_dict.pop(style_state.fg)
785+
style_state.fg = index
786+
elif ansi.STD_BG_RE.match(style) or ansi.EIGHT_BIT_BG_RE.match(style) or ansi.RGB_BG_RE.match(style):
787+
if style_state.bg is not None:
788+
style_state.style_dict.pop(style_state.bg)
789+
style_state.bg = index
790+
elif style in (
791+
str(ansi.TextStyle.INTENSITY_BOLD),
792+
str(ansi.TextStyle.INTENSITY_DIM),
793+
str(ansi.TextStyle.INTENSITY_NORMAL),
794+
):
795+
if style_state.intensity is not None:
796+
style_state.style_dict.pop(style_state.intensity)
797+
style_state.intensity = index
798+
elif style in (str(ansi.TextStyle.ITALIC_ENABLE), str(ansi.TextStyle.ITALIC_DISABLE)):
799+
if style_state.italic is not None:
800+
style_state.style_dict.pop(style_state.italic)
801+
style_state.italic = index
802+
elif style in (str(ansi.TextStyle.OVERLINE_ENABLE), str(ansi.TextStyle.OVERLINE_DISABLE)):
803+
if style_state.overline is not None:
804+
style_state.style_dict.pop(style_state.overline)
805+
style_state.overline = index
806+
elif style in (str(ansi.TextStyle.STRIKETHROUGH_ENABLE), str(ansi.TextStyle.STRIKETHROUGH_DISABLE)):
807+
if style_state.strikethrough is not None:
808+
style_state.style_dict.pop(style_state.strikethrough)
809+
style_state.strikethrough = index
810+
elif style in (str(ansi.TextStyle.UNDERLINE_ENABLE), str(ansi.TextStyle.UNDERLINE_DISABLE)):
811+
if style_state.underline is not None:
812+
style_state.style_dict.pop(style_state.underline)
813+
style_state.underline = index
814+
815+
# Store this style and its location in the dictionary
816+
style_state.style_dict[index] = style
817+
818+
return list(style_state.style_dict.values())
819+
820+
740821
class TextAlignment(Enum):
741822
"""Horizontal text alignment"""
742823

@@ -801,7 +882,7 @@ def align_text(
801882
raise (ValueError("Fill character is an unprintable character"))
802883

803884
# Isolate the style chars before and after the fill character. We will use them when building sequences of
804-
# of fill characters. Instead of repeating the style characters for each fill character, we'll wrap each sequence.
885+
# fill characters. Instead of repeating the style characters for each fill character, we'll wrap each sequence.
805886
fill_char_style_begin, fill_char_style_end = fill_char.split(stripped_fill_char)
806887

807888
if text:
@@ -811,10 +892,10 @@ def align_text(
811892

812893
text_buf = io.StringIO()
813894

814-
# ANSI style sequences that may affect future lines will be cancelled by the fill_char's style.
815-
# To avoid this, we save the state of a line's style so we can restore it when beginning the next line.
816-
# This also allows the lines to be used independently and still have their style. TableCreator does this.
817-
aggregate_styles = ''
895+
# ANSI style sequences that may affect subsequent lines will be cancelled by the fill_char's style.
896+
# To avoid this, we save styles which are still in effect so we can restore them when beginning the next line.
897+
# This also allows lines to be used independently and still have their style. TableCreator does this.
898+
previous_styles: List[str] = []
818899

819900
for index, line in enumerate(lines):
820901
if index > 0:
@@ -827,8 +908,8 @@ def align_text(
827908
if line_width == -1:
828909
raise (ValueError("Text to align contains an unprintable character"))
829910

830-
# Get the styles in this line
831-
line_styles = get_styles_in_text(line)
911+
# Get list of styles in this line
912+
line_styles = list(get_styles_dict(line).values())
832913

833914
# Calculate how wide each side of filling needs to be
834915
if line_width >= width:
@@ -858,7 +939,7 @@ def align_text(
858939
right_fill += ' ' * (right_fill_width - ansi.style_aware_wcswidth(right_fill))
859940

860941
# Don't allow styles in fill characters and text to affect one another
861-
if fill_char_style_begin or fill_char_style_end or aggregate_styles or line_styles:
942+
if fill_char_style_begin or fill_char_style_end or previous_styles or line_styles:
862943
if left_fill:
863944
left_fill = ansi.TextStyle.RESET_ALL + fill_char_style_begin + left_fill + fill_char_style_end
864945
left_fill += ansi.TextStyle.RESET_ALL
@@ -867,11 +948,12 @@ def align_text(
867948
right_fill = ansi.TextStyle.RESET_ALL + fill_char_style_begin + right_fill + fill_char_style_end
868949
right_fill += ansi.TextStyle.RESET_ALL
869950

870-
# Write the line and restore any styles from previous lines
871-
text_buf.write(left_fill + aggregate_styles + line + right_fill)
951+
# Write the line and restore styles from previous lines which are still in effect
952+
text_buf.write(left_fill + ''.join(previous_styles) + line + right_fill)
872953

873-
# Update the aggregate with styles in this line
874-
aggregate_styles += ''.join(line_styles.values())
954+
# Update list of styles that are still in effect for the next line
955+
previous_styles.extend(line_styles)
956+
previous_styles = _remove_overridden_styles(previous_styles)
875957

876958
return text_buf.getvalue()
877959

@@ -985,7 +1067,7 @@ def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str:
9851067
return line
9861068

9871069
# Find all style sequences in the line
988-
styles = get_styles_in_text(line)
1070+
styles_dict = get_styles_dict(line)
9891071

9901072
# Add characters one by one and preserve all style sequences
9911073
done = False
@@ -995,10 +1077,10 @@ def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str:
9951077

9961078
while not done:
9971079
# Check if a style sequence is at this index. These don't count toward display width.
998-
if index in styles:
999-
truncated_buf.write(styles[index])
1000-
style_len = len(styles[index])
1001-
styles.pop(index)
1080+
if index in styles_dict:
1081+
truncated_buf.write(styles_dict[index])
1082+
style_len = len(styles_dict[index])
1083+
styles_dict.pop(index)
10021084
index += style_len
10031085
continue
10041086

@@ -1015,13 +1097,16 @@ def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str:
10151097
truncated_buf.write(char)
10161098
index += 1
10171099

1018-
# Append remaining style sequences from original string
1019-
truncated_buf.write(''.join(styles.values()))
1100+
# Filter out overridden styles from the remaining ones
1101+
remaining_styles = _remove_overridden_styles(list(styles_dict.values()))
1102+
1103+
# Append the remaining styles to the truncated text
1104+
truncated_buf.write(''.join(remaining_styles))
10201105

10211106
return truncated_buf.getvalue()
10221107

10231108

1024-
def get_styles_in_text(text: str) -> Dict[int, str]:
1109+
def get_styles_dict(text: str) -> Dict[int, str]:
10251110
"""
10261111
Return an OrderedDict containing all ANSI style sequences found in a string
10271112

docs/features/argument_processing.rst

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,18 +82,13 @@ Here's what it looks like::
8282
to bugs in CPython prior to Python 3.7 which make it impossible to make a
8383
deep copy of an instance of a ``argparse.ArgumentParser``.
8484

85-
See the table_display_ example for a work-around that demonstrates how to
86-
create a function which returns a unique instance of the parser you want.
87-
8885

8986
.. note::
9087

9188
The ``@with_argparser`` decorator sets the ``prog`` variable in the argument
9289
parser based on the name of the method it is decorating. This will override
9390
anything you specify in ``prog`` variable when creating the argument parser.
9491

95-
.. _table_display: https://github.com/python-cmd2/cmd2/blob/master/examples/table_display.py
96-
9792

9893
Help Messages
9994
-------------

0 commit comments

Comments
 (0)