diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 57049b589..875924a7e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -60,15 +60,19 @@ Nearly all project configuration, including for dependencies and quality tools i See the `dependencies` list under the `[project]` heading in [pyproject.toml](../pyproject.toml). -| Prerequisite | Minimum Version | Purpose | -| --------------------------------------------------- | --------------- | -------------------------------------- | -| [python](https://www.python.org/downloads/) | `3.10` | Python programming language | -| [pyperclip](https://github.com/asweigart/pyperclip) | `1.8` | Cross-platform clipboard functions | -| [wcwidth](https://pypi.python.org/pypi/wcwidth) | `0.2.10` | Measure the displayed width of unicode | +| Prerequisite | Minimum Version | Purpose | +| ---------------------------------------------------------- | --------------- | ------------------------------------------------------ | +| [python](https://www.python.org/downloads/) | `3.10` | Python programming language | +| [pyperclip](https://github.com/asweigart/pyperclip) | `1.8` | Cross-platform clipboard functions | +| [rich](https://github.com/Textualize/rich) | `14.1.0` | Add rich text and beautiful formatting in the terminal | +| [rich-argparse](https://github.com/hamdanal/rich-argparse) | `1.7.1` | A rich-enabled help formatter for argparse | > `macOS` and `Windows` each have an extra dependency to ensure they have a viable alternative to > [readline](https://tiswww.case.edu/php/chet/readline/rltop.html) available. +> Python 3.10 depends on [backports.strenum](https://github.com/clbarnes/backports.strenum) to use +> the `enum.StrEnum` class introduced in Python 3.11. + #### Additional prerequisites to build and publish cmd2 See the `build` list under the `[dependency-groups]` heading in [pyproject.toml](../pyproject.toml) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4fd32f46..83cdd55d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,17 +5,26 @@ - No longer setting parser's `prog` value in `with_argparser()` since it gets set in `Cmd._build_parser()`. This code had previously been restored to support backward compatibility in `cmd2` 2.0 family. + - Removed table_creator.py in favor of `Rich` tables. + - Moved string styling functionality from ansi.py to string_utils.py. + - Moved all string-related functions from utils.py to string_utils.py. + - Removed all text style Enums from ansi.py in favor of `Rich` styles. + - Renamed ansi.py to terminal_utils.py to reflect the functions left in it. - Enhancements - Simplified the process to set a custom parser for `cmd2's` built-in commands. See [custom_parser.py](https://github.com/python-cmd2/cmd2/blob/main/examples/custom_parser.py) example for more details. - - Added `Cmd.macro_arg_complete()` which tab completes arguments to a macro. Its default behavior is to perform path completion, but it can be overridden as needed. - - All print methods (`poutput()`, `perror()`, `ppaged()`, etc.) have the ability to print Rich objects. + - Added string_utils.py which contains all string utility functions. This includes quoting and + alignment functions from utils.py. This also includes style-related functions from ansi.py. + This also includes style-related functions from ansi.py. + - Added colors.py which contains a StrEnum of all color names supported by Rich. + - Added styles.py which contains a StrEnum of all cmd2-specific style names and their respective + style definitions. - Bug Fixes - No longer redirecting `sys.stdout` if it's a different stream than `self.stdout`. This fixes diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 618c04723..e8aebdaf6 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -9,17 +9,7 @@ from . import ( plugin, rich_utils, -) -from .ansi import ( - Bg, - Cursor, - EightBitBg, - EightBitFg, - Fg, - RgbBg, - RgbFg, - TextStyle, - style, + string_utils, ) from .argparse_completer import set_default_ap_completer_type from .argparse_custom import ( @@ -30,6 +20,7 @@ set_default_argument_parser_type, ) from .cmd2 import Cmd +from .colors import Color from .command_definition import ( CommandSet, with_default_category, @@ -53,6 +44,8 @@ ) from .parsing import Statement from .py_bridge import CommandResult +from .string_utils import stylize +from .styles import Cmd2Style from .utils import ( CompletionMode, CustomCompletionSettings, @@ -63,16 +56,6 @@ __all__: list[str] = [ # noqa: RUF022 'COMMAND_NAME', 'DEFAULT_SHORTCUTS', - # ANSI Exports - 'Cursor', - 'Bg', - 'Fg', - 'EightBitBg', - 'EightBitFg', - 'RgbBg', - 'RgbFg', - 'TextStyle', - 'style', # Argparse Exports 'Cmd2ArgumentParser', 'Cmd2AttributeWrapper', @@ -85,6 +68,8 @@ 'CommandResult', 'CommandSet', 'Statement', + # Colors + "Color", # Decorators 'with_argument_list', 'with_argparser', @@ -100,6 +85,11 @@ # modules 'plugin', 'rich_utils', + 'string_utils', + # String Utils + 'stylize', + # Styles, + "Cmd2Style", # Utilities 'categorize', 'CompletionMode', diff --git a/cmd2/ansi.py b/cmd2/ansi.py deleted file mode 100644 index 76c540c8e..000000000 --- a/cmd2/ansi.py +++ /dev/null @@ -1,1065 +0,0 @@ -"""Support for ANSI escape sequences. - -These are used for things like applying style to text, setting the window title, and asynchronous alerts. -""" - -import functools -import re -from enum import ( - Enum, -) -from typing import ( - IO, - Any, - cast, -) - -from wcwidth import ( # type: ignore[import] - wcswidth, -) - -from . import rich_utils - -####################################################### -# Common ANSI escape sequence constants -####################################################### -ESC = '\x1b' -CSI = f'{ESC}[' -OSC = f'{ESC}]' -BEL = '\a' - - -# Regular expression to match ANSI style sequence -ANSI_STYLE_RE = re.compile(rf'{ESC}\[[^m]*m') - -# Matches standard foreground colors: CSI(30-37|90-97|39)m -STD_FG_RE = re.compile(rf'{ESC}\[(?:[39][0-7]|39)m') - -# Matches standard background colors: CSI(40-47|100-107|49)m -STD_BG_RE = re.compile(rf'{ESC}\[(?:(?:4|10)[0-7]|49)m') - -# Matches eight-bit foreground colors: CSI38;5;(0-255)m -EIGHT_BIT_FG_RE = re.compile(rf'{ESC}\[38;5;(?:1?[0-9]?[0-9]?|2[0-4][0-9]|25[0-5])m') - -# Matches eight-bit background colors: CSI48;5;(0-255)m -EIGHT_BIT_BG_RE = re.compile(rf'{ESC}\[48;5;(?:1?[0-9]?[0-9]?|2[0-4][0-9]|25[0-5])m') - -# Matches RGB foreground colors: CSI38;2;(0-255);(0-255);(0-255)m -RGB_FG_RE = re.compile(rf'{ESC}\[38;2(?:;(?:1?[0-9]?[0-9]?|2[0-4][0-9]|25[0-5])){{3}}m') - -# Matches RGB background colors: CSI48;2;(0-255);(0-255);(0-255)m -RGB_BG_RE = re.compile(rf'{ESC}\[48;2(?:;(?:1?[0-9]?[0-9]?|2[0-4][0-9]|25[0-5])){{3}}m') - - -def strip_style(text: str) -> str: - """Strip ANSI style sequences from a string. - - :param text: string which may contain ANSI style sequences - :return: the same string with any ANSI style sequences removed - """ - return ANSI_STYLE_RE.sub('', text) - - -def style_aware_wcswidth(text: str) -> int: - """Wrap wcswidth to make it compatible with strings that contain ANSI style sequences. - - This is intended for single line strings. If text contains a newline, this - function will return -1. For multiline strings, call widest_line() instead. - - :param text: the string being measured - :return: The width of the string when printed to the terminal if no errors occur. - If text contains characters with no absolute width (i.e. tabs), - then this function returns -1. Replace tabs with spaces before calling this. - """ - # Strip ANSI style sequences since they cause wcswidth to return -1 - return cast(int, wcswidth(strip_style(text))) - - -def widest_line(text: str) -> int: - """Return the width of the widest line in a multiline string. - - This wraps style_aware_wcswidth() so it handles ANSI style sequences and has the same - restrictions on non-printable characters. - - :param text: the string being measured - :return: The width of the string when printed to the terminal if no errors occur. - If text contains characters with no absolute width (i.e. tabs), - then this function returns -1. Replace tabs with spaces before calling this. - """ - if not text: - return 0 - - lines_widths = [style_aware_wcswidth(line) for line in text.splitlines()] - if -1 in lines_widths: - return -1 - - return max(lines_widths) - - -def style_aware_write(fileobj: IO[str], msg: str) -> None: - """Write a string to a fileobject and strip its ANSI style sequences if required by allow_style setting. - - :param fileobj: the file object being written to - :param msg: the string being written - """ - if rich_utils.allow_style == rich_utils.AllowStyle.NEVER or ( - rich_utils.allow_style == rich_utils.AllowStyle.TERMINAL and not fileobj.isatty() - ): - msg = strip_style(msg) - - fileobj.write(msg) - - -#################################################################################### -# Utility functions which create various ANSI sequences -#################################################################################### -def set_title(title: str) -> str: - """Generate a string that, when printed, sets a terminal's window title. - - :param title: new title for the window - :return: the set title string - """ - return f"{OSC}2;{title}{BEL}" - - -def clear_screen(clear_type: int = 2) -> str: - """Generate a string that, when printed, clears a terminal screen based on value of clear_type. - - :param clear_type: integer which specifies how to clear the screen (Defaults to 2) - Possible values: - 0 - clear from cursor to end of screen - 1 - clear from cursor to beginning of the screen - 2 - clear entire screen - 3 - clear entire screen and delete all lines saved in the scrollback buffer - :return: the clear screen string - :raises ValueError: if clear_type is not a valid value - """ - if 0 <= clear_type <= 3: - return f"{CSI}{clear_type}J" - raise ValueError("clear_type must in an integer from 0 to 3") - - -def clear_line(clear_type: int = 2) -> str: - """Generate a string that, when printed, clears a line based on value of clear_type. - - :param clear_type: integer which specifies how to clear the line (Defaults to 2) - Possible values: - 0 - clear from cursor to the end of the line - 1 - clear from cursor to beginning of the line - 2 - clear entire line - :return: the clear line string - :raises ValueError: if clear_type is not a valid value - """ - if 0 <= clear_type <= 2: - return f"{CSI}{clear_type}K" - raise ValueError("clear_type must in an integer from 0 to 2") - - -#################################################################################### -# Base classes which are not intended to be used directly -#################################################################################### -class AnsiSequence: - """Base class to create ANSI sequence strings.""" - - def __add__(self, other: Any) -> str: - """Support building an ANSI sequence string when self is the left operand. - - e.g. Fg.LIGHT_MAGENTA + "hello" - """ - return str(self) + str(other) - - def __radd__(self, other: Any) -> str: - """Support building an ANSI sequence string when self is the right operand. - - e.g. "hello" + Fg.RESET - """ - return str(other) + str(self) - - -class FgColor(AnsiSequence): - """Base class for ANSI Sequences which set foreground text color.""" - - -class BgColor(AnsiSequence): - """Base class for ANSI Sequences which set background text color.""" - - -#################################################################################### -# Implementations intended for direct use (do NOT use outside of cmd2) -#################################################################################### -class Cursor: - """Create ANSI sequences to alter the cursor position.""" - - @staticmethod - def UP(count: int = 1) -> str: # noqa: N802 - """Move the cursor up a specified amount of lines (Defaults to 1).""" - return f"{CSI}{count}A" - - @staticmethod - def DOWN(count: int = 1) -> str: # noqa: N802 - """Move the cursor down a specified amount of lines (Defaults to 1).""" - return f"{CSI}{count}B" - - @staticmethod - def FORWARD(count: int = 1) -> str: # noqa: N802 - """Move the cursor forward a specified amount of lines (Defaults to 1).""" - return f"{CSI}{count}C" - - @staticmethod - def BACK(count: int = 1) -> str: # noqa: N802 - """Move the cursor back a specified amount of lines (Defaults to 1).""" - return f"{CSI}{count}D" - - @staticmethod - def SET_POS(x: int, y: int) -> str: # noqa: N802 - """Set the cursor position to coordinates which are 1-based.""" - return f"{CSI}{y};{x}H" - - -class TextStyle(AnsiSequence, Enum): - """Create text style ANSI sequences.""" - - # Resets all styles and colors of text - RESET_ALL = 0 - ALT_RESET_ALL = '' - - INTENSITY_BOLD = 1 - INTENSITY_DIM = 2 - INTENSITY_NORMAL = 22 - - ITALIC_ENABLE = 3 - ITALIC_DISABLE = 23 - - OVERLINE_ENABLE = 53 - OVERLINE_DISABLE = 55 - - STRIKETHROUGH_ENABLE = 9 - STRIKETHROUGH_DISABLE = 29 - - UNDERLINE_ENABLE = 4 - UNDERLINE_DISABLE = 24 - - def __str__(self) -> str: - """Return ANSI text style sequence instead of enum name. - - This is helpful when using a TextStyle in an f-string or format() call - e.g. my_str = f"{TextStyle.UNDERLINE_ENABLE}hello{TextStyle.UNDERLINE_DISABLE}". - """ - return f"{CSI}{self.value}m" - - -class Fg(FgColor, Enum): - """Create ANSI sequences for the 16 standard terminal foreground text colors. - - A terminal's color settings affect how these colors appear. - To reset any foreground color, use Fg.RESET. - """ - - BLACK = 30 - RED = 31 - GREEN = 32 - YELLOW = 33 - BLUE = 34 - MAGENTA = 35 - CYAN = 36 - LIGHT_GRAY = 37 - DARK_GRAY = 90 - LIGHT_RED = 91 - LIGHT_GREEN = 92 - LIGHT_YELLOW = 93 - LIGHT_BLUE = 94 - LIGHT_MAGENTA = 95 - LIGHT_CYAN = 96 - WHITE = 97 - - RESET = 39 - - def __str__(self) -> str: - """Return ANSI color sequence instead of enum name. - - This is helpful when using an Fg in an f-string or format() call - e.g. my_str = f"{Fg.BLUE}hello{Fg.RESET}". - """ - return f"{CSI}{self.value}m" - - -class Bg(BgColor, Enum): - """Create ANSI sequences for the 16 standard terminal background text colors. - - A terminal's color settings affect how these colors appear. - To reset any background color, use Bg.RESET. - """ - - BLACK = 40 - RED = 41 - GREEN = 42 - YELLOW = 43 - BLUE = 44 - MAGENTA = 45 - CYAN = 46 - LIGHT_GRAY = 47 - DARK_GRAY = 100 - LIGHT_RED = 101 - LIGHT_GREEN = 102 - LIGHT_YELLOW = 103 - LIGHT_BLUE = 104 - LIGHT_MAGENTA = 105 - LIGHT_CYAN = 106 - WHITE = 107 - - RESET = 49 - - def __str__(self) -> str: - """Return ANSI color sequence instead of enum name. - - This is helpful when using a Bg in an f-string or format() call - e.g. my_str = f"{Bg.BLACK}hello{Bg.RESET}". - """ - return f"{CSI}{self.value}m" - - -class EightBitFg(FgColor, Enum): - """Create ANSI sequences for 8-bit terminal foreground text colors. Most terminals support 8-bit/256-color mode. - - The first 16 colors correspond to the 16 colors from Fg and behave the same way. - To reset any foreground color, including 8-bit, use Fg.RESET. - """ - - BLACK = 0 - RED = 1 - GREEN = 2 - YELLOW = 3 - BLUE = 4 - MAGENTA = 5 - CYAN = 6 - LIGHT_GRAY = 7 - DARK_GRAY = 8 - LIGHT_RED = 9 - LIGHT_GREEN = 10 - LIGHT_YELLOW = 11 - LIGHT_BLUE = 12 - LIGHT_MAGENTA = 13 - LIGHT_CYAN = 14 - WHITE = 15 - GRAY_0 = 16 - NAVY_BLUE = 17 - DARK_BLUE = 18 - BLUE_3A = 19 - BLUE_3B = 20 - BLUE_1 = 21 - DARK_GREEN = 22 - DEEP_SKY_BLUE_4A = 23 - DEEP_SKY_BLUE_4B = 24 - DEEP_SKY_BLUE_4C = 25 - DODGER_BLUE_3 = 26 - DODGER_BLUE_2 = 27 - GREEN_4 = 28 - SPRING_GREEN_4 = 29 - TURQUOISE_4 = 30 - DEEP_SKY_BLUE_3A = 31 - DEEP_SKY_BLUE_3B = 32 - DODGER_BLUE_1 = 33 - GREEN_3A = 34 - SPRING_GREEN_3A = 35 - DARK_CYAN = 36 - LIGHT_SEA_GREEN = 37 - DEEP_SKY_BLUE_2 = 38 - DEEP_SKY_BLUE_1 = 39 - GREEN_3B = 40 - SPRING_GREEN_3B = 41 - SPRING_GREEN_2A = 42 - CYAN_3 = 43 - DARK_TURQUOISE = 44 - TURQUOISE_2 = 45 - GREEN_1 = 46 - SPRING_GREEN_2B = 47 - SPRING_GREEN_1 = 48 - MEDIUM_SPRING_GREEN = 49 - CYAN_2 = 50 - CYAN_1 = 51 - DARK_RED_1 = 52 - DEEP_PINK_4A = 53 - PURPLE_4A = 54 - PURPLE_4B = 55 - PURPLE_3 = 56 - BLUE_VIOLET = 57 - ORANGE_4A = 58 - GRAY_37 = 59 - MEDIUM_PURPLE_4 = 60 - SLATE_BLUE_3A = 61 - SLATE_BLUE_3B = 62 - ROYAL_BLUE_1 = 63 - CHARTREUSE_4 = 64 - DARK_SEA_GREEN_4A = 65 - PALE_TURQUOISE_4 = 66 - STEEL_BLUE = 67 - STEEL_BLUE_3 = 68 - CORNFLOWER_BLUE = 69 - CHARTREUSE_3A = 70 - DARK_SEA_GREEN_4B = 71 - CADET_BLUE_2 = 72 - CADET_BLUE_1 = 73 - SKY_BLUE_3 = 74 - STEEL_BLUE_1A = 75 - CHARTREUSE_3B = 76 - PALE_GREEN_3A = 77 - SEA_GREEN_3 = 78 - AQUAMARINE_3 = 79 - MEDIUM_TURQUOISE = 80 - STEEL_BLUE_1B = 81 - CHARTREUSE_2A = 82 - SEA_GREEN_2 = 83 - SEA_GREEN_1A = 84 - SEA_GREEN_1B = 85 - AQUAMARINE_1A = 86 - DARK_SLATE_GRAY_2 = 87 - DARK_RED_2 = 88 - DEEP_PINK_4B = 89 - DARK_MAGENTA_1 = 90 - DARK_MAGENTA_2 = 91 - DARK_VIOLET_1A = 92 - PURPLE_1A = 93 - ORANGE_4B = 94 - LIGHT_PINK_4 = 95 - PLUM_4 = 96 - MEDIUM_PURPLE_3A = 97 - MEDIUM_PURPLE_3B = 98 - SLATE_BLUE_1 = 99 - YELLOW_4A = 100 - WHEAT_4 = 101 - GRAY_53 = 102 - LIGHT_SLATE_GRAY = 103 - MEDIUM_PURPLE = 104 - LIGHT_SLATE_BLUE = 105 - YELLOW_4B = 106 - DARK_OLIVE_GREEN_3A = 107 - DARK_GREEN_SEA = 108 - LIGHT_SKY_BLUE_3A = 109 - LIGHT_SKY_BLUE_3B = 110 - SKY_BLUE_2 = 111 - CHARTREUSE_2B = 112 - DARK_OLIVE_GREEN_3B = 113 - PALE_GREEN_3B = 114 - DARK_SEA_GREEN_3A = 115 - DARK_SLATE_GRAY_3 = 116 - SKY_BLUE_1 = 117 - CHARTREUSE_1 = 118 - LIGHT_GREEN_2 = 119 - LIGHT_GREEN_3 = 120 - PALE_GREEN_1A = 121 - AQUAMARINE_1B = 122 - DARK_SLATE_GRAY_1 = 123 - RED_3A = 124 - DEEP_PINK_4C = 125 - MEDIUM_VIOLET_RED = 126 - MAGENTA_3A = 127 - DARK_VIOLET_1B = 128 - PURPLE_1B = 129 - DARK_ORANGE_3A = 130 - INDIAN_RED_1A = 131 - HOT_PINK_3A = 132 - MEDIUM_ORCHID_3 = 133 - MEDIUM_ORCHID = 134 - MEDIUM_PURPLE_2A = 135 - DARK_GOLDENROD = 136 - LIGHT_SALMON_3A = 137 - ROSY_BROWN = 138 - GRAY_63 = 139 - MEDIUM_PURPLE_2B = 140 - MEDIUM_PURPLE_1 = 141 - GOLD_3A = 142 - DARK_KHAKI = 143 - NAVAJO_WHITE_3 = 144 - GRAY_69 = 145 - LIGHT_STEEL_BLUE_3 = 146 - LIGHT_STEEL_BLUE = 147 - YELLOW_3A = 148 - DARK_OLIVE_GREEN_3 = 149 - DARK_SEA_GREEN_3B = 150 - DARK_SEA_GREEN_2 = 151 - LIGHT_CYAN_3 = 152 - LIGHT_SKY_BLUE_1 = 153 - GREEN_YELLOW = 154 - DARK_OLIVE_GREEN_2 = 155 - PALE_GREEN_1B = 156 - DARK_SEA_GREEN_5B = 157 - DARK_SEA_GREEN_5A = 158 - PALE_TURQUOISE_1 = 159 - RED_3B = 160 - DEEP_PINK_3A = 161 - DEEP_PINK_3B = 162 - MAGENTA_3B = 163 - MAGENTA_3C = 164 - MAGENTA_2A = 165 - DARK_ORANGE_3B = 166 - INDIAN_RED_1B = 167 - HOT_PINK_3B = 168 - HOT_PINK_2 = 169 - ORCHID = 170 - MEDIUM_ORCHID_1A = 171 - ORANGE_3 = 172 - LIGHT_SALMON_3B = 173 - LIGHT_PINK_3 = 174 - PINK_3 = 175 - PLUM_3 = 176 - VIOLET = 177 - GOLD_3B = 178 - LIGHT_GOLDENROD_3 = 179 - TAN = 180 - MISTY_ROSE_3 = 181 - THISTLE_3 = 182 - PLUM_2 = 183 - YELLOW_3B = 184 - KHAKI_3 = 185 - LIGHT_GOLDENROD_2A = 186 - LIGHT_YELLOW_3 = 187 - GRAY_84 = 188 - LIGHT_STEEL_BLUE_1 = 189 - YELLOW_2 = 190 - DARK_OLIVE_GREEN_1A = 191 - DARK_OLIVE_GREEN_1B = 192 - DARK_SEA_GREEN_1 = 193 - HONEYDEW_2 = 194 - LIGHT_CYAN_1 = 195 - RED_1 = 196 - DEEP_PINK_2 = 197 - DEEP_PINK_1A = 198 - DEEP_PINK_1B = 199 - MAGENTA_2B = 200 - MAGENTA_1 = 201 - ORANGE_RED_1 = 202 - INDIAN_RED_1C = 203 - INDIAN_RED_1D = 204 - HOT_PINK_1A = 205 - HOT_PINK_1B = 206 - MEDIUM_ORCHID_1B = 207 - DARK_ORANGE = 208 - SALMON_1 = 209 - LIGHT_CORAL = 210 - PALE_VIOLET_RED_1 = 211 - ORCHID_2 = 212 - ORCHID_1 = 213 - ORANGE_1 = 214 - SANDY_BROWN = 215 - LIGHT_SALMON_1 = 216 - LIGHT_PINK_1 = 217 - PINK_1 = 218 - PLUM_1 = 219 - GOLD_1 = 220 - LIGHT_GOLDENROD_2B = 221 - LIGHT_GOLDENROD_2C = 222 - NAVAJO_WHITE_1 = 223 - MISTY_ROSE1 = 224 - THISTLE_1 = 225 - YELLOW_1 = 226 - LIGHT_GOLDENROD_1 = 227 - KHAKI_1 = 228 - WHEAT_1 = 229 - CORNSILK_1 = 230 - GRAY_100 = 231 - GRAY_3 = 232 - GRAY_7 = 233 - GRAY_11 = 234 - GRAY_15 = 235 - GRAY_19 = 236 - GRAY_23 = 237 - GRAY_27 = 238 - GRAY_30 = 239 - GRAY_35 = 240 - GRAY_39 = 241 - GRAY_42 = 242 - GRAY_46 = 243 - GRAY_50 = 244 - GRAY_54 = 245 - GRAY_58 = 246 - GRAY_62 = 247 - GRAY_66 = 248 - GRAY_70 = 249 - GRAY_74 = 250 - GRAY_78 = 251 - GRAY_82 = 252 - GRAY_85 = 253 - GRAY_89 = 254 - GRAY_93 = 255 - - def __str__(self) -> str: - """Return ANSI color sequence instead of enum name. - - This is helpful when using an EightBitFg in an f-string or format() call - e.g. my_str = f"{EightBitFg.SLATE_BLUE_1}hello{Fg.RESET}". - """ - return f"{CSI}38;5;{self.value}m" - - -class EightBitBg(BgColor, Enum): - """Create ANSI sequences for 8-bit terminal background text colors. Most terminals support 8-bit/256-color mode. - - The first 16 colors correspond to the 16 colors from Bg and behave the same way. - To reset any background color, including 8-bit, use Bg.RESET. - """ - - BLACK = 0 - RED = 1 - GREEN = 2 - YELLOW = 3 - BLUE = 4 - MAGENTA = 5 - CYAN = 6 - LIGHT_GRAY = 7 - DARK_GRAY = 8 - LIGHT_RED = 9 - LIGHT_GREEN = 10 - LIGHT_YELLOW = 11 - LIGHT_BLUE = 12 - LIGHT_MAGENTA = 13 - LIGHT_CYAN = 14 - WHITE = 15 - GRAY_0 = 16 - NAVY_BLUE = 17 - DARK_BLUE = 18 - BLUE_3A = 19 - BLUE_3B = 20 - BLUE_1 = 21 - DARK_GREEN = 22 - DEEP_SKY_BLUE_4A = 23 - DEEP_SKY_BLUE_4B = 24 - DEEP_SKY_BLUE_4C = 25 - DODGER_BLUE_3 = 26 - DODGER_BLUE_2 = 27 - GREEN_4 = 28 - SPRING_GREEN_4 = 29 - TURQUOISE_4 = 30 - DEEP_SKY_BLUE_3A = 31 - DEEP_SKY_BLUE_3B = 32 - DODGER_BLUE_1 = 33 - GREEN_3A = 34 - SPRING_GREEN_3A = 35 - DARK_CYAN = 36 - LIGHT_SEA_GREEN = 37 - DEEP_SKY_BLUE_2 = 38 - DEEP_SKY_BLUE_1 = 39 - GREEN_3B = 40 - SPRING_GREEN_3B = 41 - SPRING_GREEN_2A = 42 - CYAN_3 = 43 - DARK_TURQUOISE = 44 - TURQUOISE_2 = 45 - GREEN_1 = 46 - SPRING_GREEN_2B = 47 - SPRING_GREEN_1 = 48 - MEDIUM_SPRING_GREEN = 49 - CYAN_2 = 50 - CYAN_1 = 51 - DARK_RED_1 = 52 - DEEP_PINK_4A = 53 - PURPLE_4A = 54 - PURPLE_4B = 55 - PURPLE_3 = 56 - BLUE_VIOLET = 57 - ORANGE_4A = 58 - GRAY_37 = 59 - MEDIUM_PURPLE_4 = 60 - SLATE_BLUE_3A = 61 - SLATE_BLUE_3B = 62 - ROYAL_BLUE_1 = 63 - CHARTREUSE_4 = 64 - DARK_SEA_GREEN_4A = 65 - PALE_TURQUOISE_4 = 66 - STEEL_BLUE = 67 - STEEL_BLUE_3 = 68 - CORNFLOWER_BLUE = 69 - CHARTREUSE_3A = 70 - DARK_SEA_GREEN_4B = 71 - CADET_BLUE_2 = 72 - CADET_BLUE_1 = 73 - SKY_BLUE_3 = 74 - STEEL_BLUE_1A = 75 - CHARTREUSE_3B = 76 - PALE_GREEN_3A = 77 - SEA_GREEN_3 = 78 - AQUAMARINE_3 = 79 - MEDIUM_TURQUOISE = 80 - STEEL_BLUE_1B = 81 - CHARTREUSE_2A = 82 - SEA_GREEN_2 = 83 - SEA_GREEN_1A = 84 - SEA_GREEN_1B = 85 - AQUAMARINE_1A = 86 - DARK_SLATE_GRAY_2 = 87 - DARK_RED_2 = 88 - DEEP_PINK_4B = 89 - DARK_MAGENTA_1 = 90 - DARK_MAGENTA_2 = 91 - DARK_VIOLET_1A = 92 - PURPLE_1A = 93 - ORANGE_4B = 94 - LIGHT_PINK_4 = 95 - PLUM_4 = 96 - MEDIUM_PURPLE_3A = 97 - MEDIUM_PURPLE_3B = 98 - SLATE_BLUE_1 = 99 - YELLOW_4A = 100 - WHEAT_4 = 101 - GRAY_53 = 102 - LIGHT_SLATE_GRAY = 103 - MEDIUM_PURPLE = 104 - LIGHT_SLATE_BLUE = 105 - YELLOW_4B = 106 - DARK_OLIVE_GREEN_3A = 107 - DARK_GREEN_SEA = 108 - LIGHT_SKY_BLUE_3A = 109 - LIGHT_SKY_BLUE_3B = 110 - SKY_BLUE_2 = 111 - CHARTREUSE_2B = 112 - DARK_OLIVE_GREEN_3B = 113 - PALE_GREEN_3B = 114 - DARK_SEA_GREEN_3A = 115 - DARK_SLATE_GRAY_3 = 116 - SKY_BLUE_1 = 117 - CHARTREUSE_1 = 118 - LIGHT_GREEN_2 = 119 - LIGHT_GREEN_3 = 120 - PALE_GREEN_1A = 121 - AQUAMARINE_1B = 122 - DARK_SLATE_GRAY_1 = 123 - RED_3A = 124 - DEEP_PINK_4C = 125 - MEDIUM_VIOLET_RED = 126 - MAGENTA_3A = 127 - DARK_VIOLET_1B = 128 - PURPLE_1B = 129 - DARK_ORANGE_3A = 130 - INDIAN_RED_1A = 131 - HOT_PINK_3A = 132 - MEDIUM_ORCHID_3 = 133 - MEDIUM_ORCHID = 134 - MEDIUM_PURPLE_2A = 135 - DARK_GOLDENROD = 136 - LIGHT_SALMON_3A = 137 - ROSY_BROWN = 138 - GRAY_63 = 139 - MEDIUM_PURPLE_2B = 140 - MEDIUM_PURPLE_1 = 141 - GOLD_3A = 142 - DARK_KHAKI = 143 - NAVAJO_WHITE_3 = 144 - GRAY_69 = 145 - LIGHT_STEEL_BLUE_3 = 146 - LIGHT_STEEL_BLUE = 147 - YELLOW_3A = 148 - DARK_OLIVE_GREEN_3 = 149 - DARK_SEA_GREEN_3B = 150 - DARK_SEA_GREEN_2 = 151 - LIGHT_CYAN_3 = 152 - LIGHT_SKY_BLUE_1 = 153 - GREEN_YELLOW = 154 - DARK_OLIVE_GREEN_2 = 155 - PALE_GREEN_1B = 156 - DARK_SEA_GREEN_5B = 157 - DARK_SEA_GREEN_5A = 158 - PALE_TURQUOISE_1 = 159 - RED_3B = 160 - DEEP_PINK_3A = 161 - DEEP_PINK_3B = 162 - MAGENTA_3B = 163 - MAGENTA_3C = 164 - MAGENTA_2A = 165 - DARK_ORANGE_3B = 166 - INDIAN_RED_1B = 167 - HOT_PINK_3B = 168 - HOT_PINK_2 = 169 - ORCHID = 170 - MEDIUM_ORCHID_1A = 171 - ORANGE_3 = 172 - LIGHT_SALMON_3B = 173 - LIGHT_PINK_3 = 174 - PINK_3 = 175 - PLUM_3 = 176 - VIOLET = 177 - GOLD_3B = 178 - LIGHT_GOLDENROD_3 = 179 - TAN = 180 - MISTY_ROSE_3 = 181 - THISTLE_3 = 182 - PLUM_2 = 183 - YELLOW_3B = 184 - KHAKI_3 = 185 - LIGHT_GOLDENROD_2A = 186 - LIGHT_YELLOW_3 = 187 - GRAY_84 = 188 - LIGHT_STEEL_BLUE_1 = 189 - YELLOW_2 = 190 - DARK_OLIVE_GREEN_1A = 191 - DARK_OLIVE_GREEN_1B = 192 - DARK_SEA_GREEN_1 = 193 - HONEYDEW_2 = 194 - LIGHT_CYAN_1 = 195 - RED_1 = 196 - DEEP_PINK_2 = 197 - DEEP_PINK_1A = 198 - DEEP_PINK_1B = 199 - MAGENTA_2B = 200 - MAGENTA_1 = 201 - ORANGE_RED_1 = 202 - INDIAN_RED_1C = 203 - INDIAN_RED_1D = 204 - HOT_PINK_1A = 205 - HOT_PINK_1B = 206 - MEDIUM_ORCHID_1B = 207 - DARK_ORANGE = 208 - SALMON_1 = 209 - LIGHT_CORAL = 210 - PALE_VIOLET_RED_1 = 211 - ORCHID_2 = 212 - ORCHID_1 = 213 - ORANGE_1 = 214 - SANDY_BROWN = 215 - LIGHT_SALMON_1 = 216 - LIGHT_PINK_1 = 217 - PINK_1 = 218 - PLUM_1 = 219 - GOLD_1 = 220 - LIGHT_GOLDENROD_2B = 221 - LIGHT_GOLDENROD_2C = 222 - NAVAJO_WHITE_1 = 223 - MISTY_ROSE1 = 224 - THISTLE_1 = 225 - YELLOW_1 = 226 - LIGHT_GOLDENROD_1 = 227 - KHAKI_1 = 228 - WHEAT_1 = 229 - CORNSILK_1 = 230 - GRAY_100 = 231 - GRAY_3 = 232 - GRAY_7 = 233 - GRAY_11 = 234 - GRAY_15 = 235 - GRAY_19 = 236 - GRAY_23 = 237 - GRAY_27 = 238 - GRAY_30 = 239 - GRAY_35 = 240 - GRAY_39 = 241 - GRAY_42 = 242 - GRAY_46 = 243 - GRAY_50 = 244 - GRAY_54 = 245 - GRAY_58 = 246 - GRAY_62 = 247 - GRAY_66 = 248 - GRAY_70 = 249 - GRAY_74 = 250 - GRAY_78 = 251 - GRAY_82 = 252 - GRAY_85 = 253 - GRAY_89 = 254 - GRAY_93 = 255 - - def __str__(self) -> str: - """Return ANSI color sequence instead of enum name. - - This is helpful when using an EightBitBg in an f-string or format() call - e.g. my_str = f"{EightBitBg.KHAKI_3}hello{Bg.RESET}". - """ - return f"{CSI}48;5;{self.value}m" - - -class RgbFg(FgColor): - """Create ANSI sequences for 24-bit (RGB) terminal foreground text colors. The terminal must support 24-bit/true-color. - - To reset any foreground color, including 24-bit, use Fg.RESET. - """ - - def __init__(self, r: int, g: int, b: int) -> None: - """RgbFg initializer. - - :param r: integer from 0-255 for the red component of the color - :param g: integer from 0-255 for the green component of the color - :param b: integer from 0-255 for the blue component of the color - :raises ValueError: if r, g, or b is not in the range 0-255 - """ - if any(c < 0 or c > 255 for c in [r, g, b]): - raise ValueError("RGB values must be integers in the range of 0 to 255") - - self._sequence = f"{CSI}38;2;{r};{g};{b}m" - - def __str__(self) -> str: - """Return ANSI color sequence instead of enum name. - - This is helpful when using an RgbFg in an f-string or format() call - e.g. my_str = f"{RgbFg(0, 55, 100)}hello{Fg.RESET}". - """ - return self._sequence - - -class RgbBg(BgColor): - """Create ANSI sequences for 24-bit (RGB) terminal background text colors. The terminal must support 24-bit/true-color. - - To reset any background color, including 24-bit, use Bg.RESET. - """ - - def __init__(self, r: int, g: int, b: int) -> None: - """RgbBg initializer. - - :param r: integer from 0-255 for the red component of the color - :param g: integer from 0-255 for the green component of the color - :param b: integer from 0-255 for the blue component of the color - :raises ValueError: if r, g, or b is not in the range 0-255 - """ - if any(c < 0 or c > 255 for c in [r, g, b]): - raise ValueError("RGB values must be integers in the range of 0 to 255") - - self._sequence = f"{CSI}48;2;{r};{g};{b}m" - - def __str__(self) -> str: - """Return ANSI color sequence instead of enum name. - - This is helpful when using an RgbBg in an f-string or format() call - e.g. my_str = f"{RgbBg(100, 255, 27)}hello{Bg.RESET}". - """ - return self._sequence - - -def style( - value: Any, - *, - fg: FgColor | None = None, - bg: BgColor | None = None, - bold: bool | None = None, - dim: bool | None = None, - italic: bool | None = None, - overline: bool | None = None, - strikethrough: bool | None = None, - underline: bool | None = None, -) -> str: - """Apply ANSI colors and/or styles to a string and return it. - - The styling is self contained which means that at the end of the string reset code(s) are issued - to undo whatever styling was done at the beginning. - - :param value: object whose text is to be styled - :param fg: foreground color provided as any subclass of FgColor (e.g. Fg, EightBitFg, RgbFg) - Defaults to no color. - :param bg: foreground color provided as any subclass of BgColor (e.g. Bg, EightBitBg, RgbBg) - Defaults to no color. - :param bold: apply the bold style if True. Defaults to False. - :param dim: apply the dim style if True. Defaults to False. - :param italic: apply the italic style if True. Defaults to False. - :param overline: apply the overline style if True. Defaults to False. - :param strikethrough: apply the strikethrough style if True. Defaults to False. - :param underline: apply the underline style if True. Defaults to False. - :raises TypeError: if fg isn't None or a subclass of FgColor - :raises TypeError: if bg isn't None or a subclass of BgColor - :return: the stylized string - """ - # list of strings that add style - additions: list[AnsiSequence] = [] - - # list of strings that remove style - removals: list[AnsiSequence] = [] - - # Process the style settings - if fg is not None: - if not isinstance(fg, FgColor): - raise TypeError("fg must be a subclass of FgColor") - additions.append(fg) - removals.append(Fg.RESET) - - if bg is not None: - if not isinstance(bg, BgColor): - raise TypeError("bg must a subclass of BgColor") - additions.append(bg) - removals.append(Bg.RESET) - - if bold: - additions.append(TextStyle.INTENSITY_BOLD) - removals.append(TextStyle.INTENSITY_NORMAL) - - if dim: - additions.append(TextStyle.INTENSITY_DIM) - removals.append(TextStyle.INTENSITY_NORMAL) - - if italic: - additions.append(TextStyle.ITALIC_ENABLE) - removals.append(TextStyle.ITALIC_DISABLE) - - if overline: - additions.append(TextStyle.OVERLINE_ENABLE) - removals.append(TextStyle.OVERLINE_DISABLE) - - if strikethrough: - additions.append(TextStyle.STRIKETHROUGH_ENABLE) - removals.append(TextStyle.STRIKETHROUGH_DISABLE) - - if underline: - additions.append(TextStyle.UNDERLINE_ENABLE) - removals.append(TextStyle.UNDERLINE_DISABLE) - - # Combine the ANSI style sequences with the value's text - return "".join(map(str, additions)) + str(value) + "".join(map(str, removals)) - - -# Default styles for printing strings of various types. -# These can be altered to suit an application's needs and only need to be a -# function with the following structure: func(str) -> str -style_success = functools.partial(style, fg=Fg.GREEN) -"""Partial function supplying arguments to [cmd2.ansi.style][] which colors text to signify success""" - -style_warning = functools.partial(style, fg=Fg.LIGHT_YELLOW) -"""Partial function supplying arguments to [cmd2.ansi.style][] which colors text to signify a warning""" - -style_error = functools.partial(style, fg=Fg.LIGHT_RED) -"""Partial function supplying arguments to [cmd2.ansi.style][] which colors text to signify an error""" - - -def async_alert_str(*, terminal_columns: int, prompt: str, line: str, cursor_offset: int, alert_msg: str) -> str: - """Calculate the desired string, including ANSI escape codes, for displaying an asynchronous alert message. - - :param terminal_columns: terminal width (number of columns) - :param prompt: current onscreen prompt - :param line: current contents of the Readline line buffer - :param cursor_offset: the offset of the current cursor position within line - :param alert_msg: the message to display to the user - :return: the correct string so that the alert message appears to the user to be printed above the current line. - """ - # Split the prompt lines since it can contain newline characters. - prompt_lines = prompt.splitlines() or [''] - - # Calculate how many terminal lines are taken up by all prompt lines except for the last one. - # That will be included in the input lines calculations since that is where the cursor is. - num_prompt_terminal_lines = 0 - for prompt_line in prompt_lines[:-1]: - prompt_line_width = style_aware_wcswidth(prompt_line) - num_prompt_terminal_lines += int(prompt_line_width / terminal_columns) + 1 - - # Now calculate how many terminal lines are take up by the input - last_prompt_line = prompt_lines[-1] - last_prompt_line_width = style_aware_wcswidth(last_prompt_line) - - input_width = last_prompt_line_width + style_aware_wcswidth(line) - - num_input_terminal_lines = int(input_width / terminal_columns) + 1 - - # Get the cursor's offset from the beginning of the first input line - cursor_input_offset = last_prompt_line_width + cursor_offset - - # Calculate what input line the cursor is on - cursor_input_line = int(cursor_input_offset / terminal_columns) + 1 - - # Create a string that when printed will clear all input lines and display the alert - terminal_str = '' - - # Move the cursor down to the last input line - if cursor_input_line != num_input_terminal_lines: - terminal_str += Cursor.DOWN(num_input_terminal_lines - cursor_input_line) - - # Clear each line from the bottom up so that the cursor ends up on the first prompt line - total_lines = num_prompt_terminal_lines + num_input_terminal_lines - terminal_str += (clear_line() + Cursor.UP(1)) * (total_lines - 1) - - # Clear the first prompt line - terminal_str += clear_line() - - # Move the cursor to the beginning of the first prompt line and print the alert - terminal_str += '\r' + alert_msg - return terminal_str diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 2418d2555..92dc6b0d3 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -6,7 +6,6 @@ import argparse import inspect import numbers -import sys from collections import ( deque, ) @@ -20,17 +19,13 @@ from .constants import ( INFINITY, ) -from .rich_utils import ( - Cmd2Console, - Cmd2Style, -) +from .rich_utils import Cmd2Console if TYPE_CHECKING: # pragma: no cover from .cmd2 import ( Cmd, ) - from rich.box import SIMPLE_HEAD from rich.table import Column, Table @@ -46,6 +41,7 @@ from .exceptions import ( CompletionError, ) +from .styles import Cmd2Style # If no descriptive headers are supplied, then this will be used instead DEFAULT_DESCRIPTIVE_HEADERS: Sequence[str | Column] = ('Description',) @@ -594,9 +590,9 @@ def _format_completions(self, arg_state: _ArgumentState, completions: list[str] hint_table.add_row(item, *item.descriptive_data) # Generate the hint table string - console = Cmd2Console(sys.stdout) + console = Cmd2Console() with console.capture() as capture: - console.print(hint_table) + console.print(hint_table, end="") self._cmd2_app.formatted_completions = capture.get() # Return sorted list of completions diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index caa4aac55..f3e5344b2 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -294,11 +294,9 @@ def get_items(self) -> list[CompletionItems]: RichHelpFormatter, ) -from . import ( - constants, - rich_utils, -) -from .rich_utils import Cmd2Style +from . import constants +from .rich_utils import Cmd2Console +from .styles import Cmd2Style if TYPE_CHECKING: # pragma: no cover from .argparse_completer import ( @@ -1115,12 +1113,12 @@ def __init__( max_help_position: int = 24, width: int | None = None, *, - console: rich_utils.Cmd2Console | None = None, + console: Cmd2Console | None = None, **kwargs: Any, ) -> None: """Initialize Cmd2HelpFormatter.""" if console is None: - console = rich_utils.Cmd2Console(sys.stdout) + console = Cmd2Console() super().__init__(prog, indent_increment, max_help_position, width, console=console, **kwargs) @@ -1483,7 +1481,7 @@ def error(self, message: str) -> NoReturn: # Add error style to message console = self._get_formatter().console with console.capture() as capture: - console.print(formatted_message, style=Cmd2Style.ERROR, crop=False) + console.print(formatted_message, style=Cmd2Style.ERROR) formatted_message = f"{capture.get()}" self.exit(2, f'{formatted_message}\n') diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index b3399b9f6..979f562ea 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -66,7 +66,7 @@ from rich.box import SIMPLE_HEAD from rich.console import Group from rich.rule import Rule -from rich.style import StyleType +from rich.style import Style, StyleType from rich.table import ( Column, Table, @@ -74,14 +74,14 @@ from rich.text import Text from . import ( - ansi, argparse_completer, argparse_custom, constants, plugin, - rich_utils, utils, ) +from . import rich_utils as ru +from . import string_utils as su from .argparse_custom import ( ChoicesProviderFunc, Cmd2ArgumentParser, @@ -129,7 +129,11 @@ StatementParser, shlex_split, ) -from .rich_utils import Cmd2Console, Cmd2Style, RichPrintKwargs +from .rich_utils import ( + Cmd2Console, + RichPrintKwargs, +) +from .styles import Cmd2Style # NOTE: When using gnureadline with Python 3.13, start_ipython needs to be imported before any readline-related stuff with contextlib.suppress(ImportError): @@ -292,7 +296,7 @@ class Cmd(cmd.Cmd): DEFAULT_EDITOR = utils.find_editor() # Sorting keys for strings - ALPHABETICAL_SORT_KEY = utils.norm_fold + ALPHABETICAL_SORT_KEY = su.norm_fold NATURAL_SORT_KEY = utils.natural_keys # List for storing transcript test file names @@ -496,7 +500,7 @@ def __init__( if startup_script: startup_script = os.path.abspath(os.path.expanduser(startup_script)) if os.path.exists(startup_script): - script_cmd = f"run_script {utils.quote_string(startup_script)}" + script_cmd = f"run_script {su.quote(startup_script)}" if silence_startup_script: script_cmd += f" {constants.REDIRECTION_OUTPUT} {os.devnull}" self._startup_commands.append(script_cmd) @@ -1127,16 +1131,15 @@ def build_settables(self) -> None: def get_allow_style_choices(_cli_self: Cmd) -> list[str]: """Tab complete allow_style values.""" - return [val.name.lower() for val in rich_utils.AllowStyle] + return [val.name.lower() for val in ru.AllowStyle] - def allow_style_type(value: str) -> rich_utils.AllowStyle: - """Convert a string value into an rich_utils.AllowStyle.""" + def allow_style_type(value: str) -> ru.AllowStyle: + """Convert a string value into an ru.AllowStyle.""" try: - return rich_utils.AllowStyle[value.upper()] + return ru.AllowStyle[value.upper()] except KeyError as ex: raise ValueError( - f"must be {rich_utils.AllowStyle.ALWAYS}, {rich_utils.AllowStyle.NEVER}, or " - f"{rich_utils.AllowStyle.TERMINAL} (case-insensitive)" + f"must be {ru.AllowStyle.ALWAYS}, {ru.AllowStyle.NEVER}, or {ru.AllowStyle.TERMINAL} (case-insensitive)" ) from ex self.add_settable( @@ -1144,7 +1147,7 @@ def allow_style_type(value: str) -> rich_utils.AllowStyle: 'allow_style', allow_style_type, 'Allow ANSI text style sequences in output (valid values: ' - f'{rich_utils.AllowStyle.ALWAYS}, {rich_utils.AllowStyle.NEVER}, {rich_utils.AllowStyle.TERMINAL})', + f'{ru.AllowStyle.ALWAYS}, {ru.AllowStyle.NEVER}, {ru.AllowStyle.TERMINAL})', self, choices_provider=cast(ChoicesProviderFunc, get_allow_style_choices), ) @@ -1167,14 +1170,14 @@ def allow_style_type(value: str) -> rich_utils.AllowStyle: # ----- Methods related to presenting output to the user ----- @property - def allow_style(self) -> rich_utils.AllowStyle: + def allow_style(self) -> ru.AllowStyle: """Read-only property needed to support do_set when it reads allow_style.""" - return rich_utils.allow_style + return ru.ALLOW_STYLE @allow_style.setter - def allow_style(self, new_val: rich_utils.AllowStyle) -> None: + def allow_style(self, new_val: ru.AllowStyle) -> None: """Setter property needed to support do_set when it updates allow_style.""" - rich_utils.allow_style = new_val + ru.ALLOW_STYLE = new_val def _completion_supported(self) -> bool: """Return whether tab completion is supported.""" @@ -1189,7 +1192,7 @@ def visible_prompt(self) -> str: :return: prompt stripped of any ANSI escape codes """ - return ansi.strip_style(self.prompt) + return su.strip_style(self.prompt) def print_to( self, @@ -1219,7 +1222,7 @@ def print_to( method and still call `super()` without encountering unexpected keyword argument errors. These arguments are not passed to Rich's Console.print(). """ - prepared_objects = rich_utils.prepare_objects_for_rich_print(*objects) + prepared_objects = ru.prepare_objects_for_rich_print(*objects) try: Cmd2Console(file).print( @@ -1290,7 +1293,7 @@ def perror( :param objects: objects to print :param sep: string to write between print data. Defaults to " ". :param end: string to write at end of print data. Defaults to a newline. - :param style: optional style to apply to output. Defaults to cmd2.error. + :param style: optional style to apply to output. Defaults to Cmd2Style.ERROR. :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the terminal width; instead, any text that doesn't fit will run onto the following line(s), similar to the built-in print() function. Set to False to enable automatic word-wrapping. @@ -1320,7 +1323,7 @@ def psuccess( rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: - """Wrap poutput, but apply cmd2.success style. + """Wrap poutput, but apply Cmd2Style.SUCCESS. :param objects: objects to print :param sep: string to write between print data. Defaults to " ". @@ -1353,7 +1356,7 @@ def pwarning( rich_print_kwargs: RichPrintKwargs | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: - """Wrap perror, but apply cmd2.warning style. + """Wrap perror, but apply Cmd2Style.WARNING. :param objects: objects to print :param sep: string to write between print data. Defaults to " ". @@ -1510,7 +1513,7 @@ def ppaged( # Check if we are outputting to a pager. if functional_terminal and can_block: - prepared_objects = rich_utils.prepare_objects_for_rich_print(*objects) + prepared_objects = ru.prepare_objects_for_rich_print(*objects) # Chopping overrides soft_wrap if chop: @@ -1627,7 +1630,7 @@ def tokens_for_completion(self, line: str, begidx: int, endidx: int) -> tuple[li raw_tokens = self.statement_parser.split_on_punctuation(initial_tokens) # Save the unquoted tokens - tokens = [utils.strip_quotes(cur_token) for cur_token in raw_tokens] + tokens = [su.strip_quotes(cur_token) for cur_token in raw_tokens] # If the token being completed had an unclosed quote, we need # to remove the closing quote that was added in order for it @@ -2129,7 +2132,7 @@ def _display_matches_gnu_readline( longest_match_length = 0 for cur_match in matches_to_display: - cur_length = ansi.style_aware_wcswidth(cur_match) + cur_length = su.str_width(cur_match) longest_match_length = max(longest_match_length, cur_length) else: matches_to_display = matches @@ -3121,7 +3124,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: mode = 'a' if statement.output == constants.REDIRECTION_APPEND else 'w' try: # Use line buffering - new_stdout = cast(TextIO, open(utils.strip_quotes(statement.output_to), mode=mode, buffering=1)) # noqa: SIM115 + new_stdout = cast(TextIO, open(su.strip_quotes(statement.output_to), mode=mode, buffering=1)) # noqa: SIM115 except OSError as ex: raise RedirectionError('Failed to redirect output') from ex @@ -3251,13 +3254,12 @@ def default(self, statement: Statement) -> bool | None: # type: ignore[override if self.default_to_shell: if 'shell' not in self.exclude_from_history: self.history.append(statement) - return self.do_shell(statement.command_and_args) + err_msg = self.default_error.format(statement.command) if self.suggest_similar_command and (suggested_command := self._suggest_similar_command(statement.command)): err_msg += f"\n{self.default_suggestion_message.format(suggested_command)}" - # Set apply_style to False so styles for default_error and default_suggestion_message are not overridden self.perror(err_msg, style=None) return None @@ -3798,7 +3800,7 @@ def _build_macro_create_parser(cls) -> Cmd2ArgumentParser: "\n", Text.assemble( (" my_macro beef broccoli", Cmd2Style.EXAMPLE), - (" ───> ", "bold"), + (" ───> ", Style(bold=True)), ("make_dinner --meat beef --veggie broccoli", Cmd2Style.EXAMPLE), ), ) @@ -4096,8 +4098,6 @@ def do_help(self, args: argparse.Namespace) -> None: # If there is no help information then print an error else: err_msg = self.help_error.format(args.command) - - # Set apply_style to False so help_error's style is not overridden self.perror(err_msg, style=None) self.last_result = False @@ -4151,7 +4151,7 @@ def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None if i >= size: break x = str_list[i] - colwidth = max(colwidth, ansi.style_aware_wcswidth(x)) + colwidth = max(colwidth, su.str_width(x)) colwidths.append(colwidth) totwidth += colwidth + 2 if totwidth > display_width: @@ -4172,7 +4172,7 @@ def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None while texts and not texts[-1]: del texts[-1] for col in range(len(texts)): - texts[col] = utils.align_left(texts[col], width=colwidths[col]) + texts[col] = su.align_left(texts[col], width=colwidths[col]) self.poutput(" ".join(texts)) def _help_menu(self, verbose: bool = False) -> None: @@ -4468,7 +4468,7 @@ def do_set(self, args: argparse.Namespace) -> None: # Try to update the settable's value try: orig_value = settable.get_value() - settable.set_value(utils.strip_quotes(args.value)) + settable.set_value(su.strip_quotes(args.value)) except ValueError as ex: self.perror(f"Error setting {args.param}: {ex}") else: @@ -5064,7 +5064,7 @@ def do_history(self, args: argparse.Namespace) -> bool | None: self.run_editor(fname) # self.last_result will be set by do_run_script() - return self.do_run_script(utils.quote_string(fname)) + return self.do_run_script(su.quote(fname)) finally: os.remove(fname) elif args.output_file: @@ -5359,9 +5359,9 @@ def run_editor(self, file_path: str | None = None) -> None: if not self.editor: raise OSError("Please use 'set editor' to specify your text editing program of choice.") - command = utils.quote_string(os.path.expanduser(self.editor)) + command = su.quote(os.path.expanduser(self.editor)) if file_path: - command += " " + utils.quote_string(os.path.expanduser(file_path)) + command += " " + su.quote(os.path.expanduser(file_path)) self.do_shell(command) @@ -5499,7 +5499,7 @@ def do__relative_run_script(self, args: argparse.Namespace) -> bool | None: relative_path = os.path.join(self._current_script_dir or '', script_path) # self.last_result will be set by do_run_script() - return self.do_run_script(utils.quote_string(relative_path)) + return self.do_run_script(su.quote(relative_path)) def _run_transcript_tests(self, transcript_paths: list[str]) -> None: """Run transcript tests for provided file(s). @@ -5531,11 +5531,11 @@ class TestMyAppCase(Cmd2TestCase): verinfo = ".".join(map(str, sys.version_info[:3])) num_transcripts = len(transcripts_expanded) plural = '' if len(transcripts_expanded) == 1 else 's' - self.poutput(ansi.style(utils.align_center(' cmd2 transcript test ', fill_char='='), bold=True)) + self.poutput(su.align_center(' cmd2 transcript test ', character=self.ruler), style=Style(bold=True)) self.poutput(f'platform {sys.platform} -- Python {verinfo}, cmd2-{cmd2.__version__}, readline-{rl_type}') self.poutput(f'cwd: {os.getcwd()}') self.poutput(f'cmd2 app: {sys.argv[0]}') - self.poutput(ansi.style(f'collected {num_transcripts} transcript{plural}', bold=True)) + self.poutput(f'collected {num_transcripts} transcript{plural}', style=Style(bold=True)) self.__class__.testfiles = transcripts_expanded sys.argv = [sys.argv[0]] # the --test argument upsets unittest.main() @@ -5548,7 +5548,7 @@ class TestMyAppCase(Cmd2TestCase): if test_results.wasSuccessful(): self.perror(stream.read(), end="", style=None) finish_msg = f' {num_transcripts} transcript{plural} passed in {execution_time:.3f} seconds ' - finish_msg = utils.align_center(finish_msg, fill_char='=') + finish_msg = su.align_center(finish_msg, character=self.ruler) self.psuccess(finish_msg) else: # Strip off the initial traceback which isn't particularly useful for end users @@ -5608,14 +5608,11 @@ def async_alert(self, alert_msg: str, new_prompt: str | None = None) -> None: # rl_set_prompt(self.prompt) if update_terminal: - import shutil - - # Prior to Python 3.11 this can return 0, so use a fallback if needed. - terminal_columns = shutil.get_terminal_size().columns or constants.DEFAULT_TERMINAL_WIDTH + from .terminal_utils import async_alert_str # Print a string which replaces the onscreen prompt and input lines with the alert. - terminal_str = ansi.async_alert_str( - terminal_columns=terminal_columns, + terminal_str = async_alert_str( + terminal_columns=ru.console_width(), prompt=rl_get_display_prompt(), line=readline.get_line_buffer(), cursor_offset=rl_get_point(), @@ -5691,8 +5688,10 @@ def set_window_title(title: str) -> None: # pragma: no cover if not vt100_support: return + from .terminal_utils import set_title_str + try: - sys.stderr.write(ansi.set_title(title)) + sys.stderr.write(set_title_str(title)) sys.stderr.flush() except AttributeError: # Debugging in Pycharm has issues with setting terminal title diff --git a/cmd2/colors.py b/cmd2/colors.py new file mode 100644 index 000000000..1e6853c40 --- /dev/null +++ b/cmd2/colors.py @@ -0,0 +1,270 @@ +"""Provides a convenient StrEnum for Rich color names.""" + +import sys + +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from backports.strenum import StrEnum + + +class Color(StrEnum): + """An enumeration of all color names supported by the Rich library. + + Using this enum allows for autocompletion and prevents typos when referencing + color names. The members can be used for both foreground and background colors. + + Aside from DEFAULT, these colors come from the rich.color.ANSI_COLOR_NAMES dictionary. + + Note: The terminal color settings determines the appearance of the follow 16 colors. + + | | | + |----------------|---------------| + | BLACK | BRIGHT_WHITE | + | BLUE | BRIGHT_YELLOW | + | BRIGHT_BLACK | CYAN | + | BRIGHT_BLUE | GREEN | + | BRIGHT_CYAN | MAGENTA | + | BRIGHT_GREEN | RED | + | BRIGHT_MAGENTA | WHITE | + | BRIGHT_RED | YELLOW | + """ + + DEFAULT = "default" + """Represents the terminal's default foreground or background color.""" + + AQUAMARINE1 = "aquamarine1" + AQUAMARINE3 = "aquamarine3" + BLACK = "black" + BLUE = "blue" + BLUE1 = "blue1" + BLUE3 = "blue3" + BLUE_VIOLET = "blue_violet" + BRIGHT_BLACK = "bright_black" + BRIGHT_BLUE = "bright_blue" + BRIGHT_CYAN = "bright_cyan" + BRIGHT_GREEN = "bright_green" + BRIGHT_MAGENTA = "bright_magenta" + BRIGHT_RED = "bright_red" + BRIGHT_WHITE = "bright_white" + BRIGHT_YELLOW = "bright_yellow" + CADET_BLUE = "cadet_blue" + CHARTREUSE1 = "chartreuse1" + CHARTREUSE2 = "chartreuse2" + CHARTREUSE3 = "chartreuse3" + CHARTREUSE4 = "chartreuse4" + CORNFLOWER_BLUE = "cornflower_blue" + CORNSILK1 = "cornsilk1" + CYAN = "cyan" + CYAN1 = "cyan1" + CYAN2 = "cyan2" + CYAN3 = "cyan3" + DARK_BLUE = "dark_blue" + DARK_CYAN = "dark_cyan" + DARK_GOLDENROD = "dark_goldenrod" + DARK_GREEN = "dark_green" + DARK_KHAKI = "dark_khaki" + DARK_MAGENTA = "dark_magenta" + DARK_OLIVE_GREEN1 = "dark_olive_green1" + DARK_OLIVE_GREEN2 = "dark_olive_green2" + DARK_OLIVE_GREEN3 = "dark_olive_green3" + DARK_ORANGE = "dark_orange" + DARK_ORANGE3 = "dark_orange3" + DARK_RED = "dark_red" + DARK_SEA_GREEN = "dark_sea_green" + DARK_SEA_GREEN1 = "dark_sea_green1" + DARK_SEA_GREEN2 = "dark_sea_green2" + DARK_SEA_GREEN3 = "dark_sea_green3" + DARK_SEA_GREEN4 = "dark_sea_green4" + DARK_SLATE_GRAY1 = "dark_slate_gray1" + DARK_SLATE_GRAY2 = "dark_slate_gray2" + DARK_SLATE_GRAY3 = "dark_slate_gray3" + DARK_TURQUOISE = "dark_turquoise" + DARK_VIOLET = "dark_violet" + DEEP_PINK1 = "deep_pink1" + DEEP_PINK2 = "deep_pink2" + DEEP_PINK3 = "deep_pink3" + DEEP_PINK4 = "deep_pink4" + DEEP_SKY_BLUE1 = "deep_sky_blue1" + DEEP_SKY_BLUE2 = "deep_sky_blue2" + DEEP_SKY_BLUE3 = "deep_sky_blue3" + DEEP_SKY_BLUE4 = "deep_sky_blue4" + DODGER_BLUE1 = "dodger_blue1" + DODGER_BLUE2 = "dodger_blue2" + DODGER_BLUE3 = "dodger_blue3" + GOLD1 = "gold1" + GOLD3 = "gold3" + GRAY0 = "gray0" + GRAY3 = "gray3" + GRAY7 = "gray7" + GRAY11 = "gray11" + GRAY15 = "gray15" + GRAY19 = "gray19" + GRAY23 = "gray23" + GRAY27 = "gray27" + GRAY30 = "gray30" + GRAY35 = "gray35" + GRAY37 = "gray37" + GRAY39 = "gray39" + GRAY42 = "gray42" + GRAY46 = "gray46" + GRAY50 = "gray50" + GRAY53 = "gray53" + GRAY54 = "gray54" + GRAY58 = "gray58" + GRAY62 = "gray62" + GRAY63 = "gray63" + GRAY66 = "gray66" + GRAY69 = "gray69" + GRAY70 = "gray70" + GRAY74 = "gray74" + GRAY78 = "gray78" + GRAY82 = "gray82" + GRAY84 = "gray84" + GRAY85 = "gray85" + GRAY89 = "gray89" + GRAY93 = "gray93" + GRAY100 = "gray100" + GREEN = "green" + GREEN1 = "green1" + GREEN3 = "green3" + GREEN4 = "green4" + GREEN_YELLOW = "green_yellow" + GREY0 = "grey0" + GREY3 = "grey3" + GREY7 = "grey7" + GREY11 = "grey11" + GREY15 = "grey15" + GREY19 = "grey19" + GREY23 = "grey23" + GREY27 = "grey27" + GREY30 = "grey30" + GREY35 = "grey35" + GREY37 = "grey37" + GREY39 = "grey39" + GREY42 = "grey42" + GREY46 = "grey46" + GREY50 = "grey50" + GREY53 = "grey53" + GREY54 = "grey54" + GREY58 = "grey58" + GREY62 = "grey62" + GREY63 = "grey63" + GREY66 = "grey66" + GREY69 = "grey69" + GREY70 = "grey70" + GREY74 = "grey74" + GREY78 = "grey78" + GREY82 = "grey82" + GREY84 = "grey84" + GREY85 = "grey85" + GREY89 = "grey89" + GREY93 = "grey93" + GREY100 = "grey100" + HONEYDEW2 = "honeydew2" + HOT_PINK = "hot_pink" + HOT_PINK2 = "hot_pink2" + HOT_PINK3 = "hot_pink3" + INDIAN_RED = "indian_red" + INDIAN_RED1 = "indian_red1" + KHAKI1 = "khaki1" + KHAKI3 = "khaki3" + LIGHT_CORAL = "light_coral" + LIGHT_CYAN1 = "light_cyan1" + LIGHT_CYAN3 = "light_cyan3" + LIGHT_GOLDENROD1 = "light_goldenrod1" + LIGHT_GOLDENROD2 = "light_goldenrod2" + LIGHT_GOLDENROD3 = "light_goldenrod3" + LIGHT_GREEN = "light_green" + LIGHT_PINK1 = "light_pink1" + LIGHT_PINK3 = "light_pink3" + LIGHT_PINK4 = "light_pink4" + LIGHT_SALMON1 = "light_salmon1" + LIGHT_SALMON3 = "light_salmon3" + LIGHT_SEA_GREEN = "light_sea_green" + LIGHT_SKY_BLUE1 = "light_sky_blue1" + LIGHT_SKY_BLUE3 = "light_sky_blue3" + LIGHT_SLATE_BLUE = "light_slate_blue" + LIGHT_SLATE_GRAY = "light_slate_gray" + LIGHT_SLATE_GREY = "light_slate_grey" + LIGHT_STEEL_BLUE = "light_steel_blue" + LIGHT_STEEL_BLUE1 = "light_steel_blue1" + LIGHT_STEEL_BLUE3 = "light_steel_blue3" + LIGHT_YELLOW3 = "light_yellow3" + MAGENTA = "magenta" + MAGENTA1 = "magenta1" + MAGENTA2 = "magenta2" + MAGENTA3 = "magenta3" + MEDIUM_ORCHID = "medium_orchid" + MEDIUM_ORCHID1 = "medium_orchid1" + MEDIUM_ORCHID3 = "medium_orchid3" + MEDIUM_PURPLE = "medium_purple" + MEDIUM_PURPLE1 = "medium_purple1" + MEDIUM_PURPLE2 = "medium_purple2" + MEDIUM_PURPLE3 = "medium_purple3" + MEDIUM_PURPLE4 = "medium_purple4" + MEDIUM_SPRING_GREEN = "medium_spring_green" + MEDIUM_TURQUOISE = "medium_turquoise" + MEDIUM_VIOLET_RED = "medium_violet_red" + MISTY_ROSE1 = "misty_rose1" + MISTY_ROSE3 = "misty_rose3" + NAVAJO_WHITE1 = "navajo_white1" + NAVAJO_WHITE3 = "navajo_white3" + NAVY_BLUE = "navy_blue" + ORANGE1 = "orange1" + ORANGE3 = "orange3" + ORANGE4 = "orange4" + ORANGE_RED1 = "orange_red1" + ORCHID = "orchid" + ORCHID1 = "orchid1" + ORCHID2 = "orchid2" + PALE_GREEN1 = "pale_green1" + PALE_GREEN3 = "pale_green3" + PALE_TURQUOISE1 = "pale_turquoise1" + PALE_TURQUOISE4 = "pale_turquoise4" + PALE_VIOLET_RED1 = "pale_violet_red1" + PINK1 = "pink1" + PINK3 = "pink3" + PLUM1 = "plum1" + PLUM2 = "plum2" + PLUM3 = "plum3" + PLUM4 = "plum4" + PURPLE = "purple" + PURPLE3 = "purple3" + PURPLE4 = "purple4" + RED = "red" + RED1 = "red1" + RED3 = "red3" + ROSY_BROWN = "rosy_brown" + ROYAL_BLUE1 = "royal_blue1" + SALMON1 = "salmon1" + SANDY_BROWN = "sandy_brown" + SEA_GREEN1 = "sea_green1" + SEA_GREEN2 = "sea_green2" + SEA_GREEN3 = "sea_green3" + SKY_BLUE1 = "sky_blue1" + SKY_BLUE2 = "sky_blue2" + SKY_BLUE3 = "sky_blue3" + SLATE_BLUE1 = "slate_blue1" + SLATE_BLUE3 = "slate_blue3" + SPRING_GREEN1 = "spring_green1" + SPRING_GREEN2 = "spring_green2" + SPRING_GREEN3 = "spring_green3" + SPRING_GREEN4 = "spring_green4" + STEEL_BLUE = "steel_blue" + STEEL_BLUE1 = "steel_blue1" + STEEL_BLUE3 = "steel_blue3" + TAN = "tan" + THISTLE1 = "thistle1" + THISTLE3 = "thistle3" + TURQUOISE2 = "turquoise2" + TURQUOISE4 = "turquoise4" + VIOLET = "violet" + WHEAT1 = "wheat1" + WHEAT4 = "wheat4" + WHITE = "white" + YELLOW = "yellow" + YELLOW1 = "yellow1" + YELLOW2 = "yellow2" + YELLOW3 = "yellow3" + YELLOW4 = "yellow4" diff --git a/cmd2/constants.py b/cmd2/constants.py index c82b3ca10..5d3351ebb 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -18,9 +18,6 @@ LINE_FEED = '\n' -# One character ellipsis -HORIZONTAL_ELLIPSIS = '…' - DEFAULT_SHORTCUTS = {'?': 'help', '!': 'shell', '@': 'run_script', '@@': '_relative_run_script'} # Used as the command name placeholder in disabled command messages. @@ -55,6 +52,3 @@ # custom attributes added to argparse Namespaces NS_ATTR_SUBCMD_HANDLER = '__subcmd_handler__' - -# For cases prior to Python 3.11 when shutil.get_terminal_size().columns can return 0. -DEFAULT_TERMINAL_WIDTH = 80 diff --git a/cmd2/exceptions.py b/cmd2/exceptions.py index 5d0cd1904..052c93eed 100644 --- a/cmd2/exceptions.py +++ b/cmd2/exceptions.py @@ -40,7 +40,7 @@ class CompletionError(Exception): def __init__(self, *args: Any, apply_style: bool = True) -> None: """Initialize CompletionError instance. - :param apply_style: If True, then ansi.style_error will be applied to the message text when printed. + :param apply_style: If True, then styles.ERROR will be applied to the message text when printed. Set to False in cases where the message text already has the desired style. Defaults to True. """ diff --git a/cmd2/history.py b/cmd2/history.py index 6124c30c0..e2bd67df4 100644 --- a/cmd2/history.py +++ b/cmd2/history.py @@ -14,9 +14,7 @@ overload, ) -from . import ( - utils, -) +from . import string_utils as su from .parsing import ( Statement, shlex_split, @@ -287,9 +285,9 @@ def str_search(self, search: str, include_persisted: bool = False) -> 'OrderedDi def isin(history_item: HistoryItem) -> bool: """Filter function for string search of history.""" - sloppy = utils.norm_fold(search) - inraw = sloppy in utils.norm_fold(history_item.raw) - inexpanded = sloppy in utils.norm_fold(history_item.expanded) + sloppy = su.norm_fold(search) + inraw = sloppy in su.norm_fold(history_item.raw) + inexpanded = sloppy in su.norm_fold(history_item.expanded) return inraw or inexpanded start = 0 if include_persisted else self.session_start_index diff --git a/cmd2/parsing.py b/cmd2/parsing.py index 75e6fa41d..8a6acb08f 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -7,17 +7,14 @@ dataclass, field, ) -from typing import ( - Any, -) +from typing import Any from . import ( constants, utils, ) -from .exceptions import ( - Cmd2ShlexError, -) +from . import string_utils as su +from .exceptions import Cmd2ShlexError def shlex_split(str_to_split: str) -> list[str]: @@ -211,8 +208,8 @@ def argv(self) -> list[str]: If you want to strip quotes from the input, you can use ``argv[1:]``. """ if self.command: - rtn = [utils.strip_quotes(self.command)] - rtn.extend(utils.strip_quotes(cur_token) for cur_token in self.arg_list) + rtn = [su.strip_quotes(self.command)] + rtn.extend(su.strip_quotes(cur_token) for cur_token in self.arg_list) else: rtn = [] @@ -488,7 +485,7 @@ def parse(self, line: str) -> Statement: # Check if we are redirecting to a file if len(tokens) > output_index + 1: - unquoted_path = utils.strip_quotes(tokens[output_index + 1]) + unquoted_path = su.strip_quotes(tokens[output_index + 1]) if unquoted_path: output_to = utils.expand_user(tokens[output_index + 1]) diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 6e7daa3a7..55f0f9ae1 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -1,6 +1,5 @@ """Provides common utilities to support Rich in cmd2 applications.""" -import sys from collections.abc import Mapping from enum import Enum from typing import ( @@ -25,14 +24,11 @@ from rich.theme import Theme from rich_argparse import RichHelpFormatter -if sys.version_info >= (3, 11): - from enum import StrEnum -else: - from backports.strenum import StrEnum +from .styles import DEFAULT_CMD2_STYLES class AllowStyle(Enum): - """Values for ``cmd2.rich_utils.allow_style``.""" + """Values for ``cmd2.rich_utils.ALLOW_STYLE``.""" ALWAYS = 'Always' # Always output ANSI style sequences NEVER = 'Never' # Remove ANSI style sequences from all output @@ -48,40 +44,11 @@ def __repr__(self) -> str: # Controls when ANSI style sequences are allowed in output -allow_style = AllowStyle.TERMINAL - - -class Cmd2Style(StrEnum): - """Names of styles defined in DEFAULT_CMD2_STYLES. - - Using this enum instead of string literals prevents typos and enables IDE - autocompletion, which makes it easier to discover and use the available - styles. - """ - - ERROR = "cmd2.error" - EXAMPLE = "cmd2.example" - HELP_HEADER = "cmd2.help.header" - HELP_TITLE = "cmd2.help.title" - RULE_LINE = "cmd2.rule.line" - SUCCESS = "cmd2.success" - WARNING = "cmd2.warning" - - -# Default styles used by cmd2 -DEFAULT_CMD2_STYLES: dict[str, StyleType] = { - Cmd2Style.ERROR: Style(color="bright_red"), - Cmd2Style.EXAMPLE: Style(color="cyan", bold=True), - Cmd2Style.HELP_HEADER: Style(color="cyan", bold=True), - Cmd2Style.HELP_TITLE: Style(color="bright_green", bold=True), - Cmd2Style.RULE_LINE: Style(color="bright_green"), - Cmd2Style.SUCCESS: Style(color="green"), - Cmd2Style.WARNING: Style(color="bright_yellow"), -} +ALLOW_STYLE = AllowStyle.TERMINAL class Cmd2Theme(Theme): - """Rich theme class used by Cmd2Console.""" + """Rich theme class used by cmd2.""" def __init__(self, styles: Mapping[str, StyleType] | None = None) -> None: """Cmd2Theme initializer. @@ -106,7 +73,7 @@ def __init__(self, styles: Mapping[str, StyleType] | None = None) -> None: def set_theme(new_theme: Cmd2Theme) -> None: - """Set the Rich theme used by Cmd2Console and rich-argparse. + """Set the Rich theme used by cmd2. :param new_theme: new theme to use. """ @@ -148,20 +115,22 @@ class RichPrintKwargs(TypedDict, total=False): class Cmd2Console(Console): """Rich console with characteristics appropriate for cmd2 applications.""" - def __init__(self, file: IO[str]) -> None: + def __init__(self, file: IO[str] | None = None) -> None: """Cmd2Console initializer. - :param file: a file object where the console should write to + :param file: Optional file object where the console should write to. Defaults to sys.stdout. """ - kwargs: dict[str, Any] = {} - if allow_style == AllowStyle.ALWAYS: - kwargs["force_terminal"] = True + force_terminal: bool | None = None + force_interactive: bool | None = None + + if ALLOW_STYLE == AllowStyle.ALWAYS: + force_terminal = True # Turn off interactive mode if dest is not actually a terminal which supports it tmp_console = Console(file=file) - kwargs["force_interactive"] = tmp_console.is_interactive - elif allow_style == AllowStyle.NEVER: - kwargs["force_terminal"] = False + force_interactive = tmp_console.is_interactive + elif ALLOW_STYLE == AllowStyle.NEVER: + force_terminal = False # Configure console defaults to treat output as plain, unstructured text. # This involves enabling soft wrapping (no automatic word-wrap) and disabling @@ -172,12 +141,13 @@ def __init__(self, file: IO[str]) -> None: # in individual Console.print() calls or via cmd2's print methods. super().__init__( file=file, + force_terminal=force_terminal, + force_interactive=force_interactive, soft_wrap=True, markup=False, emoji=False, highlight=False, theme=THEME, - **kwargs, ) def on_broken_pipe(self) -> None: @@ -186,8 +156,39 @@ def on_broken_pipe(self) -> None: raise BrokenPipeError -def from_ansi(text: str) -> Text: - r"""Patched version of rich.Text.from_ansi() that handles a discarded newline issue. +def console_width() -> int: + """Return the width of the console.""" + return Cmd2Console().width + + +def rich_text_to_string(text: Text) -> str: + """Convert a Rich Text object to a string. + + This function's purpose is to render a Rich Text object, including any styles (e.g., color, bold), + to a plain Python string with ANSI escape codes. It differs from `text.plain`, which strips + all formatting. + + :param text: the text object to convert + :return: the resulting string with ANSI styles preserved. + """ + console = Console( + force_terminal=True, + soft_wrap=True, + no_color=False, + markup=False, + emoji=False, + highlight=False, + theme=THEME, + ) + with console.capture() as capture: + console.print(text, end="") + return capture.get() + + +def string_to_rich_text(text: str) -> Text: + r"""Create a Text object from a string which can contain ANSI escape codes. + + This wraps rich.Text.from_ansi() to handle a discarded newline issue. Text.from_ansi() currently removes the ending line break from string. e.g. "Hello\n" becomes "Hello" @@ -237,5 +238,5 @@ def prepare_objects_for_rich_print(*objects: Any) -> tuple[RenderableType, ...]: object_list = list(objects) for i, obj in enumerate(object_list): if not isinstance(obj, (ConsoleRenderable, RichCast)): - object_list[i] = from_ansi(str(obj)) + object_list[i] = string_to_rich_text(str(obj)) return tuple(object_list) diff --git a/cmd2/string_utils.py b/cmd2/string_utils.py new file mode 100644 index 000000000..1405b5f58 --- /dev/null +++ b/cmd2/string_utils.py @@ -0,0 +1,166 @@ +"""Provides string utility functions. + +This module offers a collection of string utility functions built on the Rich library. +These utilities are designed to correctly handle strings with complex formatting, such as +ANSI escape codes and full-width characters (like those used in CJK languages), which the +standard Python library's string methods do not properly support. +""" + +from rich.align import AlignMethod +from rich.style import StyleType + +from . import rich_utils as ru + + +def align( + val: str, + align: AlignMethod, + width: int | None = None, + character: str = " ", +) -> str: + """Align string to a given width. + + There are convenience wrappers around this function: align_left(), align_center(), and align_right() + + :param val: string to align + :param align: one of "left", "center", or "right". + :param width: Desired width. Defaults to width of the terminal. + :param character: Character to pad with. Defaults to " ". + + """ + if width is None: + width = ru.console_width() + + text = ru.string_to_rich_text(val) + text.align(align, width=width, character=character) + return ru.rich_text_to_string(text) + + +def align_left( + val: str, + width: int | None = None, + character: str = " ", +) -> str: + """Left-align string to a given width. + + :param val: string to align + :param width: Desired width. Defaults to width of the terminal. + :param character: Character to pad with. Defaults to " ". + + """ + return align(val, "left", width=width, character=character) + + +def align_center( + val: str, + width: int | None = None, + character: str = " ", +) -> str: + """Center-align string to a given width. + + :param val: string to align + :param width: Desired width. Defaults to width of the terminal. + :param character: Character to pad with. Defaults to " ". + + """ + return align(val, "center", width=width, character=character) + + +def align_right( + val: str, + width: int | None = None, + character: str = " ", +) -> str: + """Right-align string to a given width. + + :param val: string to align + :param width: Desired width. Defaults to width of the terminal. + :param character: Character to pad with. Defaults to " ". + + """ + return align(val, "right", width=width, character=character) + + +def stylize(val: str, style: StyleType) -> str: + """Apply ANSI style to a string. + + :param val: string to be styled + :param style: style instance or style definition to apply. + :return: the stylized string + """ + text = ru.string_to_rich_text(val) + text.stylize(style) + return ru.rich_text_to_string(text) + + +def strip_style(val: str) -> str: + """Strip ANSI style sequences from a string. + + :param val: string which may contain ANSI style sequences + :return: the same string with any ANSI style sequences removed + """ + text = ru.string_to_rich_text(val) + return text.plain + + +def str_width(val: str) -> int: + """Return the display width of a string. + + This is intended for single line strings. + Replace tabs with spaces before calling this. + + :param val: the string being measured + :return: width of the string when printed to the terminal + """ + text = ru.string_to_rich_text(val) + return text.cell_len + + +def is_quoted(val: str) -> bool: + """Check if a string is quoted. + + :param val: the string being checked for quotes + :return: True if a string is quoted + """ + from . import constants + + return len(val) > 1 and val[0] == val[-1] and val[0] in constants.QUOTES + + +def quote(val: str) -> str: + """Quote a string.""" + quote = "'" if '"' in val else '"' + + return quote + val + quote + + +def quote_if_needed(val: str) -> str: + """Quote a string if it contains spaces and isn't already quoted.""" + if is_quoted(val) or ' ' not in val: + return val + + return quote(val) + + +def strip_quotes(val: str) -> str: + """Strip outer quotes from a string. + + Applies to both single and double quotes. + + :param val: string to strip outer quotes from + :return: same string with potentially outer quotes stripped + """ + if is_quoted(val): + val = val[1:-1] + return val + + +def norm_fold(val: str) -> str: + """Normalize and casefold Unicode strings for saner comparisons. + + :param val: input unicode string + :return: a normalized and case-folded version of the input string + """ + import unicodedata + + return unicodedata.normalize('NFC', val).casefold() diff --git a/cmd2/styles.py b/cmd2/styles.py new file mode 100644 index 000000000..f11ab7245 --- /dev/null +++ b/cmd2/styles.py @@ -0,0 +1,51 @@ +"""Defines custom Rich styles and their corresponding names for cmd2. + +This module provides a centralized and discoverable way to manage Rich styles used +within the cmd2 framework. It defines a StrEnum for style names and a dictionary +that maps these names to their default style objects. +""" + +import sys + +from rich.style import ( + Style, + StyleType, +) + +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from backports.strenum import StrEnum + +from .colors import Color + + +class Cmd2Style(StrEnum): + """An enumeration of the names of custom Rich styles used in cmd2. + + Using this enum allows for autocompletion and prevents typos when + referencing cmd2-specific styles. + + This StrEnum is tightly coupled with DEFAULT_CMD2_STYLES. Any name + added here must have a corresponding style definition there. + """ + + ERROR = "cmd2.error" + EXAMPLE = "cmd2.example" + HELP_HEADER = "cmd2.help.header" + HELP_TITLE = "cmd2.help.title" + RULE_LINE = "cmd2.rule.line" + SUCCESS = "cmd2.success" + WARNING = "cmd2.warning" + + +# Default styles used by cmd2. Tightly coupled with the Cmd2Style enum. +DEFAULT_CMD2_STYLES: dict[str, StyleType] = { + Cmd2Style.ERROR: Style(color=Color.BRIGHT_RED), + Cmd2Style.EXAMPLE: Style(color=Color.CYAN, bold=True), + Cmd2Style.HELP_HEADER: Style(color=Color.CYAN, bold=True), + Cmd2Style.HELP_TITLE: Style(color=Color.BRIGHT_GREEN, bold=True), + Cmd2Style.RULE_LINE: Style(color=Color.BRIGHT_GREEN), + Cmd2Style.SUCCESS: Style(color=Color.GREEN), + Cmd2Style.WARNING: Style(color=Color.BRIGHT_YELLOW), +} diff --git a/cmd2/table_creator.py b/cmd2/table_creator.py deleted file mode 100644 index df1a722b5..000000000 --- a/cmd2/table_creator.py +++ /dev/null @@ -1,1121 +0,0 @@ -"""cmd2 table creation API. - -This API is built upon two core classes: Column and TableCreator -The general use case is to inherit from TableCreator to create a table class with custom formatting options. -There are already implemented and ready-to-use examples of this below TableCreator's code. -""" - -import copy -import io -from collections import ( - deque, -) -from collections.abc import Sequence -from enum import ( - Enum, -) -from typing import ( - Any, -) - -from wcwidth import ( # type: ignore[import] - wcwidth, -) - -from . import ( - ansi, - constants, - utils, -) - -# Constants -EMPTY = '' -SPACE = ' ' - - -class HorizontalAlignment(Enum): - """Horizontal alignment of text in a cell.""" - - LEFT = 1 - CENTER = 2 - RIGHT = 3 - - -class VerticalAlignment(Enum): - """Vertical alignment of text in a cell.""" - - TOP = 1 - MIDDLE = 2 - BOTTOM = 3 - - -class Column: - """Table column configuration.""" - - def __init__( - self, - header: str, - *, - width: int | None = None, - header_horiz_align: HorizontalAlignment = HorizontalAlignment.LEFT, - header_vert_align: VerticalAlignment = VerticalAlignment.BOTTOM, - style_header_text: bool = True, - data_horiz_align: HorizontalAlignment = HorizontalAlignment.LEFT, - data_vert_align: VerticalAlignment = VerticalAlignment.TOP, - style_data_text: bool = True, - max_data_lines: float = constants.INFINITY, - ) -> None: - """Column initializer. - - :param header: label for column header - :param width: display width of column. This does not account for any borders or padding which - may be added (e.g pre_line, inter_cell, and post_line). Header and data text wrap within - this width using word-based wrapping (defaults to actual width of header or 1 if header is blank) - :param header_horiz_align: horizontal alignment of header cells (defaults to left) - :param header_vert_align: vertical alignment of header cells (defaults to bottom) - :param style_header_text: if True, then the table is allowed to apply styles to the header text, which may - conflict with any styles the header already has. If False, the header is printed as is. - Table classes which apply style to headers must account for the value of this flag. - (defaults to True) - :param data_horiz_align: horizontal alignment of data cells (defaults to left) - :param data_vert_align: vertical alignment of data cells (defaults to top) - :param style_data_text: if True, then the table is allowed to apply styles to the data text, which may - conflict with any styles the data already has. If False, the data is printed as is. - Table classes which apply style to data must account for the value of this flag. - (defaults to True) - :param max_data_lines: maximum lines allowed in a data cell. If line count exceeds this, then the final - line displayed will be truncated with an ellipsis. (defaults to INFINITY) - :raises ValueError: if width is less than 1 - :raises ValueError: if max_data_lines is less than 1 - """ - self.header = header - - if width is not None and width < 1: - raise ValueError("Column width cannot be less than 1") - self.width: int = width if width is not None else -1 - - self.header_horiz_align = header_horiz_align - self.header_vert_align = header_vert_align - self.style_header_text = style_header_text - - self.data_horiz_align = data_horiz_align - self.data_vert_align = data_vert_align - self.style_data_text = style_data_text - - if max_data_lines < 1: - raise ValueError("Max data lines cannot be less than 1") - - self.max_data_lines = max_data_lines - - -class TableCreator: - """Base table creation class. - - This class handles ANSI style sequences and characters with display widths greater than 1 - when performing width calculations. It was designed with the ability to build tables one row at a time. This helps - when you have large data sets that you don't want to hold in memory or when you receive portions of the data set - incrementally. - - TableCreator has one public method: generate_row() - - This function and the Column class provide all features needed to build tables with headers, borders, colors, - horizontal and vertical alignment, and wrapped text. However, it's generally easier to inherit from this class and - implement a more granular API rather than use TableCreator directly. There are ready-to-use examples of this - defined after this class. - """ - - def __init__(self, cols: Sequence[Column], *, tab_width: int = 4) -> None: - """TableCreator initializer. - - :param cols: column definitions for this table - :param tab_width: all tabs will be replaced with this many spaces. If a row's fill_char is a tab, - then it will be converted to one space. - :raises ValueError: if tab_width is less than 1 - """ - if tab_width < 1: - raise ValueError("Tab width cannot be less than 1") - - self.cols = copy.copy(cols) - self.tab_width = tab_width - - for col in self.cols: - # Replace tabs before calculating width of header strings - col.header = col.header.replace('\t', SPACE * self.tab_width) - - # For headers with the width not yet set, use the width of the - # widest line in the header or 1 if the header has no width - if col.width <= 0: - col.width = max(1, ansi.widest_line(col.header)) - - @staticmethod - def _wrap_long_word(word: str, max_width: int, max_lines: float, is_last_word: bool) -> tuple[str, int, int]: - """Wrap a long word over multiple lines, used by _wrap_text(). - - :param word: word being wrapped - :param max_width: maximum display width of a line - :param max_lines: maximum lines to wrap before ending the last line displayed with an ellipsis - :param is_last_word: True if this is the last word of the total text being wrapped - :return: Tuple(wrapped text, lines used, display width of last line) - """ - styles_dict = utils.get_styles_dict(word) - wrapped_buf = io.StringIO() - - # How many lines we've used - total_lines = 1 - - # Display width of the current line we are building - cur_line_width = 0 - - char_index = 0 - while char_index < len(word): - # We've reached the last line. Let truncate_line do the rest. - if total_lines == max_lines: - # If this isn't the last word, but it's gonna fill the final line, then force truncate_line - # to place an ellipsis at the end of it by making the word too wide. - remaining_word = word[char_index:] - if not is_last_word and ansi.style_aware_wcswidth(remaining_word) == max_width: - remaining_word += "EXTRA" - - truncated_line = utils.truncate_line(remaining_word, max_width) - cur_line_width = ansi.style_aware_wcswidth(truncated_line) - wrapped_buf.write(truncated_line) - break - - # Check if we're at a style sequence. These don't count toward display width. - if char_index in styles_dict: - wrapped_buf.write(styles_dict[char_index]) - char_index += len(styles_dict[char_index]) - continue - - cur_char = word[char_index] - cur_char_width = wcwidth(cur_char) - - if cur_char_width > max_width: - # We have a case where the character is wider than max_width. This can happen if max_width - # is 1 and the text contains wide characters (e.g. East Asian). Replace it with an ellipsis. - cur_char = constants.HORIZONTAL_ELLIPSIS - cur_char_width = wcwidth(cur_char) - - if cur_line_width + cur_char_width > max_width: - # Adding this char will exceed the max_width. Start a new line. - wrapped_buf.write('\n') - total_lines += 1 - cur_line_width = 0 - continue - - # Add this character and move to the next one - cur_line_width += cur_char_width - wrapped_buf.write(cur_char) - char_index += 1 - - return wrapped_buf.getvalue(), total_lines, cur_line_width - - @staticmethod - def _wrap_text(text: str, max_width: int, max_lines: float) -> str: - """Wrap text into lines with a display width no longer than max_width. - - This function breaks words on whitespace boundaries. If a word is longer than the space remaining on a line, - then it will start on a new line. ANSI escape sequences do not count toward the width of a line. - - :param text: text to be wrapped - :param max_width: maximum display width of a line - :param max_lines: maximum lines to wrap before ending the last line displayed with an ellipsis - :return: wrapped text - """ - # MyPy Issue #7057 documents regression requiring nonlocals to be defined earlier - cur_line_width = 0 - total_lines = 0 - - def add_word(word_to_add: str, is_last_word: bool) -> None: - """Aadd a word to the wrapped text, called from loop. - - :param word_to_add: the word being added - :param is_last_word: True if this is the last word of the total text being wrapped - """ - nonlocal cur_line_width - nonlocal total_lines - - # No more space to add word - if total_lines == max_lines and cur_line_width == max_width: - return - - word_width = ansi.style_aware_wcswidth(word_to_add) - - # If the word is wider than max width of a line, attempt to start it on its own line and wrap it - if word_width > max_width: - room_to_add = True - - if cur_line_width > 0: - # The current line already has text, check if there is room to create a new line - if total_lines < max_lines: - wrapped_buf.write('\n') - total_lines += 1 - else: - # We will truncate this word on the remaining line - room_to_add = False - - if room_to_add: - wrapped_word, lines_used, cur_line_width = TableCreator._wrap_long_word( - word_to_add, max_width, max_lines - total_lines + 1, is_last_word - ) - # Write the word to the buffer - wrapped_buf.write(wrapped_word) - total_lines += lines_used - 1 - return - - # We aren't going to wrap the word across multiple lines - remaining_width = max_width - cur_line_width - - # Check if we need to start a new line - if word_width > remaining_width and total_lines < max_lines: - # Save the last character in wrapped_buf, which can't be empty at this point. - seek_pos = wrapped_buf.tell() - 1 - wrapped_buf.seek(seek_pos) - last_char = wrapped_buf.read() - - wrapped_buf.write('\n') - total_lines += 1 - cur_line_width = 0 - remaining_width = max_width - - # Only when a space is following a space do we want to start the next line with it. - if word_to_add == SPACE and last_char != SPACE: - return - - # Check if we've hit the last line we're allowed to create - if total_lines == max_lines: - # If this word won't fit, truncate it - if word_width > remaining_width: - word_to_add = utils.truncate_line(word_to_add, remaining_width) - word_width = remaining_width - - # If this isn't the last word, but it's gonna fill the final line, then force truncate_line - # to place an ellipsis at the end of it by making the word too wide. - elif not is_last_word and word_width == remaining_width: - word_to_add = utils.truncate_line(word_to_add + "EXTRA", remaining_width) - - cur_line_width += word_width - wrapped_buf.write(word_to_add) - - ############################################################################################################ - # _wrap_text() main code - ############################################################################################################ - # Buffer of the wrapped text - wrapped_buf = io.StringIO() - - # How many lines we've used - total_lines = 0 - - # Respect the existing line breaks - data_str_lines = text.splitlines() - for data_line_index, data_line in enumerate(data_str_lines): - total_lines += 1 - - if data_line_index > 0: - wrapped_buf.write('\n') - - # If the last line is empty, then add a newline and stop - if data_line_index == len(data_str_lines) - 1 and not data_line: - wrapped_buf.write('\n') - break - - # Locate the styles in this line - styles_dict = utils.get_styles_dict(data_line) - - # Display width of the current line we are building - cur_line_width = 0 - - # Current word being built - cur_word_buf = io.StringIO() - - char_index = 0 - while char_index < len(data_line): - if total_lines == max_lines and cur_line_width == max_width: - break - - # Check if we're at a style sequence. These don't count toward display width. - if char_index in styles_dict: - cur_word_buf.write(styles_dict[char_index]) - char_index += len(styles_dict[char_index]) - continue - - cur_char = data_line[char_index] - if cur_char == SPACE: - # If we've reached the end of a word, then add the word to the wrapped text - if cur_word_buf.tell() > 0: - # is_last_word is False since there is a space after the word - add_word(cur_word_buf.getvalue(), is_last_word=False) - cur_word_buf = io.StringIO() - - # Add the space to the wrapped text - last_word = data_line_index == len(data_str_lines) - 1 and char_index == len(data_line) - 1 - add_word(cur_char, last_word) - else: - # Add this character to the word buffer - cur_word_buf.write(cur_char) - - char_index += 1 - - # Add the final word of this line if it's been started - if cur_word_buf.tell() > 0: - last_word = data_line_index == len(data_str_lines) - 1 and char_index == len(data_line) - add_word(cur_word_buf.getvalue(), last_word) - - # Stop line loop if we've written to max_lines - if total_lines == max_lines: - # If this isn't the last data line and there is space - # left on the final wrapped line, then add an ellipsis - if data_line_index < len(data_str_lines) - 1 and cur_line_width < max_width: - wrapped_buf.write(constants.HORIZONTAL_ELLIPSIS) - break - - return wrapped_buf.getvalue() - - def _generate_cell_lines(self, cell_data: Any, is_header: bool, col: Column, fill_char: str) -> tuple[deque[str], int]: - """Generate the lines of a table cell. - - :param cell_data: data to be included in cell - :param is_header: True if writing a header cell, otherwise writing a data cell. This determines whether to - use header or data alignment settings as well as maximum lines to wrap. - :param col: Column definition for this cell - :param fill_char: character that fills remaining space in a cell. If your text has a background color, - then give fill_char the same background color. (Cannot be a line breaking character) - :return: Tuple(deque of cell lines, display width of the cell) - """ - # Convert data to string and replace tabs with spaces - data_str = str(cell_data).replace('\t', SPACE * self.tab_width) - - # Wrap text in this cell - max_lines = constants.INFINITY if is_header else col.max_data_lines - wrapped_text = self._wrap_text(data_str, col.width, max_lines) - - # Align the text horizontally - horiz_alignment = col.header_horiz_align if is_header else col.data_horiz_align - if horiz_alignment == HorizontalAlignment.LEFT: - text_alignment = utils.TextAlignment.LEFT - elif horiz_alignment == HorizontalAlignment.CENTER: - text_alignment = utils.TextAlignment.CENTER - else: - text_alignment = utils.TextAlignment.RIGHT - - aligned_text = utils.align_text(wrapped_text, fill_char=fill_char, width=col.width, alignment=text_alignment) - - # Calculate cell_width first to avoid having 2 copies of aligned_text.splitlines() in memory - cell_width = ansi.widest_line(aligned_text) - lines = deque(aligned_text.splitlines()) - - return lines, cell_width - - def generate_row( - self, - row_data: Sequence[Any], - is_header: bool, - *, - fill_char: str = SPACE, - pre_line: str = EMPTY, - inter_cell: str = (2 * SPACE), - post_line: str = EMPTY, - ) -> str: - """Generate a header or data table row. - - :param row_data: data with an entry for each column in the row - :param is_header: True if writing a header cell, otherwise writing a data cell. This determines whether to - use header or data alignment settings as well as maximum lines to wrap. - :param fill_char: character that fills remaining space in a cell. Defaults to space. If this is a tab, - then it will be converted to one space. (Cannot be a line breaking character) - :param pre_line: string to print before each line of a row. This can be used for a left row border and - padding before the first cell's text. (Defaults to blank) - :param inter_cell: string to print where two cells meet. This can be used for a border between cells and padding - between it and the 2 cells' text. (Defaults to 2 spaces) - :param post_line: string to print after each line of a row. This can be used for padding after - the last cell's text and a right row border. (Defaults to blank) - :return: row string - :raises ValueError: if row_data isn't the same length as self.cols - :raises TypeError: if fill_char is more than one character (not including ANSI style sequences) - :raises ValueError: if fill_char, pre_line, inter_cell, or post_line contains an unprintable - character like a newline - """ - - class Cell: - """Inner class which represents a table cell.""" - - def __init__(self) -> None: - # Data in this cell split into individual lines - self.lines: deque[str] = deque() - - # Display width of this cell - self.width = 0 - - if len(row_data) != len(self.cols): - raise ValueError("Length of row_data must match length of cols") - - # Replace tabs (tabs in data strings will be handled in _generate_cell_lines()) - fill_char = fill_char.replace('\t', SPACE) - pre_line = pre_line.replace('\t', SPACE * self.tab_width) - inter_cell = inter_cell.replace('\t', SPACE * self.tab_width) - post_line = post_line.replace('\t', SPACE * self.tab_width) - - # Validate fill_char character count - if len(ansi.strip_style(fill_char)) != 1: - raise TypeError("Fill character must be exactly one character long") - - # Look for unprintable characters - validation_dict = {'fill_char': fill_char, 'pre_line': pre_line, 'inter_cell': inter_cell, 'post_line': post_line} - for key, val in validation_dict.items(): - if ansi.style_aware_wcswidth(val) == -1: - raise ValueError(f"{key} contains an unprintable character") - - # Number of lines this row uses - total_lines = 0 - - # Generate the cells for this row - cells = [] - - for col_index, col in enumerate(self.cols): - cell = Cell() - cell.lines, cell.width = self._generate_cell_lines(row_data[col_index], is_header, col, fill_char) - cells.append(cell) - total_lines = max(len(cell.lines), total_lines) - - row_buf = io.StringIO() - - # Vertically align each cell - for cell_index, cell in enumerate(cells): - col = self.cols[cell_index] - vert_align = col.header_vert_align if is_header else col.data_vert_align - - # Check if this cell need vertical filler - line_diff = total_lines - len(cell.lines) - if line_diff == 0: - continue - - # Add vertical filler lines - padding_line = utils.align_left(EMPTY, fill_char=fill_char, width=cell.width) - if vert_align == VerticalAlignment.TOP: - to_top = 0 - to_bottom = line_diff - elif vert_align == VerticalAlignment.MIDDLE: - to_top = line_diff // 2 - to_bottom = line_diff - to_top - else: - to_top = line_diff - to_bottom = 0 - - for _ in range(to_top): - cell.lines.appendleft(padding_line) - for _ in range(to_bottom): - cell.lines.append(padding_line) - - # Build this row one line at a time - for line_index in range(total_lines): - for cell_index, cell in enumerate(cells): - if cell_index == 0: - row_buf.write(pre_line) - - row_buf.write(cell.lines[line_index]) - - if cell_index < len(self.cols) - 1: - row_buf.write(inter_cell) - if cell_index == len(self.cols) - 1: - row_buf.write(post_line) - - # Add a newline if this is not the last line - if line_index < total_lines - 1: - row_buf.write('\n') - - return row_buf.getvalue() - - -############################################################################################################ -# The following are implementations of TableCreator which demonstrate how to make various types -# of tables. They can be used as-is or serve as inspiration for other custom table classes. -############################################################################################################ -class SimpleTable(TableCreator): - """Implementation of TableCreator which generates a borderless table with an optional divider row after the header. - - This class can be used to create the whole table at once or one row at a time. - """ - - def __init__( - self, - cols: Sequence[Column], - *, - column_spacing: int = 2, - tab_width: int = 4, - divider_char: str | None = '-', - header_bg: ansi.BgColor | None = None, - data_bg: ansi.BgColor | None = None, - ) -> None: - """SimpleTable initializer. - - :param cols: column definitions for this table - :param column_spacing: how many spaces to place between columns. Defaults to 2. - :param tab_width: all tabs will be replaced with this many spaces. If a row's fill_char is a tab, - then it will be converted to one space. - :param divider_char: optional character used to build the header divider row. Set this to blank or None if you don't - want a divider row. Defaults to dash. (Cannot be a line breaking character) - :param header_bg: optional background color for header cells (defaults to None) - :param data_bg: optional background color for data cells (defaults to None) - :raises ValueError: if tab_width is less than 1 - :raises ValueError: if column_spacing is less than 0 - :raises TypeError: if divider_char is longer than one character - :raises ValueError: if divider_char is an unprintable character - """ - super().__init__(cols, tab_width=tab_width) - - if column_spacing < 0: - raise ValueError("Column spacing cannot be less than 0") - - self.column_spacing = column_spacing - - if divider_char == '': - divider_char = None - - if divider_char is not None: - if len(ansi.strip_style(divider_char)) != 1: - raise TypeError("Divider character must be exactly one character long") - - divider_char_width = ansi.style_aware_wcswidth(divider_char) - if divider_char_width == -1: - raise ValueError("Divider character is an unprintable character") - - self.divider_char = divider_char - self.header_bg = header_bg - self.data_bg = data_bg - - def apply_header_bg(self, value: Any) -> str: - """If defined, apply the header background color to header text. - - :param value: object whose text is to be colored - :return: formatted text. - """ - if self.header_bg is None: - return str(value) - return ansi.style(value, bg=self.header_bg) - - def apply_data_bg(self, value: Any) -> str: - """If defined, apply the data background color to data text. - - :param value: object whose text is to be colored - :return: formatted data string. - """ - if self.data_bg is None: - return str(value) - return ansi.style(value, bg=self.data_bg) - - @classmethod - def base_width(cls, num_cols: int, *, column_spacing: int = 2) -> int: - """Calculate the display width required for a table before data is added to it. - - This is useful when determining how wide to make your columns to have a table be a specific width. - - :param num_cols: how many columns the table will have - :param column_spacing: how many spaces to place between columns. Defaults to 2. - :return: base width - :raises ValueError: if column_spacing is less than 0 - :raises ValueError: if num_cols is less than 1 - """ - if num_cols < 1: - raise ValueError("Column count cannot be less than 1") - - data_str = SPACE - data_width = ansi.style_aware_wcswidth(data_str) * num_cols - - tbl = cls([Column(data_str)] * num_cols, column_spacing=column_spacing) - data_row = tbl.generate_data_row([data_str] * num_cols) - - return ansi.style_aware_wcswidth(data_row) - data_width - - def total_width(self) -> int: - """Calculate the total display width of this table.""" - base_width = self.base_width(len(self.cols), column_spacing=self.column_spacing) - data_width = sum(col.width for col in self.cols) - return base_width + data_width - - def generate_header(self) -> str: - """Generate table header with an optional divider row.""" - header_buf = io.StringIO() - - fill_char = self.apply_header_bg(SPACE) - inter_cell = self.apply_header_bg(self.column_spacing * SPACE) - - # Apply background color to header text in Columns which allow it - to_display: list[Any] = [] - for col in self.cols: - if col.style_header_text: - to_display.append(self.apply_header_bg(col.header)) - else: - to_display.append(col.header) - - # Create the header labels - header_labels = self.generate_row(to_display, is_header=True, fill_char=fill_char, inter_cell=inter_cell) - header_buf.write(header_labels) - - # Add the divider if necessary - divider = self.generate_divider() - if divider: - header_buf.write('\n' + divider) - - return header_buf.getvalue() - - def generate_divider(self) -> str: - """Generate divider row.""" - if self.divider_char is None: - return '' - - return utils.align_left('', fill_char=self.divider_char, width=self.total_width()) - - def generate_data_row(self, row_data: Sequence[Any]) -> str: - """Generate a data row. - - :param row_data: data with an entry for each column in the row - :return: data row string - :raises ValueError: if row_data isn't the same length as self.cols - """ - if len(row_data) != len(self.cols): - raise ValueError("Length of row_data must match length of cols") - - fill_char = self.apply_data_bg(SPACE) - inter_cell = self.apply_data_bg(self.column_spacing * SPACE) - - # Apply background color to data text in Columns which allow it - to_display: list[Any] = [] - for index, col in enumerate(self.cols): - if col.style_data_text: - to_display.append(self.apply_data_bg(row_data[index])) - else: - to_display.append(row_data[index]) - - return self.generate_row(to_display, is_header=False, fill_char=fill_char, inter_cell=inter_cell) - - def generate_table(self, table_data: Sequence[Sequence[Any]], *, include_header: bool = True, row_spacing: int = 1) -> str: - """Generate a table from a data set. - - :param table_data: Data with an entry for each data row of the table. Each entry should have data for - each column in the row. - :param include_header: If True, then a header will be included at top of table. (Defaults to True) - :param row_spacing: A number 0 or greater specifying how many blank lines to place between - each row (Defaults to 1) - :raises ValueError: if row_spacing is less than 0 - """ - if row_spacing < 0: - raise ValueError("Row spacing cannot be less than 0") - - table_buf = io.StringIO() - - if include_header: - header = self.generate_header() - table_buf.write(header) - if len(table_data) > 0: - table_buf.write('\n') - - row_divider = utils.align_left('', fill_char=self.apply_data_bg(SPACE), width=self.total_width()) + '\n' - - for index, row_data in enumerate(table_data): - if index > 0 and row_spacing > 0: - table_buf.write(row_spacing * row_divider) - - row = self.generate_data_row(row_data) - table_buf.write(row) - if index < len(table_data) - 1: - table_buf.write('\n') - - return table_buf.getvalue() - - -class BorderedTable(TableCreator): - """Implementation of TableCreator which generates a table with borders around the table and between rows. - - Borders between columns can also be toggled. This class can be used to create the whole table at once or one row at a time. - """ - - def __init__( - self, - cols: Sequence[Column], - *, - tab_width: int = 4, - column_borders: bool = True, - padding: int = 1, - border_fg: ansi.FgColor | None = None, - border_bg: ansi.BgColor | None = None, - header_bg: ansi.BgColor | None = None, - data_bg: ansi.BgColor | None = None, - ) -> None: - """BorderedTable initializer. - - :param cols: column definitions for this table - :param tab_width: all tabs will be replaced with this many spaces. If a row's fill_char is a tab, - then it will be converted to one space. - :param column_borders: if True, borders between columns will be included. This gives the table a grid-like - appearance. Turning off column borders results in a unified appearance between - a row's cells. (Defaults to True) - :param padding: number of spaces between text and left/right borders of cell - :param border_fg: optional foreground color for borders (defaults to None) - :param border_bg: optional background color for borders (defaults to None) - :param header_bg: optional background color for header cells (defaults to None) - :param data_bg: optional background color for data cells (defaults to None) - :raises ValueError: if tab_width is less than 1 - :raises ValueError: if padding is less than 0 - """ - super().__init__(cols, tab_width=tab_width) - self.empty_data = [EMPTY] * len(self.cols) - self.column_borders = column_borders - - if padding < 0: - raise ValueError("Padding cannot be less than 0") - self.padding = padding - - self.border_fg = border_fg - self.border_bg = border_bg - self.header_bg = header_bg - self.data_bg = data_bg - - def apply_border_color(self, value: Any) -> str: - """If defined, apply the border foreground and background colors. - - :param value: object whose text is to be colored - :return: formatted text. - """ - if self.border_fg is None and self.border_bg is None: - return str(value) - return ansi.style(value, fg=self.border_fg, bg=self.border_bg) - - def apply_header_bg(self, value: Any) -> str: - """If defined, apply the header background color to header text. - - :param value: object whose text is to be colored - :return: formatted text. - """ - if self.header_bg is None: - return str(value) - return ansi.style(value, bg=self.header_bg) - - def apply_data_bg(self, value: Any) -> str: - """If defined, apply the data background color to data text. - - :param value: object whose text is to be colored - :return: formatted data string. - """ - if self.data_bg is None: - return str(value) - return ansi.style(value, bg=self.data_bg) - - @classmethod - def base_width(cls, num_cols: int, *, column_borders: bool = True, padding: int = 1) -> int: - """Calculate the display width required for a table before data is added to it. - - This is useful when determining how wide to make your columns to have a table be a specific width. - - :param num_cols: how many columns the table will have - :param column_borders: if True, borders between columns will be included in the calculation (Defaults to True) - :param padding: number of spaces between text and left/right borders of cell - :return: base width - :raises ValueError: if num_cols is less than 1 - """ - if num_cols < 1: - raise ValueError("Column count cannot be less than 1") - - data_str = SPACE - data_width = ansi.style_aware_wcswidth(data_str) * num_cols - - tbl = cls([Column(data_str)] * num_cols, column_borders=column_borders, padding=padding) - data_row = tbl.generate_data_row([data_str] * num_cols) - - return ansi.style_aware_wcswidth(data_row) - data_width - - def total_width(self) -> int: - """Calculate the total display width of this table.""" - base_width = self.base_width(len(self.cols), column_borders=self.column_borders, padding=self.padding) - data_width = sum(col.width for col in self.cols) - return base_width + data_width - - def generate_table_top_border(self) -> str: - """Generate a border which appears at the top of the header and data section.""" - fill_char = '═' - - pre_line = '╔' + self.padding * '═' - - inter_cell = self.padding * '═' - if self.column_borders: - inter_cell += "╤" - inter_cell += self.padding * '═' - - post_line = self.padding * '═' + '╗' - - return self.generate_row( - self.empty_data, - is_header=False, - fill_char=self.apply_border_color(fill_char), - pre_line=self.apply_border_color(pre_line), - inter_cell=self.apply_border_color(inter_cell), - post_line=self.apply_border_color(post_line), - ) - - def generate_header_bottom_border(self) -> str: - """Generate a border which appears at the bottom of the header.""" - fill_char = '═' - - pre_line = '╠' + self.padding * '═' - - inter_cell = self.padding * '═' - if self.column_borders: - inter_cell += '╪' - inter_cell += self.padding * '═' - - post_line = self.padding * '═' + '╣' - - return self.generate_row( - self.empty_data, - is_header=False, - fill_char=self.apply_border_color(fill_char), - pre_line=self.apply_border_color(pre_line), - inter_cell=self.apply_border_color(inter_cell), - post_line=self.apply_border_color(post_line), - ) - - def generate_row_bottom_border(self) -> str: - """Generate a border which appears at the bottom of rows.""" - fill_char = '─' - - pre_line = '╟' + self.padding * '─' - - inter_cell = self.padding * '─' - if self.column_borders: - inter_cell += '┼' - inter_cell += self.padding * '─' - - post_line = self.padding * '─' + '╢' - - return self.generate_row( - self.empty_data, - is_header=False, - fill_char=self.apply_border_color(fill_char), - pre_line=self.apply_border_color(pre_line), - inter_cell=self.apply_border_color(inter_cell), - post_line=self.apply_border_color(post_line), - ) - - def generate_table_bottom_border(self) -> str: - """Generate a border which appears at the bottom of the table.""" - fill_char = '═' - - pre_line = '╚' + self.padding * '═' - - inter_cell = self.padding * '═' - if self.column_borders: - inter_cell += '╧' - inter_cell += self.padding * '═' - - post_line = self.padding * '═' + '╝' - - return self.generate_row( - self.empty_data, - is_header=False, - fill_char=self.apply_border_color(fill_char), - pre_line=self.apply_border_color(pre_line), - inter_cell=self.apply_border_color(inter_cell), - post_line=self.apply_border_color(post_line), - ) - - def generate_header(self) -> str: - """Generate table header.""" - fill_char = self.apply_header_bg(SPACE) - - pre_line = self.apply_border_color('║') + self.apply_header_bg(self.padding * SPACE) - - inter_cell = self.apply_header_bg(self.padding * SPACE) - if self.column_borders: - inter_cell += self.apply_border_color('│') - inter_cell += self.apply_header_bg(self.padding * SPACE) - - post_line = self.apply_header_bg(self.padding * SPACE) + self.apply_border_color('║') - - # Apply background color to header text in Columns which allow it - to_display: list[Any] = [] - for col in self.cols: - if col.style_header_text: - to_display.append(self.apply_header_bg(col.header)) - else: - to_display.append(col.header) - - # Create the bordered header - header_buf = io.StringIO() - header_buf.write(self.generate_table_top_border()) - header_buf.write('\n') - header_buf.write( - self.generate_row( - to_display, is_header=True, fill_char=fill_char, pre_line=pre_line, inter_cell=inter_cell, post_line=post_line - ) - ) - header_buf.write('\n') - header_buf.write(self.generate_header_bottom_border()) - - return header_buf.getvalue() - - def generate_data_row(self, row_data: Sequence[Any]) -> str: - """Generate a data row. - - :param row_data: data with an entry for each column in the row - :return: data row string - :raises ValueError: if row_data isn't the same length as self.cols - """ - if len(row_data) != len(self.cols): - raise ValueError("Length of row_data must match length of cols") - - fill_char = self.apply_data_bg(SPACE) - - pre_line = self.apply_border_color('║') + self.apply_data_bg(self.padding * SPACE) - - inter_cell = self.apply_data_bg(self.padding * SPACE) - if self.column_borders: - inter_cell += self.apply_border_color('│') - inter_cell += self.apply_data_bg(self.padding * SPACE) - - post_line = self.apply_data_bg(self.padding * SPACE) + self.apply_border_color('║') - - # Apply background color to data text in Columns which allow it - to_display: list[Any] = [] - for index, col in enumerate(self.cols): - if col.style_data_text: - to_display.append(self.apply_data_bg(row_data[index])) - else: - to_display.append(row_data[index]) - - return self.generate_row( - to_display, is_header=False, fill_char=fill_char, pre_line=pre_line, inter_cell=inter_cell, post_line=post_line - ) - - def generate_table(self, table_data: Sequence[Sequence[Any]], *, include_header: bool = True) -> str: - """Generate a table from a data set. - - :param table_data: Data with an entry for each data row of the table. Each entry should have data for - each column in the row. - :param include_header: If True, then a header will be included at top of table. (Defaults to True) - """ - table_buf = io.StringIO() - - if include_header: - header = self.generate_header() - table_buf.write(header) - else: - top_border = self.generate_table_top_border() - table_buf.write(top_border) - - table_buf.write('\n') - - for index, row_data in enumerate(table_data): - if index > 0: - row_bottom_border = self.generate_row_bottom_border() - table_buf.write(row_bottom_border) - table_buf.write('\n') - - row = self.generate_data_row(row_data) - table_buf.write(row) - table_buf.write('\n') - - table_buf.write(self.generate_table_bottom_border()) - return table_buf.getvalue() - - -class AlternatingTable(BorderedTable): - """Implementation of BorderedTable which uses background colors to distinguish between rows instead of row border lines. - - This class can be used to create the whole table at once or one row at a time. - - To nest an AlternatingTable within another AlternatingTable, set style_data_text to False on the Column - which contains the nested table. That will prevent the current row's background color from affecting the colors - of the nested table. - """ - - def __init__( - self, - cols: Sequence[Column], - *, - tab_width: int = 4, - column_borders: bool = True, - padding: int = 1, - border_fg: ansi.FgColor | None = None, - border_bg: ansi.BgColor | None = None, - header_bg: ansi.BgColor | None = None, - odd_bg: ansi.BgColor | None = None, - even_bg: ansi.BgColor | None = ansi.Bg.DARK_GRAY, - ) -> None: - """AlternatingTable initializer. - - Note: Specify background colors using subclasses of BgColor (e.g. Bg, EightBitBg, RgbBg) - - :param cols: column definitions for this table - :param tab_width: all tabs will be replaced with this many spaces. If a row's fill_char is a tab, - then it will be converted to one space. - :param column_borders: if True, borders between columns will be included. This gives the table a grid-like - appearance. Turning off column borders results in a unified appearance between - a row's cells. (Defaults to True) - :param padding: number of spaces between text and left/right borders of cell - :param border_fg: optional foreground color for borders (defaults to None) - :param border_bg: optional background color for borders (defaults to None) - :param header_bg: optional background color for header cells (defaults to None) - :param odd_bg: optional background color for odd numbered data rows (defaults to None) - :param even_bg: optional background color for even numbered data rows (defaults to StdBg.DARK_GRAY) - :raises ValueError: if tab_width is less than 1 - :raises ValueError: if padding is less than 0 - """ - super().__init__( - cols, - tab_width=tab_width, - column_borders=column_borders, - padding=padding, - border_fg=border_fg, - border_bg=border_bg, - header_bg=header_bg, - ) - self.row_num = 1 - self.odd_bg = odd_bg - self.even_bg = even_bg - - def apply_data_bg(self, value: Any) -> str: - """Apply background color to data text based on what row is being generated and whether a color has been defined. - - :param value: object whose text is to be colored - :return: formatted data string. - """ - if self.row_num % 2 == 0 and self.even_bg is not None: - return ansi.style(value, bg=self.even_bg) - if self.row_num % 2 != 0 and self.odd_bg is not None: - return ansi.style(value, bg=self.odd_bg) - return str(value) - - def generate_data_row(self, row_data: Sequence[Any]) -> str: - """Generate a data row. - - :param row_data: data with an entry for each column in the row - :return: data row string - """ - row = super().generate_data_row(row_data) - self.row_num += 1 - return row - - def generate_table(self, table_data: Sequence[Sequence[Any]], *, include_header: bool = True) -> str: - """Generate a table from a data set. - - :param table_data: Data with an entry for each data row of the table. Each entry should have data for - each column in the row. - :param include_header: If True, then a header will be included at top of table. (Defaults to True) - """ - table_buf = io.StringIO() - - if include_header: - header = self.generate_header() - table_buf.write(header) - else: - top_border = self.generate_table_top_border() - table_buf.write(top_border) - - table_buf.write('\n') - - for row_data in table_data: - row = self.generate_data_row(row_data) - table_buf.write(row) - table_buf.write('\n') - - table_buf.write(self.generate_table_bottom_border()) - return table_buf.getvalue() diff --git a/cmd2/terminal_utils.py b/cmd2/terminal_utils.py new file mode 100644 index 000000000..1245803f0 --- /dev/null +++ b/cmd2/terminal_utils.py @@ -0,0 +1,144 @@ +r"""Support for terminal control escape sequences. + +These are used for things like setting the window title and asynchronous alerts. +""" + +from . import string_utils as su + +####################################################### +# Common ANSI escape sequence constants +####################################################### +ESC = '\x1b' +CSI = f'{ESC}[' +OSC = f'{ESC}]' +BEL = '\a' + + +#################################################################################### +# Utility functions which create various ANSI sequences +#################################################################################### +def set_title_str(title: str) -> str: + """Generate a string that, when printed, sets a terminal's window title. + + :param title: new title for the window + :return: the set title string + """ + return f"{OSC}2;{title}{BEL}" + + +def clear_screen_str(clear_type: int = 2) -> str: + """Generate a string that, when printed, clears a terminal screen based on value of clear_type. + + :param clear_type: integer which specifies how to clear the screen (Defaults to 2) + Possible values: + 0 - clear from cursor to end of screen + 1 - clear from cursor to beginning of the screen + 2 - clear entire screen + 3 - clear entire screen and delete all lines saved in the scrollback buffer + :return: the clear screen string + :raises ValueError: if clear_type is not a valid value + """ + if 0 <= clear_type <= 3: + return f"{CSI}{clear_type}J" + raise ValueError("clear_type must in an integer from 0 to 3") + + +def clear_line_str(clear_type: int = 2) -> str: + """Generate a string that, when printed, clears a line based on value of clear_type. + + :param clear_type: integer which specifies how to clear the line (Defaults to 2) + Possible values: + 0 - clear from cursor to the end of the line + 1 - clear from cursor to beginning of the line + 2 - clear entire line + :return: the clear line string + :raises ValueError: if clear_type is not a valid value + """ + if 0 <= clear_type <= 2: + return f"{CSI}{clear_type}K" + raise ValueError("clear_type must in an integer from 0 to 2") + + +#################################################################################### +# Implementations intended for direct use (do NOT use outside of cmd2) +#################################################################################### +class Cursor: + """Create ANSI sequences to alter the cursor position.""" + + @staticmethod + def UP(count: int = 1) -> str: # noqa: N802 + """Move the cursor up a specified amount of lines (Defaults to 1).""" + return f"{CSI}{count}A" + + @staticmethod + def DOWN(count: int = 1) -> str: # noqa: N802 + """Move the cursor down a specified amount of lines (Defaults to 1).""" + return f"{CSI}{count}B" + + @staticmethod + def FORWARD(count: int = 1) -> str: # noqa: N802 + """Move the cursor forward a specified amount of lines (Defaults to 1).""" + return f"{CSI}{count}C" + + @staticmethod + def BACK(count: int = 1) -> str: # noqa: N802 + """Move the cursor back a specified amount of lines (Defaults to 1).""" + return f"{CSI}{count}D" + + @staticmethod + def SET_POS(x: int, y: int) -> str: # noqa: N802 + """Set the cursor position to coordinates which are 1-based.""" + return f"{CSI}{y};{x}H" + + +def async_alert_str(*, terminal_columns: int, prompt: str, line: str, cursor_offset: int, alert_msg: str) -> str: + """Calculate the desired string, including ANSI escape codes, for displaying an asynchronous alert message. + + :param terminal_columns: terminal width (number of columns) + :param prompt: current onscreen prompt + :param line: current contents of the Readline line buffer + :param cursor_offset: the offset of the current cursor position within line + :param alert_msg: the message to display to the user + :return: the correct string so that the alert message appears to the user to be printed above the current line. + """ + # Split the prompt lines since it can contain newline characters. + prompt_lines = prompt.splitlines() or [''] + + # Calculate how many terminal lines are taken up by all prompt lines except for the last one. + # That will be included in the input lines calculations since that is where the cursor is. + num_prompt_terminal_lines = 0 + for prompt_line in prompt_lines[:-1]: + prompt_line_width = su.str_width(prompt_line) + num_prompt_terminal_lines += int(prompt_line_width / terminal_columns) + 1 + + # Now calculate how many terminal lines are take up by the input + last_prompt_line = prompt_lines[-1] + last_prompt_line_width = su.str_width(last_prompt_line) + + input_width = last_prompt_line_width + su.str_width(line) + + num_input_terminal_lines = int(input_width / terminal_columns) + 1 + + # Get the cursor's offset from the beginning of the first input line + cursor_input_offset = last_prompt_line_width + cursor_offset + + # Calculate what input line the cursor is on + cursor_input_line = int(cursor_input_offset / terminal_columns) + 1 + + # Create a string that when printed will clear all input lines and display the alert + terminal_str = '' + + # Move the cursor down to the last input line + if cursor_input_line != num_input_terminal_lines: + terminal_str += Cursor.DOWN(num_input_terminal_lines - cursor_input_line) + + # Clear each line from the bottom up so that the cursor ends up on the first prompt line + total_lines = num_prompt_terminal_lines + num_input_terminal_lines + terminal_str += (clear_line_str() + Cursor.UP(1)) * (total_lines - 1) + + # Clear the first prompt line + terminal_str += clear_line_str() + + # Move the cursor to the beginning of the first prompt line and print the alert + terminal_str += '\r' + alert_msg + return terminal_str diff --git a/cmd2/transcript.py b/cmd2/transcript.py index 05c5db6c3..50a6fd61a 100644 --- a/cmd2/transcript.py +++ b/cmd2/transcript.py @@ -18,10 +18,8 @@ class is used in cmd2.py::run_transcript_tests() cast, ) -from . import ( - ansi, - utils, -) +from . import string_utils as su +from . import utils if TYPE_CHECKING: # pragma: no cover from cmd2 import ( @@ -76,13 +74,13 @@ def _test_transcript(self, fname: str, transcript: Iterator[str]) -> None: line_num = 0 finished = False - line = ansi.strip_style(next(transcript)) + line = su.strip_style(next(transcript)) line_num += 1 while not finished: # Scroll forward to where actual commands begin while not line.startswith(self.cmdapp.visible_prompt): try: - line = ansi.strip_style(next(transcript)) + line = su.strip_style(next(transcript)) except StopIteration: finished = True break @@ -108,14 +106,14 @@ def _test_transcript(self, fname: str, transcript: Iterator[str]) -> None: result = self.cmdapp.stdout.read() stop_msg = 'Command indicated application should quit, but more commands in transcript' # Read the expected result from transcript - if ansi.strip_style(line).startswith(self.cmdapp.visible_prompt): + if su.strip_style(line).startswith(self.cmdapp.visible_prompt): message = f'\nFile {fname}, line {line_num}\nCommand was:\n{command}\nExpected: (nothing)\nGot:\n{result}\n' assert not result.strip(), message # noqa: S101 # If the command signaled the application to quit there should be no more commands assert not stop, stop_msg # noqa: S101 continue expected_parts = [] - while not ansi.strip_style(line).startswith(self.cmdapp.visible_prompt): + while not su.strip_style(line).startswith(self.cmdapp.visible_prompt): expected_parts.append(line) try: line = next(transcript) diff --git a/cmd2/utils.py b/cmd2/utils.py index c4b37ab3a..bf4d14864 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -12,14 +12,27 @@ import subprocess import sys import threading -import unicodedata -from collections.abc import Callable, Iterable +from collections.abc import ( + Callable, + Iterable, +) from difflib import SequenceMatcher from enum import Enum -from typing import TYPE_CHECKING, Any, TextIO, TypeVar, Union, cast +from typing import ( + TYPE_CHECKING, + Any, + TextIO, + TypeVar, + Union, + cast, +) from . import constants -from .argparse_custom import ChoicesProviderFunc, CompleterFunc +from . import string_utils as su +from .argparse_custom import ( + ChoicesProviderFunc, + CompleterFunc, +) if TYPE_CHECKING: # pragma: no cover import cmd2 # noqa: F401 @@ -31,43 +44,6 @@ _T = TypeVar('_T') -def is_quoted(arg: str) -> bool: - """Check if a string is quoted. - - :param arg: the string being checked for quotes - :return: True if a string is quoted - """ - return len(arg) > 1 and arg[0] == arg[-1] and arg[0] in constants.QUOTES - - -def quote_string(arg: str) -> str: - """Quote a string.""" - quote = "'" if '"' in arg else '"' - - return quote + arg + quote - - -def quote_string_if_needed(arg: str) -> str: - """Quote a string if it contains spaces and isn't already quoted.""" - if is_quoted(arg) or ' ' not in arg: - return arg - - return quote_string(arg) - - -def strip_quotes(arg: str) -> str: - """Strip outer quotes from a string. - - Applies to both single and double quotes. - - :param arg: string to strip outer quotes from - :return: same string with potentially outer quotes stripped - """ - if is_quoted(arg): - arg = arg[1:-1] - return arg - - def to_bool(val: Any) -> bool: """Convert anything to a boolean based on its value. @@ -214,15 +190,6 @@ def remove_duplicates(list_to_prune: list[_T]) -> list[_T]: return list(temp_dict.keys()) -def norm_fold(astr: str) -> str: - """Normalize and casefold Unicode strings for saner comparisons. - - :param astr: input unicode string - :return: a normalized and case-folded version of the input string - """ - return unicodedata.normalize('NFC', astr).casefold() - - def alphabetical_sort(list_to_sort: Iterable[str]) -> list[str]: """Sorts a list of strings alphabetically. @@ -235,7 +202,7 @@ def alphabetical_sort(list_to_sort: Iterable[str]) -> list[str]: :param list_to_sort: the list being sorted :return: the sorted list """ - return sorted(list_to_sort, key=norm_fold) + return sorted(list_to_sort, key=su.norm_fold) def try_int_or_force_to_lower_case(input_str: str) -> int | str: @@ -247,7 +214,7 @@ def try_int_or_force_to_lower_case(input_str: str) -> int | str: try: return int(input_str) except ValueError: - return norm_fold(input_str) + return su.norm_fold(input_str) def natural_keys(input_str: str) -> list[int | str]: @@ -283,7 +250,7 @@ def quote_specific_tokens(tokens: list[str], tokens_to_quote: list[str]) -> None """ for i, token in enumerate(tokens): if token in tokens_to_quote: - tokens[i] = quote_string(token) + tokens[i] = su.quote(token) def unquote_specific_tokens(tokens: list[str], tokens_to_unquote: list[str]) -> None: @@ -293,7 +260,7 @@ def unquote_specific_tokens(tokens: list[str], tokens_to_unquote: list[str]) -> :param tokens_to_unquote: the tokens, which if present in tokens, to unquote """ for i, token in enumerate(tokens): - unquoted_token = strip_quotes(token) + unquoted_token = su.strip_quotes(token) if unquoted_token in tokens_to_unquote: tokens[i] = unquoted_token @@ -304,9 +271,9 @@ def expand_user(token: str) -> str: :param token: the string to expand """ if token: - if is_quoted(token): + if su.is_quoted(token): quote_char = token[0] - token = strip_quotes(token) + token = su.strip_quotes(token) else: quote_char = '' @@ -704,397 +671,6 @@ def __init__( self.saved_redirecting = saved_redirecting -def _remove_overridden_styles(styles_to_parse: list[str]) -> list[str]: - """Filter a style list down to only those which would still be in effect if all were processed in order. - - Utility function for align_text() / truncate_line(). - - This is mainly used to reduce how many style strings are stored in memory when - building large multiline strings with ANSI styles. We only need to carry over - styles from previous lines that are still in effect. - - :param styles_to_parse: list of styles to evaluate. - :return: list of styles that are still in effect. - """ - from . import ( - ansi, - ) - - class StyleState: - """Keeps track of what text styles are enabled.""" - - def __init__(self) -> None: - # Contains styles still in effect, keyed by their index in styles_to_parse - self.style_dict: dict[int, str] = {} - - # Indexes into style_dict - self.reset_all: int | None = None - self.fg: int | None = None - self.bg: int | None = None - self.intensity: int | None = None - self.italic: int | None = None - self.overline: int | None = None - self.strikethrough: int | None = None - self.underline: int | None = None - - # Read the previous styles in order and keep track of their states - style_state = StyleState() - - for index, style in enumerate(styles_to_parse): - # For styles types that we recognize, only keep their latest value from styles_to_parse. - # All unrecognized style types will be retained and their order preserved. - if style in (str(ansi.TextStyle.RESET_ALL), str(ansi.TextStyle.ALT_RESET_ALL)): - style_state = StyleState() - style_state.reset_all = index - elif ansi.STD_FG_RE.match(style) or ansi.EIGHT_BIT_FG_RE.match(style) or ansi.RGB_FG_RE.match(style): - if style_state.fg is not None: - style_state.style_dict.pop(style_state.fg) - style_state.fg = index - elif ansi.STD_BG_RE.match(style) or ansi.EIGHT_BIT_BG_RE.match(style) or ansi.RGB_BG_RE.match(style): - if style_state.bg is not None: - style_state.style_dict.pop(style_state.bg) - style_state.bg = index - elif style in ( - str(ansi.TextStyle.INTENSITY_BOLD), - str(ansi.TextStyle.INTENSITY_DIM), - str(ansi.TextStyle.INTENSITY_NORMAL), - ): - if style_state.intensity is not None: - style_state.style_dict.pop(style_state.intensity) - style_state.intensity = index - elif style in (str(ansi.TextStyle.ITALIC_ENABLE), str(ansi.TextStyle.ITALIC_DISABLE)): - if style_state.italic is not None: - style_state.style_dict.pop(style_state.italic) - style_state.italic = index - elif style in (str(ansi.TextStyle.OVERLINE_ENABLE), str(ansi.TextStyle.OVERLINE_DISABLE)): - if style_state.overline is not None: - style_state.style_dict.pop(style_state.overline) - style_state.overline = index - elif style in (str(ansi.TextStyle.STRIKETHROUGH_ENABLE), str(ansi.TextStyle.STRIKETHROUGH_DISABLE)): - if style_state.strikethrough is not None: - style_state.style_dict.pop(style_state.strikethrough) - style_state.strikethrough = index - elif style in (str(ansi.TextStyle.UNDERLINE_ENABLE), str(ansi.TextStyle.UNDERLINE_DISABLE)): - if style_state.underline is not None: - style_state.style_dict.pop(style_state.underline) - style_state.underline = index - - # Store this style and its location in the dictionary - style_state.style_dict[index] = style - - return list(style_state.style_dict.values()) - - -class TextAlignment(Enum): - """Horizontal text alignment.""" - - LEFT = 1 - CENTER = 2 - RIGHT = 3 - - -def align_text( - text: str, - alignment: TextAlignment, - *, - fill_char: str = ' ', - width: int | None = None, - tab_width: int = 4, - truncate: bool = False, -) -> str: - """Align text for display within a given width. Supports characters with display widths greater than 1. - - ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned - independently. - - There are convenience wrappers around this function: align_left(), align_center(), and align_right() - - :param text: text to align (can contain multiple lines) - :param alignment: how to align the text - :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character) - :param width: display width of the aligned text. Defaults to width of the terminal. - :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will - be converted to one space. - :param truncate: if True, then each line will be shortened to fit within the display width. The truncated - portions are replaced by a '…' character. Defaults to False. - :return: aligned text - :raises TypeError: if fill_char is more than one character (not including ANSI style sequences) - :raises ValueError: if text or fill_char contains an unprintable character - :raises ValueError: if width is less than 1 - """ - import io - import shutil - - from . import ( - ansi, - ) - - if width is None: - # Prior to Python 3.11 this can return 0, so use a fallback if needed. - width = shutil.get_terminal_size().columns or constants.DEFAULT_TERMINAL_WIDTH - - if width < 1: - raise ValueError("width must be at least 1") - - # Convert tabs to spaces - text = text.replace('\t', ' ' * tab_width) - fill_char = fill_char.replace('\t', ' ') - - # Save fill_char with no styles for use later - stripped_fill_char = ansi.strip_style(fill_char) - if len(stripped_fill_char) != 1: - raise TypeError("Fill character must be exactly one character long") - - fill_char_width = ansi.style_aware_wcswidth(fill_char) - if fill_char_width == -1: - raise (ValueError("Fill character is an unprintable character")) - - # Isolate the style chars before and after the fill character. We will use them when building sequences of - # fill characters. Instead of repeating the style characters for each fill character, we'll wrap each sequence. - fill_char_style_begin, fill_char_style_end = fill_char.split(stripped_fill_char) - - lines = text.splitlines() if text else [''] - - text_buf = io.StringIO() - - # ANSI style sequences that may affect subsequent lines will be cancelled by the fill_char's style. - # To avoid this, we save styles which are still in effect so we can restore them when beginning the next line. - # This also allows lines to be used independently and still have their style. TableCreator does this. - previous_styles: list[str] = [] - - for index, line in enumerate(lines): - if index > 0: - text_buf.write('\n') - - if truncate: - line = truncate_line(line, width) # noqa: PLW2901 - - line_width = ansi.style_aware_wcswidth(line) - if line_width == -1: - raise (ValueError("Text to align contains an unprintable character")) - - # Get list of styles in this line - line_styles = list(get_styles_dict(line).values()) - - # Calculate how wide each side of filling needs to be - total_fill_width = 0 if line_width >= width else width - line_width - # Even if the line needs no fill chars, there may be styles sequences to restore - - if alignment == TextAlignment.LEFT: - left_fill_width = 0 - right_fill_width = total_fill_width - elif alignment == TextAlignment.CENTER: - left_fill_width = total_fill_width // 2 - right_fill_width = total_fill_width - left_fill_width - else: - left_fill_width = total_fill_width - right_fill_width = 0 - - # Determine how many fill characters are needed to cover the width - left_fill = (left_fill_width // fill_char_width) * stripped_fill_char - right_fill = (right_fill_width // fill_char_width) * stripped_fill_char - - # In cases where the fill character display width didn't divide evenly into - # the gap being filled, pad the remainder with space. - left_fill += ' ' * (left_fill_width - ansi.style_aware_wcswidth(left_fill)) - right_fill += ' ' * (right_fill_width - ansi.style_aware_wcswidth(right_fill)) - - # Don't allow styles in fill characters and text to affect one another - if fill_char_style_begin or fill_char_style_end or previous_styles or line_styles: - if left_fill: - left_fill = ansi.TextStyle.RESET_ALL + fill_char_style_begin + left_fill + fill_char_style_end - left_fill += ansi.TextStyle.RESET_ALL - - if right_fill: - right_fill = ansi.TextStyle.RESET_ALL + fill_char_style_begin + right_fill + fill_char_style_end - right_fill += ansi.TextStyle.RESET_ALL - - # Write the line and restore styles from previous lines which are still in effect - text_buf.write(left_fill + ''.join(previous_styles) + line + right_fill) - - # Update list of styles that are still in effect for the next line - previous_styles.extend(line_styles) - previous_styles = _remove_overridden_styles(previous_styles) - - return text_buf.getvalue() - - -def align_left( - text: str, *, fill_char: str = ' ', width: int | None = None, tab_width: int = 4, truncate: bool = False -) -> str: - """Left align text for display within a given width. Supports characters with display widths greater than 1. - - ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned - independently. - - :param text: text to left align (can contain multiple lines) - :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character) - :param width: display width of the aligned text. Defaults to width of the terminal. - :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will - be converted to one space. - :param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is - replaced by a '…' character. Defaults to False. - :return: left-aligned text - :raises TypeError: if fill_char is more than one character (not including ANSI style sequences) - :raises ValueError: if text or fill_char contains an unprintable character - :raises ValueError: if width is less than 1 - """ - return align_text(text, TextAlignment.LEFT, fill_char=fill_char, width=width, tab_width=tab_width, truncate=truncate) - - -def align_center( - text: str, *, fill_char: str = ' ', width: int | None = None, tab_width: int = 4, truncate: bool = False -) -> str: - """Center text for display within a given width. Supports characters with display widths greater than 1. - - ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned - independently. - - :param text: text to center (can contain multiple lines) - :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character) - :param width: display width of the aligned text. Defaults to width of the terminal. - :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will - be converted to one space. - :param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is - replaced by a '…' character. Defaults to False. - :return: centered text - :raises TypeError: if fill_char is more than one character (not including ANSI style sequences) - :raises ValueError: if text or fill_char contains an unprintable character - :raises ValueError: if width is less than 1 - """ - return align_text(text, TextAlignment.CENTER, fill_char=fill_char, width=width, tab_width=tab_width, truncate=truncate) - - -def align_right( - text: str, *, fill_char: str = ' ', width: int | None = None, tab_width: int = 4, truncate: bool = False -) -> str: - """Right align text for display within a given width. Supports characters with display widths greater than 1. - - ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned - independently. - - :param text: text to right align (can contain multiple lines) - :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character) - :param width: display width of the aligned text. Defaults to width of the terminal. - :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will - be converted to one space. - :param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is - replaced by a '…' character. Defaults to False. - :return: right-aligned text - :raises TypeError: if fill_char is more than one character (not including ANSI style sequences) - :raises ValueError: if text or fill_char contains an unprintable character - :raises ValueError: if width is less than 1 - """ - return align_text(text, TextAlignment.RIGHT, fill_char=fill_char, width=width, tab_width=tab_width, truncate=truncate) - - -def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str: - """Truncate a single line to fit within a given display width. - - Any portion of the string that is truncated is replaced by a '…' character. Supports characters with display widths greater - than 1. ANSI style sequences do not count toward the display width. - - If there are ANSI style sequences in the string after where truncation occurs, this function will append them - to the returned string. - - This is done to prevent issues caused in cases like: truncate_line(Fg.BLUE + hello + Fg.RESET, 3) - In this case, "hello" would be truncated before Fg.RESET resets the color from blue. Appending the remaining style - sequences makes sure the style is in the same state had the entire string been printed. align_text() relies on this - behavior when preserving style over multiple lines. - - :param line: text to truncate - :param max_width: the maximum display width the resulting string is allowed to have - :param tab_width: any tabs in the text will be replaced with this many spaces - :return: line that has a display width less than or equal to width - :raises ValueError: if text contains an unprintable character like a newline - :raises ValueError: if max_width is less than 1 - """ - import io - - from . import ( - ansi, - ) - - # Handle tabs - line = line.replace('\t', ' ' * tab_width) - - if ansi.style_aware_wcswidth(line) == -1: - raise (ValueError("text contains an unprintable character")) - - if max_width < 1: - raise ValueError("max_width must be at least 1") - - if ansi.style_aware_wcswidth(line) <= max_width: - return line - - # Find all style sequences in the line - styles_dict = get_styles_dict(line) - - # Add characters one by one and preserve all style sequences - done = False - index = 0 - total_width = 0 - truncated_buf = io.StringIO() - - while not done: - # Check if a style sequence is at this index. These don't count toward display width. - if index in styles_dict: - truncated_buf.write(styles_dict[index]) - style_len = len(styles_dict[index]) - styles_dict.pop(index) - index += style_len - continue - - char = line[index] - char_width = ansi.style_aware_wcswidth(char) - - # This char will make the text too wide, add the ellipsis instead - if char_width + total_width >= max_width: - char = constants.HORIZONTAL_ELLIPSIS - char_width = ansi.style_aware_wcswidth(char) - done = True - - total_width += char_width - truncated_buf.write(char) - index += 1 - - # Filter out overridden styles from the remaining ones - remaining_styles = _remove_overridden_styles(list(styles_dict.values())) - - # Append the remaining styles to the truncated text - truncated_buf.write(''.join(remaining_styles)) - - return truncated_buf.getvalue() - - -def get_styles_dict(text: str) -> dict[int, str]: - """Return an OrderedDict containing all ANSI style sequences found in a string. - - The structure of the dictionary is: - key: index where sequences begins - value: ANSI style sequence found at index in text - - Keys are in ascending order - - :param text: text to search for style sequences - """ - from . import ( - ansi, - ) - - start = 0 - styles = collections.OrderedDict() - - while True: - match = ansi.ANSI_STYLE_RE.search(text, start) - if match is None: - break - styles[match.start()] = match.group() - start += len(match.group()) - - return styles - - def categorize(func: Callable[..., Any] | Iterable[Callable[..., Any]], category: str) -> None: """Categorize a function. diff --git a/docs/api/ansi.md b/docs/api/ansi.md deleted file mode 100644 index 754861d50..000000000 --- a/docs/api/ansi.md +++ /dev/null @@ -1,3 +0,0 @@ -# cmd2.ansi - -::: cmd2.ansi diff --git a/docs/api/clipboard.md b/docs/api/clipboard.md new file mode 100644 index 000000000..b3f9a2bf9 --- /dev/null +++ b/docs/api/clipboard.md @@ -0,0 +1,3 @@ +# cmd2.clipboard + +::: cmd2.clipboard diff --git a/docs/api/colors.md b/docs/api/colors.md new file mode 100644 index 000000000..cb37aece6 --- /dev/null +++ b/docs/api/colors.md @@ -0,0 +1,3 @@ +# cmd2.colors + +::: cmd2.colors diff --git a/docs/api/index.md b/docs/api/index.md index 291bcbccd..c52dca6f8 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -12,10 +12,10 @@ incremented according to the [Semantic Version Specification](https://semver.org ## Modules - [cmd2.Cmd](./cmd.md) - functions and attributes of the main class in this library -- [cmd2.ansi](./ansi.md) - convenience classes and functions for generating ANSI escape sequences to - style text in the terminal - [cmd2.argparse_completer](./argparse_completer.md) - classes for `argparse`-based tab completion - [cmd2.argparse_custom](./argparse_custom.md) - classes and functions for extending `argparse` +- [cmd2.clipboard](./clipboard.md) - functions to copy from and paste to the clipboard/pastebuffer +- [cmd2.colors](./colors.md) - StrEnum of all color names supported by the Rich library - [cmd2.command_definition](./command_definition.md) - supports the definition of commands in separate classes to be composed into cmd2.Cmd - [cmd2.constants](./constants.md) - just like it says on the tin @@ -26,5 +26,11 @@ incremented according to the [Semantic Version Specification](https://semver.org - [cmd2.plugin](./plugin.md) - data classes for hook methods - [cmd2.py_bridge](./py_bridge.md) - classes for bridging calls from the embedded python environment to the host app -- [cmd2.table_creator](./table_creator.md) - table creation module +- [cmd2.rich_utils](./rich_utils.md) - common utilities to support Rich in cmd2 applications +- [cmd2.rl_utils](./rl_utils.md) - imports the proper Readline for the platform and provides utility + functions for it +- [cmd2.string_utils](./string_utils.md) - string utility functions +- [cmd2.styles](./styles.md) - cmd2-specific Rich styles and a StrEnum of their corresponding names +- [cmd2.terminal_utils](./terminal_utils.md) - support for terminal control escape sequences +- [cmd2.transcript](./transcript.md) - functions and classes for running and validating transcripts - [cmd2.utils](./utils.md) - various utility classes and functions diff --git a/docs/api/rich_utils.md b/docs/api/rich_utils.md new file mode 100644 index 000000000..e339843d0 --- /dev/null +++ b/docs/api/rich_utils.md @@ -0,0 +1,3 @@ +# cmd2.rich_utils + +::: cmd2.rich_utils diff --git a/docs/api/rl_utils.md b/docs/api/rl_utils.md new file mode 100644 index 000000000..52beb31ba --- /dev/null +++ b/docs/api/rl_utils.md @@ -0,0 +1,3 @@ +# cmd2.rl_utils + +::: cmd2.rl_utils diff --git a/docs/api/string_utils.md b/docs/api/string_utils.md new file mode 100644 index 000000000..5717608b1 --- /dev/null +++ b/docs/api/string_utils.md @@ -0,0 +1,3 @@ +# cmd2.string_utils + +::: cmd2.string_utils diff --git a/docs/api/styles.md b/docs/api/styles.md new file mode 100644 index 000000000..4f10ccb12 --- /dev/null +++ b/docs/api/styles.md @@ -0,0 +1,3 @@ +# cmd2.styles + +::: cmd2.styles diff --git a/docs/api/table_creator.md b/docs/api/table_creator.md deleted file mode 100644 index 2d3887fcf..000000000 --- a/docs/api/table_creator.md +++ /dev/null @@ -1,3 +0,0 @@ -# cmd2.table_creator - -::: cmd2.table_creator diff --git a/docs/api/terminal_utils.md b/docs/api/terminal_utils.md new file mode 100644 index 000000000..919f36dd5 --- /dev/null +++ b/docs/api/terminal_utils.md @@ -0,0 +1,3 @@ +# cmd2.terminal_utils + +::: cmd2.terminal_utils diff --git a/docs/api/transcript.md b/docs/api/transcript.md new file mode 100644 index 000000000..bde72d371 --- /dev/null +++ b/docs/api/transcript.md @@ -0,0 +1,3 @@ +# cmd2.transcript + +::: cmd2.transcript diff --git a/docs/features/generating_output.md b/docs/features/generating_output.md index 4892dd7e6..9fc1f7f10 100644 --- a/docs/features/generating_output.md +++ b/docs/features/generating_output.md @@ -43,12 +43,11 @@ def do_echo(self, args): ## Error Messages When an error occurs in your program, you can display it on `sys.stderr` by calling the -`.cmd2.Cmd.perror` method. By default this method applies `cmd2.ansi.style_error` to the output. +`.cmd2.Cmd.perror` method. By default this method applies `Cmd2Style.ERROR` to the output. ## Warning Messages -`cmd2.Cmd.pwarning` is just like `cmd2.Cmd.perror` but applies `cmd2.ansi.style_warning` to the -output. +`cmd2.Cmd.pwarning` is just like `cmd2.Cmd.perror` but applies `Cmd2Style.WARNING` to the output. ## Feedback @@ -85,7 +84,13 @@ You can add your own [ANSI escape sequences](https://en.wikipedia.org/wiki/ANSI_ to your output which tell the terminal to change the foreground and background colors. `cmd2` provides a number of convenience functions and classes for adding color and other styles to -text. These are all documented in [cmd2.ansi][]. +text. These are all based on [rich](https://github.com/Textualize/rich) and are documented in the +following sectins: + +- [cmd2.colors][] +- [cmd2.rich_utils][] +- [cmd2.string_utils][] +- [cmd2.terminal_utils][] After adding the desired escape sequences to your output, you should use one of these methods to present the output to the user: diff --git a/docs/features/table_creation.md b/docs/features/table_creation.md index a41300a5a..cd0a7278d 100644 --- a/docs/features/table_creation.md +++ b/docs/features/table_creation.md @@ -1,33 +1,9 @@ # Table Creation -`cmd2` provides a table creation class called `cmd2.table_creator.TableCreator`. This class handles -ANSI style sequences and characters with display widths greater than 1 when performing width -calculations. It was designed with the ability to build tables one row at a time. This helps when -you have large data sets that you don't want to hold in memory or when you receive portions of the -data set incrementally. +As of version 3, `cmd2` no longer includes code for table creation. -`TableCreator` has one public method: `cmd2.table_creator.TableCreator.generate_row()`. +This is because `cmd2` now has a dependency on [rich](https://github.com/Textualize/rich) which has +excellent support for this feature. -This function and the `cmd2.table_creator.Column` class provide all features needed to build tables -with headers, borders, colors, horizontal and vertical alignment, and wrapped text. However, it's -generally easier to inherit from this class and implement a more granular API rather than use -`TableCreator` directly. - -The following table classes build upon `TableCreator` and are provided in the -[cmd2.table_creater](../api/table_creator.md) module. They can be used as is or as examples for how -to build your own table classes. - -`cmd2.table_creator.SimpleTable` - Implementation of TableCreator which generates a borderless table -with an optional divider row after the header. This class can be used to create the whole table at -once or one row at a time. - -`cmd2.table_creator.BorderedTable` - Implementation of TableCreator which generates a table with -borders around the table and between rows. Borders between columns can also be toggled. This class -can be used to create the whole table at once or one row at a time. - -`cmd2.table_creator.AlternatingTable` - Implementation of BorderedTable which uses background colors -to distinguish between rows instead of row border lines. This class can be used to create the whole -table at once or one row at a time. - -See the [table_creation](https://github.com/python-cmd2/cmd2/blob/main/examples/table_creation.py) -example to see these classes in use +Please see rich's docummentation on [Tables](https://rich.readthedocs.io/en/latest/tables.html) for +more information. diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index f680db57c..000000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -mkdocs-include-markdown-plugin -mkdocs-macros-plugin -mkdocs-material -pyperclip -setuptools -setuptools-scm -wcwidth diff --git a/examples/async_printing.py b/examples/async_printing.py index 5655a62ff..f1eac85d4 100755 --- a/examples/async_printing.py +++ b/examples/async_printing.py @@ -9,8 +9,8 @@ import cmd2 from cmd2 import ( - Fg, - style, + Color, + stylize, ) ALERTS = [ @@ -139,20 +139,20 @@ def _generate_colored_prompt(self) -> str: """ rand_num = random.randint(1, 20) - status_color = Fg.RESET + status_color = Color.DEFAULT if rand_num == 1: - status_color = Fg.LIGHT_RED + status_color = Color.BRIGHT_RED elif rand_num == 2: - status_color = Fg.LIGHT_YELLOW + status_color = Color.BRIGHT_YELLOW elif rand_num == 3: - status_color = Fg.CYAN + status_color = Color.CYAN elif rand_num == 4: - status_color = Fg.LIGHT_GREEN + status_color = Color.BRIGHT_GREEN elif rand_num == 5: - status_color = Fg.LIGHT_BLUE + status_color = Color.BRIGHT_BLUE - return style(self.visible_prompt, fg=status_color) + return stylize(self.visible_prompt, style=status_color) def _alerter_thread_func(self) -> None: """Prints alerts and updates the prompt any time the prompt is showing.""" diff --git a/examples/custom_parser.py b/examples/custom_parser.py index d4c331168..70a279e8a 100644 --- a/examples/custom_parser.py +++ b/examples/custom_parser.py @@ -8,9 +8,10 @@ from cmd2 import ( Cmd2ArgumentParser, - ansi, cmd2, set_default_argument_parser_type, + styles, + stylize, ) @@ -34,8 +35,11 @@ def error(self, message: str) -> NoReturn: self.print_usage(sys.stderr) - # Format errors with style_warning() - formatted_message = ansi.style_warning(formatted_message) + # Format errors with warning style + formatted_message = stylize( + formatted_message, + style=styles.WARNING, + ) self.exit(2, f'{formatted_message}\n\n') diff --git a/examples/table_creation.py b/examples/table_creation.py deleted file mode 100755 index 754fe9721..000000000 --- a/examples/table_creation.py +++ /dev/null @@ -1,275 +0,0 @@ -#!/usr/bin/env python -"""Examples of using the cmd2 table creation API.""" - -import functools -import sys -from typing import Any - -from cmd2 import ( - EightBitBg, - EightBitFg, - Fg, - ansi, - rich_utils, -) -from cmd2.table_creator import ( - AlternatingTable, - BorderedTable, - Column, - HorizontalAlignment, - SimpleTable, -) - -# Text styles used in the tables -bold_yellow = functools.partial(ansi.style, fg=Fg.LIGHT_YELLOW, bold=True) -blue = functools.partial(ansi.style, fg=Fg.LIGHT_BLUE) -green = functools.partial(ansi.style, fg=Fg.GREEN) - - -class DollarFormatter: - """Example class to show that any object type can be passed as data to TableCreator and converted to a string.""" - - def __init__(self, val: float) -> None: - self.val = val - - def __str__(self) -> str: - """Returns the value in dollar currency form (e.g. $100.22).""" - return f"${self.val:,.2f}" - - -class Relative: - """Class used for example data.""" - - def __init__(self, name: str, relationship: str) -> None: - self.name = name - self.relationship = relationship - - -class Book: - """Class used for example data.""" - - def __init__(self, title: str, year_published: str) -> None: - self.title = title - self.year_published = year_published - - -class Author: - """Class used for example data.""" - - def __init__(self, name: str, birthday: str, place_of_birth: str) -> None: - self.name = name - self.birthday = birthday - self.place_of_birth = place_of_birth - self.books: list[Book] = [] - self.relatives: list[Relative] = [] - - -def ansi_print(text) -> None: - """Wraps style_aware_write so style can be stripped if needed.""" - ansi.style_aware_write(sys.stdout, text + '\n\n') - - -def basic_tables() -> None: - """Demonstrates basic examples of the table classes.""" - # Table data which demonstrates handling of wrapping and text styles - data_list: list[list[Any]] = [] - data_list.append(["Billy Smith", "123 Sesame St.\nFake Town, USA 33445", DollarFormatter(100333.03)]) - data_list.append( - [ - "William Longfellow Marmaduke III", - "984 Really Long Street Name Which Will Wrap Nicely\nApt 22G\nPensacola, FL 32501", - DollarFormatter(55135.22), - ] - ) - data_list.append( - [ - "James " + blue("Bluestone"), - bold_yellow("This address has line feeds,\ntext styles, and wrapping. ") - + blue("Style is preserved across lines."), - DollarFormatter(300876.10), - ] - ) - data_list.append(["John Jones", "9235 Highway 32\n" + green("Greenville") + ", SC 29604", DollarFormatter(82987.71)]) - - # Table Columns (width does not account for any borders or padding which may be added) - columns: list[Column] = [] - columns.append(Column("Name", width=20)) - columns.append(Column("Address", width=38)) - columns.append( - Column("Income", width=14, header_horiz_align=HorizontalAlignment.RIGHT, data_horiz_align=HorizontalAlignment.RIGHT) - ) - - st = SimpleTable(columns) - table = st.generate_table(data_list) - ansi_print(table) - - bt = BorderedTable(columns) - table = bt.generate_table(data_list) - ansi_print(table) - - at = AlternatingTable(columns) - table = at.generate_table(data_list) - ansi_print(table) - - -def nested_tables() -> None: - """Demonstrates how to nest tables with styles which conflict with the parent table by setting style_data_text to False. - It also demonstrates coloring various aspects of tables. - """ - # Create data for this example - author_data: list[Author] = [] - author_1 = Author("Frank Herbert", "10/08/1920", "Tacoma, Washington") - author_1.books.append(Book("Dune", "1965")) - author_1.books.append(Book("Dune Messiah", "1969")) - author_1.books.append(Book("Children of Dune", "1976")) - author_1.books.append(Book("God Emperor of Dune", "1981")) - author_1.books.append(Book("Heretics of Dune", "1984")) - author_1.books.append(Book("Chapterhouse: Dune", "1985")) - author_1.relatives.append(Relative("Flora Lillian Parkinson", "First Wife")) - author_1.relatives.append(Relative("Beverly Ann Stuart", "Second Wife")) - author_1.relatives.append(Relative("Theresa Diane Shackelford", "Third Wife")) - author_1.relatives.append(Relative("Penelope Herbert", "Daughter")) - author_1.relatives.append(Relative("Brian Patrick Herbert", "Son")) - author_1.relatives.append(Relative("Bruce Calvin Herbert", "Son")) - - author_2 = Author("Jane Austen", "12/16/1775", "Steventon, Hampshire, England") - author_2.books.append(Book("Sense and Sensibility", "1811")) - author_2.books.append(Book("Pride and Prejudice", "1813")) - author_2.books.append(Book("Mansfield Park ", "1814")) - author_2.books.append(Book("Emma", "1815")) - author_2.books.append(Book("Northanger Abbey", "1818")) - author_2.books.append(Book("Persuasion", "1818")) - author_2.books.append(Book("Lady Susan", "1871")) - author_2.relatives.append(Relative("James Austen", "Brother")) - author_2.relatives.append(Relative("George Austen", "Brother")) - author_2.relatives.append(Relative("Edward Austen", "Brother")) - author_2.relatives.append(Relative("Henry Thomas Austen", "Brother")) - author_2.relatives.append(Relative("Cassandra Elizabeth Austen", "Sister")) - author_2.relatives.append(Relative("Francis William Austen", "Brother")) - author_2.relatives.append(Relative("Charles John Austen", "Brother")) - - author_data.append(author_1) - author_data.append(author_2) - - # Define table which presents Author data fields vertically with no header. - # This will be nested in the parent table's first column. - author_columns: list[Column] = [] - author_columns.append(Column("", width=14)) - author_columns.append(Column("", width=20)) - - # The text labels in this table will be bold text. They will also be aligned by the table code. - # When styled text is aligned, a TextStyle.RESET_ALL sequence is inserted between the aligned text - # and the fill characters. Therefore, the Author table will contain TextStyle.RESET_ALL sequences, - # which would interfere with the background color applied by the parent table. To account for this, - # we will manually color the Author tables to match the background colors of the parent AlternatingTable's - # rows and set style_data_text to False in the Author column. - odd_author_tbl = SimpleTable(author_columns, data_bg=EightBitBg.GRAY_0) - even_author_tbl = SimpleTable(author_columns, data_bg=EightBitBg.GRAY_15) - - # Define AlternatingTable for books checked out by people in the first table. - # This will be nested in the parent table's second column. - books_columns: list[Column] = [] - books_columns.append(Column(ansi.style("Title", bold=True), width=25)) - books_columns.append( - Column( - ansi.style("Published", bold=True), - width=9, - header_horiz_align=HorizontalAlignment.RIGHT, - data_horiz_align=HorizontalAlignment.RIGHT, - ) - ) - - books_tbl = AlternatingTable( - books_columns, - column_borders=False, - border_fg=EightBitFg.GRAY_15, - header_bg=EightBitBg.GRAY_0, - odd_bg=EightBitBg.GRAY_0, - even_bg=EightBitBg.GRAY_15, - ) - - # Define BorderedTable for relatives of the author - # This will be nested in the parent table's third column. - relative_columns: list[Column] = [] - relative_columns.append(Column(ansi.style("Name", bold=True), width=25)) - relative_columns.append(Column(ansi.style("Relationship", bold=True), width=12)) - - # Since the header labels are bold, we have the same issue as the Author table. Therefore, we will manually - # color Relatives tables to match the background colors of the parent AlternatingTable's rows and set style_data_text - # to False in the Relatives column. - odd_relatives_tbl = BorderedTable( - relative_columns, - border_fg=EightBitFg.GRAY_15, - border_bg=EightBitBg.GRAY_0, - header_bg=EightBitBg.GRAY_0, - data_bg=EightBitBg.GRAY_0, - ) - - even_relatives_tbl = BorderedTable( - relative_columns, - border_fg=EightBitFg.GRAY_0, - border_bg=EightBitBg.GRAY_15, - header_bg=EightBitBg.GRAY_15, - data_bg=EightBitBg.GRAY_15, - ) - - # Define parent AlternatingTable which contains Author and Book tables - parent_tbl_columns: list[Column] = [] - - # All of the nested tables already have background colors. Set style_data_text - # to False so the parent AlternatingTable does not apply background color to them. - parent_tbl_columns.append( - Column(ansi.style("Author", bold=True), width=odd_author_tbl.total_width(), style_data_text=False) - ) - parent_tbl_columns.append(Column(ansi.style("Books", bold=True), width=books_tbl.total_width(), style_data_text=False)) - parent_tbl_columns.append( - Column(ansi.style("Relatives", bold=True), width=odd_relatives_tbl.total_width(), style_data_text=False) - ) - - parent_tbl = AlternatingTable( - parent_tbl_columns, - column_borders=False, - border_fg=EightBitFg.GRAY_93, - header_bg=EightBitBg.GRAY_0, - odd_bg=EightBitBg.GRAY_0, - even_bg=EightBitBg.GRAY_15, - ) - - # Construct the tables - parent_table_data: list[list[Any]] = [] - for row, author in enumerate(author_data, start=1): - # First build the author table and color it based on row number - author_tbl = even_author_tbl if row % 2 == 0 else odd_author_tbl - - # This table has three rows and two columns - table_data = [ - [ansi.style("Name", bold=True), author.name], - [ansi.style("Birthday", bold=True), author.birthday], - [ansi.style("Place of Birth", bold=True), author.place_of_birth], - ] - - # Build the author table string - author_tbl_str = author_tbl.generate_table(table_data, include_header=False, row_spacing=0) - - # Now build this author's book table - table_data = [[book.title, book.year_published] for book in author.books] - book_tbl_str = books_tbl.generate_table(table_data) - - # Lastly build the relatives table and color it based on row number - relatives_tbl = even_relatives_tbl if row % 2 == 0 else odd_relatives_tbl - table_data = [[relative.name, relative.relationship] for relative in author.relatives] - relatives_tbl_str = relatives_tbl.generate_table(table_data) - - # Add these tables to the parent table's data - parent_table_data.append(['\n' + author_tbl_str, '\n' + book_tbl_str + '\n\n', '\n' + relatives_tbl_str + '\n\n']) - - # Build the parent table - top_table_str = parent_tbl.generate_table(parent_table_data) - ansi_print(top_table_str) - - -if __name__ == '__main__': - # Default to terminal mode so redirecting to a file won't include the ANSI style sequences - rich_utils.allow_style = rich_utils.AllowStyle.TERMINAL - basic_tables() - nested_tables() diff --git a/mkdocs.yml b/mkdocs.yml index 77a3d3d79..be5275a2b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -200,9 +200,10 @@ nav: - API Reference: - api/index.md - api/cmd.md - - api/ansi.md - api/argparse_completer.md - api/argparse_custom.md + - api/clipboard.md + - api/colors.md - api/command_definition.md - api/constants.md - api/decorators.md @@ -211,7 +212,12 @@ nav: - api/parsing.md - api/plugin.md - api/py_bridge.md - - api/table_creator.md + - api/rich_utils.md + - api/rl_utils.md + - api/string_utils.md + - api/styles.md + - api/terminal_utils.md + - api/transcript.md - api/utils.md - Meta: - doc_conventions.md diff --git a/pyproject.toml b/pyproject.toml index 8a99b54d4..1ba77140e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,8 +32,8 @@ dependencies = [ "gnureadline>=8; platform_system == 'Darwin'", "pyperclip>=1.8", "pyreadline3>=3.4; platform_system == 'Windows'", + "rich>=14.1.0", "rich-argparse>=1.7.1", - "wcwidth>=0.2.10", ] [dependency-groups] diff --git a/tests/test_ansi.py b/tests/test_ansi.py deleted file mode 100644 index 841190724..000000000 --- a/tests/test_ansi.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Unit testing for cmd2/ansi.py module""" - -import pytest - -from cmd2 import ( - ansi, -) - -HELLO_WORLD = 'Hello, world!' - - -def test_strip_style() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.style(base_str, fg=ansi.Fg.GREEN) - assert base_str != ansi_str - assert base_str == ansi.strip_style(ansi_str) - - -def test_style_aware_wcswidth() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.style(base_str, fg=ansi.Fg.GREEN) - assert ansi.style_aware_wcswidth(HELLO_WORLD) == ansi.style_aware_wcswidth(ansi_str) - - assert ansi.style_aware_wcswidth('i have a tab\t') == -1 - assert ansi.style_aware_wcswidth('i have a newline\n') == -1 - - -def test_widest_line() -> None: - text = ansi.style('i have\n3 lines\nThis is the longest one', fg=ansi.Fg.GREEN) - assert ansi.widest_line(text) == ansi.style_aware_wcswidth("This is the longest one") - - text = "I'm just one line" - assert ansi.widest_line(text) == ansi.style_aware_wcswidth(text) - - assert ansi.widest_line('i have a tab\t') == -1 - - -def test_style_none() -> None: - base_str = HELLO_WORLD - ansi_str = base_str - assert ansi.style(base_str) == ansi_str - - -@pytest.mark.parametrize('fg_color', [ansi.Fg.BLUE, ansi.EightBitFg.AQUAMARINE_1A, ansi.RgbFg(0, 2, 4)]) -def test_style_fg(fg_color) -> None: - base_str = HELLO_WORLD - ansi_str = fg_color + base_str + ansi.Fg.RESET - assert ansi.style(base_str, fg=fg_color) == ansi_str - - -@pytest.mark.parametrize('bg_color', [ansi.Bg.BLUE, ansi.EightBitBg.AQUAMARINE_1A, ansi.RgbBg(0, 2, 4)]) -def test_style_bg(bg_color) -> None: - base_str = HELLO_WORLD - ansi_str = bg_color + base_str + ansi.Bg.RESET - assert ansi.style(base_str, bg=bg_color) == ansi_str - - -def test_style_invalid_types() -> None: - # Use a BgColor with fg - with pytest.raises(TypeError): - ansi.style('test', fg=ansi.Bg.BLUE) - - # Use a FgColor with bg - with pytest.raises(TypeError): - ansi.style('test', bg=ansi.Fg.BLUE) - - -def test_style_bold() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.TextStyle.INTENSITY_BOLD + base_str + ansi.TextStyle.INTENSITY_NORMAL - assert ansi.style(base_str, bold=True) == ansi_str - - -def test_style_dim() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.TextStyle.INTENSITY_DIM + base_str + ansi.TextStyle.INTENSITY_NORMAL - assert ansi.style(base_str, dim=True) == ansi_str - - -def test_style_italic() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.TextStyle.ITALIC_ENABLE + base_str + ansi.TextStyle.ITALIC_DISABLE - assert ansi.style(base_str, italic=True) == ansi_str - - -def test_style_overline() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.TextStyle.OVERLINE_ENABLE + base_str + ansi.TextStyle.OVERLINE_DISABLE - assert ansi.style(base_str, overline=True) == ansi_str - - -def test_style_strikethrough() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.TextStyle.STRIKETHROUGH_ENABLE + base_str + ansi.TextStyle.STRIKETHROUGH_DISABLE - assert ansi.style(base_str, strikethrough=True) == ansi_str - - -def test_style_underline() -> None: - base_str = HELLO_WORLD - ansi_str = ansi.TextStyle.UNDERLINE_ENABLE + base_str + ansi.TextStyle.UNDERLINE_DISABLE - assert ansi.style(base_str, underline=True) == ansi_str - - -def test_style_multi() -> None: - base_str = HELLO_WORLD - fg_color = ansi.Fg.LIGHT_BLUE - bg_color = ansi.Bg.LIGHT_GRAY - ansi_str = ( - fg_color - + bg_color - + ansi.TextStyle.INTENSITY_BOLD - + ansi.TextStyle.INTENSITY_DIM - + ansi.TextStyle.ITALIC_ENABLE - + ansi.TextStyle.OVERLINE_ENABLE - + ansi.TextStyle.STRIKETHROUGH_ENABLE - + ansi.TextStyle.UNDERLINE_ENABLE - + base_str - + ansi.Fg.RESET - + ansi.Bg.RESET - + ansi.TextStyle.INTENSITY_NORMAL - + ansi.TextStyle.INTENSITY_NORMAL - + ansi.TextStyle.ITALIC_DISABLE - + ansi.TextStyle.OVERLINE_DISABLE - + ansi.TextStyle.STRIKETHROUGH_DISABLE - + ansi.TextStyle.UNDERLINE_DISABLE - ) - assert ( - ansi.style( - base_str, - fg=fg_color, - bg=bg_color, - bold=True, - dim=True, - italic=True, - overline=True, - strikethrough=True, - underline=True, - ) - == ansi_str - ) - - -def test_set_title() -> None: - title = HELLO_WORLD - assert ansi.set_title(title) == ansi.OSC + '2;' + title + ansi.BEL - - -@pytest.mark.parametrize( - ('cols', 'prompt', 'line', 'cursor', 'msg', 'expected'), - [ - ( - 127, - '(Cmd) ', - 'help his', - 12, - ansi.style('Hello World!', fg=ansi.Fg.MAGENTA), - '\x1b[2K\r\x1b[35mHello World!\x1b[39m', - ), - (127, '\n(Cmd) ', 'help ', 5, 'foo', '\x1b[2K\x1b[1A\x1b[2K\rfoo'), - ( - 10, - '(Cmd) ', - 'help history of the american republic', - 4, - 'boo', - '\x1b[3B\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\rboo', - ), - ], -) -def test_async_alert_str(cols, prompt, line, cursor, msg, expected) -> None: - alert_str = ansi.async_alert_str(terminal_columns=cols, prompt=prompt, line=line, cursor_offset=cursor, alert_msg=msg) - assert alert_str == expected - - -def test_clear_screen() -> None: - clear_type = 2 - assert ansi.clear_screen(clear_type) == f"{ansi.CSI}{clear_type}J" - - clear_type = -1 - expected_err = "clear_type must in an integer from 0 to 3" - with pytest.raises(ValueError, match=expected_err): - ansi.clear_screen(clear_type) - - clear_type = 4 - with pytest.raises(ValueError, match=expected_err): - ansi.clear_screen(clear_type) - - -def test_clear_line() -> None: - clear_type = 2 - assert ansi.clear_line(clear_type) == f"{ansi.CSI}{clear_type}K" - - clear_type = -1 - expected_err = "clear_type must in an integer from 0 to 2" - with pytest.raises(ValueError, match=expected_err): - ansi.clear_line(clear_type) - - clear_type = 3 - with pytest.raises(ValueError, match=expected_err): - ansi.clear_line(clear_type) - - -def test_cursor() -> None: - count = 1 - assert ansi.Cursor.UP(count) == f"{ansi.CSI}{count}A" - assert ansi.Cursor.DOWN(count) == f"{ansi.CSI}{count}B" - assert ansi.Cursor.FORWARD(count) == f"{ansi.CSI}{count}C" - assert ansi.Cursor.BACK(count) == f"{ansi.CSI}{count}D" - - x = 4 - y = 5 - assert ansi.Cursor.SET_POS(x, y) == f"{ansi.CSI}{y};{x}H" - - -@pytest.mark.parametrize( - 'ansi_sequence', - [ - ansi.Fg.MAGENTA, - ansi.Bg.LIGHT_GRAY, - ansi.EightBitBg.CHARTREUSE_2A, - ansi.EightBitBg.MEDIUM_PURPLE, - ansi.RgbFg(0, 5, 22), - ansi.RgbBg(100, 150, 222), - ansi.TextStyle.OVERLINE_ENABLE, - ], -) -def test_sequence_str_building(ansi_sequence) -> None: - """This tests __add__(), __radd__(), and __str__() methods for AnsiSequences""" - assert ansi_sequence + ansi_sequence == str(ansi_sequence) + str(ansi_sequence) - - -@pytest.mark.parametrize( - ('r', 'g', 'b', 'valid'), - [ - (0, 0, 0, True), - (255, 255, 255, True), - (-1, 0, 0, False), - (256, 255, 255, False), - (0, -1, 0, False), - (255, 256, 255, False), - (0, 0, -1, False), - (255, 255, 256, False), - ], -) -def test_rgb_bounds(r, g, b, valid) -> None: - if valid: - ansi.RgbFg(r, g, b) - ansi.RgbBg(r, g, b) - else: - expected_err = "RGB values must be integers in the range of 0 to 255" - with pytest.raises(ValueError, match=expected_err): - ansi.RgbFg(r, g, b) - with pytest.raises(ValueError, match=expected_err): - ansi.RgbBg(r, g, b) - - -def test_std_color_re() -> None: - """Test regular expressions for matching standard foreground and background colors""" - for color in ansi.Fg: - assert ansi.STD_FG_RE.match(str(color)) - assert not ansi.STD_BG_RE.match(str(color)) - for color in ansi.Bg: - assert ansi.STD_BG_RE.match(str(color)) - assert not ansi.STD_FG_RE.match(str(color)) - - # Test an invalid color code - assert not ansi.STD_FG_RE.match(f'{ansi.CSI}38m') - assert not ansi.STD_BG_RE.match(f'{ansi.CSI}48m') - - -def test_eight_bit_color_re() -> None: - """Test regular expressions for matching eight-bit foreground and background colors""" - for color in ansi.EightBitFg: - assert ansi.EIGHT_BIT_FG_RE.match(str(color)) - assert not ansi.EIGHT_BIT_BG_RE.match(str(color)) - for color in ansi.EightBitBg: - assert ansi.EIGHT_BIT_BG_RE.match(str(color)) - assert not ansi.EIGHT_BIT_FG_RE.match(str(color)) - - # Test invalid eight-bit value (256) - assert not ansi.EIGHT_BIT_FG_RE.match(f'{ansi.CSI}38;5;256m') - assert not ansi.EIGHT_BIT_BG_RE.match(f'{ansi.CSI}48;5;256m') - - -def test_rgb_color_re() -> None: - """Test regular expressions for matching RGB foreground and background colors""" - for i in range(256): - fg_color = ansi.RgbFg(i, i, i) - assert ansi.RGB_FG_RE.match(str(fg_color)) - assert not ansi.RGB_BG_RE.match(str(fg_color)) - - bg_color = ansi.RgbBg(i, i, i) - assert ansi.RGB_BG_RE.match(str(bg_color)) - assert not ansi.RGB_FG_RE.match(str(bg_color)) - - # Test invalid RGB value (256) - assert not ansi.RGB_FG_RE.match(f'{ansi.CSI}38;2;256;256;256m') - assert not ansi.RGB_BG_RE.match(f'{ansi.CSI}48;2;256;256;256m') diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 2ab59d29b..bb9172e0c 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -10,9 +10,7 @@ InteractiveConsole, ) from typing import NoReturn -from unittest import ( - mock, -) +from unittest import mock import pytest from rich.text import Text @@ -20,17 +18,20 @@ import cmd2 from cmd2 import ( COMMAND_NAME, - ansi, + Cmd2Style, + Color, clipboard, constants, exceptions, plugin, - rich_utils, + stylize, utils, ) -from cmd2.rl_utils import ( - readline, # This ensures gnureadline is used in macOS tests -) +from cmd2 import rich_utils as ru +from cmd2 import string_utils as su + +# This ensures gnureadline is used in macOS tests +from cmd2.rl_utils import readline # type: ignore[atrr-defined] from .conftest import ( SHORTCUTS_TXT, @@ -48,12 +49,12 @@ def arg_decorator(func): @functools.wraps(func) def cmd_wrapper(*args, **kwargs): - old = rich_utils.allow_style - rich_utils.allow_style = style + old = ru.ALLOW_STYLE + ru.ALLOW_STYLE = style try: retval = func(*args, **kwargs) finally: - rich_utils.allow_style = old + ru.ALLOW_STYLE = old return retval return cmd_wrapper @@ -232,31 +233,31 @@ def test_set_no_settables(base_app) -> None: @pytest.mark.parametrize( ('new_val', 'is_valid', 'expected'), [ - (rich_utils.AllowStyle.NEVER, True, rich_utils.AllowStyle.NEVER), - ('neVeR', True, rich_utils.AllowStyle.NEVER), - (rich_utils.AllowStyle.TERMINAL, True, rich_utils.AllowStyle.TERMINAL), - ('TeRMInal', True, rich_utils.AllowStyle.TERMINAL), - (rich_utils.AllowStyle.ALWAYS, True, rich_utils.AllowStyle.ALWAYS), - ('AlWaYs', True, rich_utils.AllowStyle.ALWAYS), - ('invalid', False, rich_utils.AllowStyle.TERMINAL), + (ru.AllowStyle.NEVER, True, ru.AllowStyle.NEVER), + ('neVeR', True, ru.AllowStyle.NEVER), + (ru.AllowStyle.TERMINAL, True, ru.AllowStyle.TERMINAL), + ('TeRMInal', True, ru.AllowStyle.TERMINAL), + (ru.AllowStyle.ALWAYS, True, ru.AllowStyle.ALWAYS), + ('AlWaYs', True, ru.AllowStyle.ALWAYS), + ('invalid', False, ru.AllowStyle.TERMINAL), ], ) def test_set_allow_style(base_app, new_val, is_valid, expected) -> None: # Initialize allow_style for this test - rich_utils.allow_style = rich_utils.AllowStyle.TERMINAL + ru.ALLOW_STYLE = ru.AllowStyle.TERMINAL # Use the set command to alter it out, err = run_cmd(base_app, f'set allow_style {new_val}') assert base_app.last_result is is_valid # Verify the results - assert rich_utils.allow_style == expected + assert expected == ru.ALLOW_STYLE if is_valid: assert not err assert out # Reset allow_style to its default since it's an application-wide setting that can affect other unit tests - rich_utils.allow_style = rich_utils.AllowStyle.TERMINAL + ru.ALLOW_STYLE = ru.AllowStyle.TERMINAL def test_set_with_choices(base_app) -> None: @@ -575,8 +576,8 @@ def test_relative_run_script_with_odd_file_names(base_app, file_name, monkeypatc run_script_mock = mock.MagicMock(name='do_run_script') monkeypatch.setattr("cmd2.Cmd.do_run_script", run_script_mock) - run_cmd(base_app, f"_relative_run_script {utils.quote_string(file_name)}") - run_script_mock.assert_called_once_with(utils.quote_string(file_name)) + run_cmd(base_app, f"_relative_run_script {su.quote(file_name)}") + run_script_mock.assert_called_once_with(su.quote(file_name)) def test_relative_run_script_requires_an_argument(base_app) -> None: @@ -987,9 +988,9 @@ def test_edit_file_with_odd_file_names(base_app, file_name, monkeypatch) -> None monkeypatch.setattr("cmd2.Cmd.do_shell", shell_mock) base_app.editor = 'fooedit' - file_name = utils.quote_string('nothingweird.py') - run_cmd(base_app, f"edit {utils.quote_string(file_name)}") - shell_mock.assert_called_once_with(f'"fooedit" {utils.quote_string(file_name)}') + file_name = su.quote('nothingweird.py') + run_cmd(base_app, f"edit {su.quote(file_name)}") + shell_mock.assert_called_once_with(f'"fooedit" {su.quote(file_name)}') def test_edit_file_with_spaces(base_app, request, monkeypatch) -> None: @@ -1221,8 +1222,7 @@ def test_escaping_prompt() -> None: assert rl_escape_prompt(prompt) == prompt # This prompt has color which needs to be escaped - color = ansi.Fg.CYAN - prompt = ansi.style('InColor', fg=color) + prompt = stylize('InColor', style=Color.CYAN) escape_start = "\x01" escape_end = "\x02" @@ -1232,8 +1232,10 @@ def test_escaping_prompt() -> None: # PyReadline on Windows doesn't need to escape invisible characters assert escaped_prompt == prompt else: - assert escaped_prompt.startswith(escape_start + color + escape_end) - assert escaped_prompt.endswith(escape_start + ansi.Fg.RESET + escape_end) + cyan = "\x1b[36m" + reset_all = "\x1b[0m" + assert escaped_prompt.startswith(escape_start + cyan + escape_end) + assert escaped_prompt.endswith(escape_start + reset_all + escape_end) assert rl_unescape_prompt(escaped_prompt) == prompt @@ -2035,7 +2037,7 @@ def test_poutput_none(outsim_app) -> None: assert out == expected -@with_ansi_style(rich_utils.AllowStyle.ALWAYS) +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_poutput_ansi_always(outsim_app) -> None: msg = 'Hello World' colored_msg = Text(msg, style="cyan") @@ -2044,7 +2046,7 @@ def test_poutput_ansi_always(outsim_app) -> None: assert out == "\x1b[36mHello World\x1b[0m\n" -@with_ansi_style(rich_utils.AllowStyle.NEVER) +@with_ansi_style(ru.AllowStyle.NEVER) def test_poutput_ansi_never(outsim_app) -> None: msg = 'Hello World' colored_msg = Text(msg, style="cyan") @@ -2054,7 +2056,7 @@ def test_poutput_ansi_never(outsim_app) -> None: assert out == expected -@with_ansi_style(rich_utils.AllowStyle.TERMINAL) +@with_ansi_style(ru.AllowStyle.TERMINAL) def test_poutput_ansi_terminal(outsim_app) -> None: """Test that AllowStyle.TERMINAL strips style when redirecting.""" msg = 'testing...' @@ -2495,7 +2497,7 @@ def test_nonexistent_macro(base_app) -> None: assert exception is not None -@with_ansi_style(rich_utils.AllowStyle.ALWAYS) +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_perror_style(base_app, capsys) -> None: msg = 'testing...' base_app.perror(msg) @@ -2503,7 +2505,7 @@ def test_perror_style(base_app, capsys) -> None: assert err == "\x1b[91mtesting...\x1b[0m\x1b[91m\n\x1b[0m" -@with_ansi_style(rich_utils.AllowStyle.ALWAYS) +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_perror_no_style(base_app, capsys) -> None: msg = 'testing...' end = '\n' @@ -2512,7 +2514,7 @@ def test_perror_no_style(base_app, capsys) -> None: assert err == msg + end -@with_ansi_style(rich_utils.AllowStyle.ALWAYS) +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_pexcept_style(base_app, capsys) -> None: msg = Exception('testing...') @@ -2521,7 +2523,7 @@ def test_pexcept_style(base_app, capsys) -> None: assert err.startswith("\x1b[91mEXCEPTION of type 'Exception' occurred with message: testing") -@with_ansi_style(rich_utils.AllowStyle.NEVER) +@with_ansi_style(ru.AllowStyle.NEVER) def test_pexcept_no_style(base_app, capsys) -> None: msg = Exception('testing...') @@ -2530,7 +2532,7 @@ def test_pexcept_no_style(base_app, capsys) -> None: assert err.startswith("EXCEPTION of type 'Exception' occurred with message: testing...") -@with_ansi_style(rich_utils.AllowStyle.ALWAYS) +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_pexcept_not_exception(base_app, capsys) -> None: # Pass in a msg that is not an Exception object msg = False @@ -2755,12 +2757,12 @@ def do_echo(self, args) -> None: self.perror(args) def do_echo_error(self, args) -> None: - self.poutput(ansi.style(args, fg=ansi.Fg.RED)) + self.poutput(args, style=Cmd2Style.ERROR) # perror uses colors by default self.perror(args) -@with_ansi_style(rich_utils.AllowStyle.ALWAYS) +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_ansi_pouterr_always_tty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=True) @@ -2783,7 +2785,7 @@ def test_ansi_pouterr_always_tty(mocker, capsys) -> None: assert 'oopsie' in err -@with_ansi_style(rich_utils.AllowStyle.ALWAYS) +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_ansi_pouterr_always_notty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=False) @@ -2806,7 +2808,7 @@ def test_ansi_pouterr_always_notty(mocker, capsys) -> None: assert 'oopsie' in err -@with_ansi_style(rich_utils.AllowStyle.TERMINAL) +@with_ansi_style(ru.AllowStyle.TERMINAL) def test_ansi_terminal_tty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=True) @@ -2828,7 +2830,7 @@ def test_ansi_terminal_tty(mocker, capsys) -> None: assert 'oopsie' in err -@with_ansi_style(rich_utils.AllowStyle.TERMINAL) +@with_ansi_style(ru.AllowStyle.TERMINAL) def test_ansi_terminal_notty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=False) @@ -2843,7 +2845,7 @@ def test_ansi_terminal_notty(mocker, capsys) -> None: assert out == err == 'oopsie\n' -@with_ansi_style(rich_utils.AllowStyle.NEVER) +@with_ansi_style(ru.AllowStyle.NEVER) def test_ansi_never_tty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=True) @@ -2858,7 +2860,7 @@ def test_ansi_never_tty(mocker, capsys) -> None: assert out == err == 'oopsie\n' -@with_ansi_style(rich_utils.AllowStyle.NEVER) +@with_ansi_style(ru.AllowStyle.NEVER) def test_ansi_never_notty(mocker, capsys) -> None: app = AnsiApp() mocker.patch.object(app.stdout, 'isatty', return_value=False) @@ -3071,7 +3073,7 @@ def test_startup_script_with_odd_file_names(startup_script) -> None: app = cmd2.Cmd(allow_cli_args=False, startup_script=startup_script) assert len(app._startup_commands) == 1 - assert app._startup_commands[0] == f"run_script {utils.quote_string(os.path.abspath(startup_script))}" + assert app._startup_commands[0] == f"run_script {su.quote(os.path.abspath(startup_script))}" # Restore os.path.exists os.path.exists = saved_exists @@ -3083,15 +3085,6 @@ def test_transcripts_at_init() -> None: assert app._transcript_files == transcript_files -def test_columnize_too_wide(outsim_app) -> None: - """Test calling columnize with output that wider than display_width""" - str_list = ["way too wide", "much wider than the first"] - outsim_app.columnize(str_list, display_width=5) - - expected = "\n".join(str_list) + "\n" - assert outsim_app.stdout.getvalue() == expected - - def test_command_parser_retrieval(outsim_app: cmd2.Cmd) -> None: # Pass something that isn't a method not_a_method = "just a string" diff --git a/tests/test_completion.py b/tests/test_completion.py index 702d5bd5a..95e0f314e 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -8,19 +8,13 @@ import os import sys from typing import NoReturn -from unittest import ( - mock, -) +from unittest import mock import pytest import cmd2 -from cmd2 import ( - utils, -) -from examples.subcommands import ( - SubcommandsExample, -) +from cmd2 import utils +from examples.subcommands import SubcommandsExample from .conftest import ( complete_tester, diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 711868cad..b7af37145 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -8,8 +8,8 @@ from cmd2 import ( constants, exceptions, - utils, ) +from cmd2 import string_utils as su from cmd2.parsing import ( Statement, StatementParser, @@ -140,7 +140,7 @@ def test_parse_single_word(parser, line) -> None: statement = parser.parse(line) assert statement.command == line assert statement == '' - assert statement.argv == [utils.strip_quotes(line)] + assert statement.argv == [su.strip_quotes(line)] assert not statement.arg_list assert statement.args == statement assert statement.raw == line diff --git a/tests/test_run_pyscript.py b/tests/test_run_pyscript.py index 78739dc4a..0d21379ab 100644 --- a/tests/test_run_pyscript.py +++ b/tests/test_run_pyscript.py @@ -8,7 +8,7 @@ import pytest -from cmd2 import utils +from cmd2.string_utils import quote from .conftest import ( odd_file_names, @@ -63,7 +63,7 @@ def test_run_pyscript_with_odd_file_names(base_app, python_script) -> None: input_mock = mock.MagicMock(name='input', return_value='1') builtins.input = input_mock - out, err = run_cmd(base_app, f"run_pyscript {utils.quote_string(python_script)}") + out, err = run_cmd(base_app, f"run_pyscript {quote(python_script)}") err = ''.join(err) assert f"Error reading script file '{python_script}'" in err assert base_app.last_result is False diff --git a/tests/test_string_utils.py b/tests/test_string_utils.py new file mode 100644 index 000000000..f0c955165 --- /dev/null +++ b/tests/test_string_utils.py @@ -0,0 +1,217 @@ +"""Unit testing for cmd2/string_utils.py module""" + +from rich.style import Style + +from cmd2 import Color +from cmd2 import rich_utils as ru +from cmd2 import string_utils as su + +HELLO_WORLD = 'Hello, world!' + + +def test_align_blank() -> None: + text = '' + character = '-' + width = 5 + aligned = su.align(text, "left", width=width, character=character) + assert aligned == character * width + + +def test_align_wider_than_width() -> None: + text = 'long text field' + character = '-' + width = 8 + aligned = su.align(text, "left", width=width, character=character) + assert aligned == text[:width] + + +def test_align_term_width() -> None: + text = 'foo' + character = ' ' + + term_width = ru.console_width() + expected_padding = (term_width - su.str_width(text)) * character + + aligned = su.align(text, "left", character=character) + assert aligned == text + expected_padding + + +def test_align_left() -> None: + text = 'foo' + character = '-' + width = 5 + aligned = su.align_left(text, width=width, character=character) + assert aligned == text + character * 2 + + +def test_align_left_wide_text() -> None: + text = '苹' + character = '-' + width = 4 + aligned = su.align_left(text, width=width, character=character) + assert aligned == text + character * 2 + + +def test_align_left_with_style() -> None: + character = '-' + + styled_text = su.stylize('table', style=Color.BRIGHT_BLUE) + width = 8 + + aligned = su.align_left(styled_text, width=width, character=character) + assert aligned == styled_text + character * 3 + + +def test_align_center() -> None: + text = 'foo' + character = '-' + width = 5 + aligned = su.align_center(text, width=width, character=character) + assert aligned == character + text + character + + +def test_align_center_wide_text() -> None: + text = '苹' + character = '-' + width = 4 + aligned = su.align_center(text, width=width, character=character) + assert aligned == character + text + character + + +def test_align_center_with_style() -> None: + character = '-' + + styled_text = su.stylize('table', style=Color.BRIGHT_BLUE) + width = 8 + + aligned = su.align_center(styled_text, width=width, character=character) + assert aligned == character + styled_text + character * 2 + + +def test_align_right() -> None: + text = 'foo' + character = '-' + width = 5 + aligned = su.align_right(text, width=width, character=character) + assert aligned == character * 2 + text + + +def test_align_right_wide_text() -> None: + text = '苹' + character = '-' + width = 4 + aligned = su.align_right(text, width=width, character=character) + assert aligned == character * 2 + text + + +def test_align_right_with_style() -> None: + character = '-' + + styled_text = su.stylize('table', style=Color.BRIGHT_BLUE) + width = 8 + + aligned = su.align_right(styled_text, width=width, character=character) + assert aligned == character * 3 + styled_text + + +def test_stylize() -> None: + styled_str = su.stylize( + HELLO_WORLD, + style=Style( + color=Color.GREEN, + bgcolor=Color.BLUE, + bold=True, + underline=True, + ), + ) + + assert styled_str == "\x1b[1;4;32;44mHello, world!\x1b[0m" + + +def test_strip_style() -> None: + base_str = HELLO_WORLD + styled_str = su.stylize(base_str, style=Color.GREEN) + assert base_str != styled_str + assert base_str == su.strip_style(styled_str) + + +def test_str_width() -> None: + # Include a full-width character + base_str = HELLO_WORLD + "深" + styled_str = su.stylize(base_str, style=Color.GREEN) + expected_width = len(HELLO_WORLD) + 2 + assert su.str_width(base_str) == su.str_width(styled_str) == expected_width + + +def test_is_quoted_short() -> None: + my_str = '' + assert not su.is_quoted(my_str) + your_str = '"' + assert not su.is_quoted(your_str) + + +def test_is_quoted_yes() -> None: + my_str = '"This is a test"' + assert su.is_quoted(my_str) + your_str = "'of the emergengy broadcast system'" + assert su.is_quoted(your_str) + + +def test_is_quoted_no() -> None: + my_str = '"This is a test' + assert not su.is_quoted(my_str) + your_str = "of the emergengy broadcast system'" + assert not su.is_quoted(your_str) + simple_str = "hello world" + assert not su.is_quoted(simple_str) + + +def test_quote() -> None: + my_str = "Hello World" + assert su.quote(my_str) == '"' + my_str + '"' + + my_str = "'Hello World'" + assert su.quote(my_str) == '"' + my_str + '"' + + my_str = '"Hello World"' + assert su.quote(my_str) == "'" + my_str + "'" + + +def test_quote_if_needed_yes() -> None: + my_str = "Hello World" + assert su.quote_if_needed(my_str) == '"' + my_str + '"' + your_str = '"foo" bar' + assert su.quote_if_needed(your_str) == "'" + your_str + "'" + + +def test_quote_if_needed_no() -> None: + my_str = "HelloWorld" + assert su.quote_if_needed(my_str) == my_str + your_str = "'Hello World'" + assert su.quote_if_needed(your_str) == your_str + + +def test_strip_quotes_no_quotes() -> None: + base_str = HELLO_WORLD + stripped = su.strip_quotes(base_str) + assert base_str == stripped + + +def test_strip_quotes_with_quotes() -> None: + base_str = '"' + HELLO_WORLD + '"' + stripped = su.strip_quotes(base_str) + assert stripped == HELLO_WORLD + + +def test_unicode_normalization() -> None: + s1 = 'café' + s2 = 'cafe\u0301' + assert s1 != s2 + assert su.norm_fold(s1) == su.norm_fold(s2) + + +def test_unicode_casefold() -> None: + micro = 'µ' + micro_cf = micro.casefold() + assert micro != micro_cf + assert su.norm_fold(micro) == su.norm_fold(micro_cf) diff --git a/tests/test_table_creator.py b/tests/test_table_creator.py deleted file mode 100644 index caf19b7eb..000000000 --- a/tests/test_table_creator.py +++ /dev/null @@ -1,725 +0,0 @@ -"""Unit testing for cmd2/table_creator.py module""" - -import pytest - -from cmd2 import ( - Bg, - Fg, - TextStyle, - ansi, -) -from cmd2.table_creator import ( - AlternatingTable, - BorderedTable, - Column, - HorizontalAlignment, - SimpleTable, - TableCreator, - VerticalAlignment, -) - -# Turn off black formatting for entire file so multiline strings -# can be visually aligned to match the tables being tested. -# fmt: off - - -def test_column_creation() -> None: - # Width less than 1 - with pytest.raises(ValueError, match="Column width cannot be less than 1"): - Column("Column 1", width=0) - - # Width specified - c = Column("header", width=20) - assert c.width == 20 - - # max_data_lines less than 1 - with pytest.raises(ValueError, match="Max data lines cannot be less than 1"): - Column("Column 1", max_data_lines=0) - - # No width specified, blank label - c = Column("") - assert c.width < 0 - tc = TableCreator([c]) - assert tc.cols[0].width == 1 - - # No width specified, label isn't blank but has no width - c = Column(ansi.style('', fg=Fg.GREEN)) - assert c.width < 0 - tc = TableCreator([c]) - assert tc.cols[0].width == 1 - - # No width specified, label has width - c = Column("a line") - assert c.width < 0 - tc = TableCreator([c]) - assert tc.cols[0].width == ansi.style_aware_wcswidth("a line") - - # No width specified, label has width and multiple lines - c = Column("short\nreally long") - assert c.width < 0 - tc = TableCreator([c]) - assert tc.cols[0].width == ansi.style_aware_wcswidth("really long") - - # No width specified, label has tabs - c = Column("line\twith\ttabs") - assert c.width < 0 - tc = TableCreator([c]) - assert tc.cols[0].width == ansi.style_aware_wcswidth("line with tabs") - - # Add basic tests for style_header_text and style_data_text to make sure these members don't get removed. - c = Column("Column 1") - assert c.style_header_text is True - assert c.style_data_text is True - - c = Column("Column 1", style_header_text=False) - assert c.style_header_text is False - assert c.style_data_text is True - - c = Column("Column 1", style_data_text=False) - assert c.style_header_text is True - assert c.style_data_text is False - - -def test_column_alignment() -> None: - column_1 = Column( - "Col 1", - width=10, - header_horiz_align=HorizontalAlignment.LEFT, - header_vert_align=VerticalAlignment.TOP, - data_horiz_align=HorizontalAlignment.RIGHT, - data_vert_align=VerticalAlignment.BOTTOM, - ) - column_2 = Column( - "Col 2", - width=10, - header_horiz_align=HorizontalAlignment.RIGHT, - header_vert_align=VerticalAlignment.BOTTOM, - data_horiz_align=HorizontalAlignment.CENTER, - data_vert_align=VerticalAlignment.MIDDLE, - ) - column_3 = Column( - "Col 3", - width=10, - header_horiz_align=HorizontalAlignment.CENTER, - header_vert_align=VerticalAlignment.MIDDLE, - data_horiz_align=HorizontalAlignment.LEFT, - data_vert_align=VerticalAlignment.TOP, - ) - column_4 = Column("Three\nline\nheader", width=10) - - columns = [column_1, column_2, column_3, column_4] - tc = TableCreator(columns) - - # Check defaults - assert column_4.header_horiz_align == HorizontalAlignment.LEFT - assert column_4.header_vert_align == VerticalAlignment.BOTTOM - assert column_4.data_horiz_align == HorizontalAlignment.LEFT - assert column_4.data_vert_align == VerticalAlignment.TOP - - # Create a header row - row_data = [col.header for col in columns] - header = tc.generate_row(row_data=row_data, is_header=True) - assert header == ( - 'Col 1 Three \n' - ' Col 3 line \n' - ' Col 2 header ' - ) - - # Create a data row - row_data = ["Val 1", "Val 2", "Val 3", "Three\nline\ndata"] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ( - ' Val 3 Three \n' - ' Val 2 line \n' - ' Val 1 data ' - ) - - -def test_blank_last_line() -> None: - """This tests that an empty line is inserted when the last data line is blank""" - column_1 = Column("Col 1", width=10) - tc = TableCreator([column_1]) - - row_data = ['my line\n\n'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('my line \n' - ' ') - - row_data = ['\n'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ' ' - - row_data = [''] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ' ' - - -def test_wrap_text() -> None: - column_1 = Column("Col 1", width=10) - tc = TableCreator([column_1]) - - # Test normal wrapping - row_data = ['Some text to wrap\nA new line that will wrap\nNot wrap\n 1 2 3'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('Some text \n' - 'to wrap \n' - 'A new line\n' - 'that will \n' - 'wrap \n' - 'Not wrap \n' - ' 1 2 3 ') - - # Test preserving a multiple space sequence across a line break - row_data = ['First last one'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('First \n' - ' last one ') - - -def test_wrap_text_max_lines() -> None: - column_1 = Column("Col 1", width=10, max_data_lines=2) - tc = TableCreator([column_1]) - - # Test not needing to truncate the final line - row_data = ['First line last line'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('First line\n' - 'last line ') - - # Test having to truncate the last word because it's too long for the final line - row_data = ['First line last lineextratext'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('First line\n' - 'last line…') - - # Test having to truncate the last word because it fits the final line but there is more text not being included - row_data = ['First line thistxtfit extra'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('First line\n' - 'thistxtfi…') - - # Test having to truncate the last word because it fits the final line but there are more lines not being included - row_data = ['First line thistxtfit\nextra'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('First line\n' - 'thistxtfi…') - - # Test having space left on the final line and adding an ellipsis because there are more lines not being included - row_data = ['First line last line\nextra line'] - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('First line\n' - 'last line…') - - -def test_wrap_long_word() -> None: - # Make sure words wider than column start on own line and wrap - column_1 = Column("LongColumnName", width=10) - column_2 = Column("Col 2", width=10) - - columns = [column_1, column_2] - tc = TableCreator(columns) - - # Test header row - row_data = [col.header for col in columns] - header = tc.generate_row(row_data, is_header=True) - assert header == ('LongColumn \n' - 'Name Col 2 ') - - # Test data row - row_data = [] - - # Long word should start on the first line (style should not affect width) - row_data.append(ansi.style("LongerThan10", fg=Fg.GREEN)) - - # Long word should start on the second line - row_data.append("Word LongerThan10") - - row = tc.generate_row(row_data=row_data, is_header=False) - expected = ( - TextStyle.RESET_ALL - + Fg.GREEN - + "LongerThan" - + TextStyle.RESET_ALL - + " Word \n" - + TextStyle.RESET_ALL - + Fg.GREEN - + "10" - + Fg.RESET - + TextStyle.RESET_ALL - + ' ' - + TextStyle.RESET_ALL - + ' LongerThan\n' - ' 10 ' - ) - assert row == expected - - -def test_wrap_long_word_max_data_lines() -> None: - column_1 = Column("Col 1", width=10, max_data_lines=2) - column_2 = Column("Col 2", width=10, max_data_lines=2) - column_3 = Column("Col 3", width=10, max_data_lines=2) - column_4 = Column("Col 4", width=10, max_data_lines=1) - - columns = [column_1, column_2, column_3, column_4] - tc = TableCreator(columns) - - row_data = [] - - # This long word will exactly fit the last line and it's the final word in the text. No ellipsis should appear. - row_data.append("LongerThan10FitsLast") - - # This long word will exactly fit the last line but it's not the final word in the text. - # Make sure ellipsis word's final character. - row_data.append("LongerThan10FitsLast\nMore lines") - - # This long word will run over the last line. Make sure it is truncated. - row_data.append("LongerThan10RunsOverLast") - - # This long word will start on the final line after another word. Therefore it won't wrap but will instead be truncated. - row_data.append("A LongerThan10RunsOverLast") - - row = tc.generate_row(row_data=row_data, is_header=False) - assert row == ('LongerThan LongerThan LongerThan A LongerT…\n' - '10FitsLast 10FitsLas… 10RunsOve… ') - - -def test_wrap_long_char_wider_than_max_width() -> None: - """This tests case where a character is wider than max_width. This can happen if max_width - is 1 and the text contains wide characters (e.g. East Asian). Replace it with an ellipsis. - """ - column_1 = Column("Col 1", width=1) - tc = TableCreator([column_1]) - row = tc.generate_row(row_data=['深'], is_header=False) - assert row == '…' - - -def test_generate_row_exceptions() -> None: - column_1 = Column("Col 1") - tc = TableCreator([column_1]) - row_data = ['fake'] - - # fill_char too long - with pytest.raises(TypeError) as excinfo: - tc.generate_row(row_data=row_data, is_header=False, fill_char='too long') - assert "Fill character must be exactly one character long" in str(excinfo.value) - - # Unprintable characters - for arg in ['fill_char', 'pre_line', 'inter_cell', 'post_line']: - kwargs = {arg: '\n'} - with pytest.raises(ValueError, match=f"{arg} contains an unprintable character"): - tc.generate_row(row_data=row_data, is_header=False, **kwargs) - - # Data with too many columns - row_data = ['Data 1', 'Extra Column'] - with pytest.raises(ValueError, match="Length of row_data must match length of cols"): - tc.generate_row(row_data=row_data, is_header=False) - - -def test_tabs() -> None: - column_1 = Column("Col\t1", width=20) - column_2 = Column("Col 2") - columns = [column_1, column_2] - tc = TableCreator(columns, tab_width=2) - - row_data = [col.header for col in columns] - row = tc.generate_row(row_data, is_header=True, fill_char='\t', pre_line='\t', inter_cell='\t', post_line='\t') - assert row == ' Col 1 Col 2 ' - - with pytest.raises(ValueError, match="Tab width cannot be less than 1" ): - TableCreator([column_1, column_2], tab_width=0) - - -def test_simple_table_creation() -> None: - column_1 = Column("Col 1", width=16) - column_2 = Column("Col 2", width=16) - - row_data = [] - row_data.append(["Col 1 Row 1", "Col 2 Row 1"]) - row_data.append(["Col 1 Row 2", "Col 2 Row 2"]) - - # Default options - st = SimpleTable([column_1, column_2]) - table = st.generate_table(row_data) - - assert table == ( - 'Col 1 Col 2 \n' - '----------------------------------\n' - 'Col 1 Row 1 Col 2 Row 1 \n' - ' \n' - 'Col 1 Row 2 Col 2 Row 2 ' - ) - - # Custom column spacing - st = SimpleTable([column_1, column_2], column_spacing=5) - table = st.generate_table(row_data) - - assert table == ( - 'Col 1 Col 2 \n' - '-------------------------------------\n' - 'Col 1 Row 1 Col 2 Row 1 \n' - ' \n' - 'Col 1 Row 2 Col 2 Row 2 ' - ) - - # Custom divider - st = SimpleTable([column_1, column_2], divider_char='─') - table = st.generate_table(row_data) - - assert table == ( - 'Col 1 Col 2 \n' - '──────────────────────────────────\n' - 'Col 1 Row 1 Col 2 Row 1 \n' - ' \n' - 'Col 1 Row 2 Col 2 Row 2 ' - ) - - # No divider - st = SimpleTable([column_1, column_2], divider_char=None) - no_divider_1 = st.generate_table(row_data) - - st = SimpleTable([column_1, column_2], divider_char='') - no_divider_2 = st.generate_table(row_data) - - assert no_divider_1 == no_divider_2 == ( - 'Col 1 Col 2 \n' - 'Col 1 Row 1 Col 2 Row 1 \n' - ' \n' - 'Col 1 Row 2 Col 2 Row 2 ' - ) - - # No row spacing - st = SimpleTable([column_1, column_2]) - table = st.generate_table(row_data, row_spacing=0) - assert table == ( - 'Col 1 Col 2 \n' - '----------------------------------\n' - 'Col 1 Row 1 Col 2 Row 1 \n' - 'Col 1 Row 2 Col 2 Row 2 ' - ) - - # No header - st = SimpleTable([column_1, column_2]) - table = st.generate_table(row_data, include_header=False) - - assert table == ('Col 1 Row 1 Col 2 Row 1 \n' - ' \n' - 'Col 1 Row 2 Col 2 Row 2 ') - - # Wide custom divider (divider needs no padding) - st = SimpleTable([column_1, column_2], divider_char='深') - table = st.generate_table(row_data) - - assert table == ( - 'Col 1 Col 2 \n' - '深深深深深深深深深深深深深深深深深\n' - 'Col 1 Row 1 Col 2 Row 1 \n' - ' \n' - 'Col 1 Row 2 Col 2 Row 2 ' - ) - - # Wide custom divider (divider needs padding) - st = SimpleTable([column_1, Column("Col 2", width=17)], - divider_char='深') - table = st.generate_table(row_data) - - assert table == ( - 'Col 1 Col 2 \n' - '深深深深深深深深深深深深深深深深深 \n' - 'Col 1 Row 1 Col 2 Row 1 \n' - ' \n' - 'Col 1 Row 2 Col 2 Row 2 ' - ) - - # Invalid column spacing - with pytest.raises(ValueError, match="Column spacing cannot be less than 0"): - SimpleTable([column_1, column_2], column_spacing=-1) - - # Invalid divider character - with pytest.raises(TypeError, match="Divider character must be exactly one character long"): - SimpleTable([column_1, column_2], divider_char='too long') - - with pytest.raises(ValueError, match="Divider character is an unprintable character"): - SimpleTable([column_1, column_2], divider_char='\n') - - # Invalid row spacing - st = SimpleTable([column_1, column_2]) - with pytest.raises(ValueError, match="Row spacing cannot be less than 0"): - st.generate_table(row_data, row_spacing=-1) - - # Test header and data colors - st = SimpleTable([column_1, column_2], divider_char=None, header_bg=Bg.GREEN, data_bg=Bg.LIGHT_BLUE) - table = st.generate_table(row_data) - assert table == ( - '\x1b[0m\x1b[42mCol 1\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 2\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\n' - '\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 2 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\n' - '\x1b[0m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\n' - '\x1b[0m\x1b[104mCol 1 Row 2\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 2 Row 2\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m' - ) - - # Make sure SimpleTable respects style_header_text and style_data_text flags. - # Don't apply parent table's background colors to header or data text in second column. - st = SimpleTable([column_1, Column("Col 2", width=16, style_header_text=False, style_data_text=False)], - divider_char=None, header_bg=Bg.GREEN, data_bg=Bg.LIGHT_BLUE) - table = st.generate_table(row_data) - assert table == ( - '\x1b[0m\x1b[42mCol 1\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0mCol 2\x1b[0m\x1b[42m \x1b[49m\x1b[0m\n' - '\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0mCol 2 Row 1\x1b[0m\x1b[104m \x1b[49m\x1b[0m\n' - '\x1b[0m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\n' - '\x1b[0m\x1b[104mCol 1 Row 2\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0mCol 2 Row 2\x1b[0m\x1b[104m \x1b[49m\x1b[0m' - ) - - -def test_simple_table_width() -> None: - # Base width - for num_cols in range(1, 10): - assert SimpleTable.base_width(num_cols) == (num_cols - 1) * 2 - - # Invalid num_cols value - with pytest.raises(ValueError, match="Column count cannot be less than 1"): - SimpleTable.base_width(0) - - # Total width - column_1 = Column("Col 1", width=16) - column_2 = Column("Col 2", width=16) - - row_data = [] - row_data.append(["Col 1 Row 1", "Col 2 Row 1"]) - row_data.append(["Col 1 Row 2", "Col 2 Row 2"]) - - st = SimpleTable([column_1, column_2]) - assert st.total_width() == 34 - - -def test_simple_generate_data_row_exceptions() -> None: - column_1 = Column("Col 1") - tc = SimpleTable([column_1]) - - # Data with too many columns - row_data = ['Data 1', 'Extra Column'] - with pytest.raises(ValueError, match="Length of row_data must match length of cols"): - tc.generate_data_row(row_data=row_data) - - -def test_bordered_table_creation() -> None: - column_1 = Column("Col 1", width=15) - column_2 = Column("Col 2", width=15) - - row_data = [] - row_data.append(["Col 1 Row 1", "Col 2 Row 1"]) - row_data.append(["Col 1 Row 2", "Col 2 Row 2"]) - - # Default options - bt = BorderedTable([column_1, column_2]) - table = bt.generate_table(row_data) - assert table == ( - '╔═════════════════╤═════════════════╗\n' - '║ Col 1 │ Col 2 ║\n' - '╠═════════════════╪═════════════════╣\n' - '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' - '╟─────────────────┼─────────────────╢\n' - '║ Col 1 Row 2 │ Col 2 Row 2 ║\n' - '╚═════════════════╧═════════════════╝' - ) - - # No column borders - bt = BorderedTable([column_1, column_2], column_borders=False) - table = bt.generate_table(row_data) - assert table == ( - '╔══════════════════════════════════╗\n' - '║ Col 1 Col 2 ║\n' - '╠══════════════════════════════════╣\n' - '║ Col 1 Row 1 Col 2 Row 1 ║\n' - '╟──────────────────────────────────╢\n' - '║ Col 1 Row 2 Col 2 Row 2 ║\n' - '╚══════════════════════════════════╝' - ) - - # No header - bt = BorderedTable([column_1, column_2]) - table = bt.generate_table(row_data, include_header=False) - assert table == ( - '╔═════════════════╤═════════════════╗\n' - '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' - '╟─────────────────┼─────────────────╢\n' - '║ Col 1 Row 2 │ Col 2 Row 2 ║\n' - '╚═════════════════╧═════════════════╝' - ) - - # Non-default padding - bt = BorderedTable([column_1, column_2], padding=2) - table = bt.generate_table(row_data) - assert table == ( - '╔═══════════════════╤═══════════════════╗\n' - '║ Col 1 │ Col 2 ║\n' - '╠═══════════════════╪═══════════════════╣\n' - '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' - '╟───────────────────┼───────────────────╢\n' - '║ Col 1 Row 2 │ Col 2 Row 2 ║\n' - '╚═══════════════════╧═══════════════════╝' - ) - - # Invalid padding - with pytest.raises(ValueError, match="Padding cannot be less than 0"): - BorderedTable([column_1, column_2], padding=-1) - - # Test border, header, and data colors - bt = BorderedTable([column_1, column_2], border_fg=Fg.LIGHT_YELLOW, border_bg=Bg.WHITE, - header_bg=Bg.GREEN, data_bg=Bg.LIGHT_BLUE) - table = bt.generate_table(row_data) - assert table == ( - '\x1b[93m\x1b[107m╔═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╤═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╗\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 1\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[93m\x1b[107m│\x1b[39m\x1b[49m\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 2\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m╠═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╪═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╣\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[93m\x1b[107m│\x1b[39m\x1b[49m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 2 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m╟─\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m───────────────\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m─┼─\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m───────────────\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m─╢\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 1 Row 2\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[93m\x1b[107m│\x1b[39m\x1b[49m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 2 Row 2\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m╚═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╧═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╝\x1b[39m\x1b[49m' - ) - - # Make sure BorderedTable respects style_header_text and style_data_text flags. - # Don't apply parent table's background colors to header or data text in second column. - bt = BorderedTable([column_1, Column("Col 2", width=15, style_header_text=False, style_data_text=False)], - header_bg=Bg.GREEN, data_bg=Bg.LIGHT_BLUE) - table = bt.generate_table(row_data) - assert table == ( - '╔═════════════════╤═════════════════╗\n' - '║\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 1\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m│\x1b[42m \x1b[49m\x1b[0mCol 2\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m║\n' - '╠═════════════════╪═════════════════╣\n' - '║\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m│\x1b[104m \x1b[49m\x1b[0mCol 2 Row 1\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m║\n' - '╟─────────────────┼─────────────────╢\n' - '║\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 1 Row 2\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m│\x1b[104m \x1b[49m\x1b[0mCol 2 Row 2\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m║\n' - '╚═════════════════╧═════════════════╝' - ) - - -def test_bordered_table_width() -> None: - # Default behavior (column_borders=True, padding=1) - assert BorderedTable.base_width(1) == 4 - assert BorderedTable.base_width(2) == 7 - assert BorderedTable.base_width(3) == 10 - - # No column borders - assert BorderedTable.base_width(1, column_borders=False) == 4 - assert BorderedTable.base_width(2, column_borders=False) == 6 - assert BorderedTable.base_width(3, column_borders=False) == 8 - - # No padding - assert BorderedTable.base_width(1, padding=0) == 2 - assert BorderedTable.base_width(2, padding=0) == 3 - assert BorderedTable.base_width(3, padding=0) == 4 - - # Extra padding - assert BorderedTable.base_width(1, padding=3) == 8 - assert BorderedTable.base_width(2, padding=3) == 15 - assert BorderedTable.base_width(3, padding=3) == 22 - - # Invalid num_cols value - with pytest.raises(ValueError, match="Column count cannot be less than 1"): - BorderedTable.base_width(0) - - # Total width - column_1 = Column("Col 1", width=15) - column_2 = Column("Col 2", width=15) - - row_data = [] - row_data.append(["Col 1 Row 1", "Col 2 Row 1"]) - row_data.append(["Col 1 Row 2", "Col 2 Row 2"]) - - bt = BorderedTable([column_1, column_2]) - assert bt.total_width() == 37 - - -def test_bordered_generate_data_row_exceptions() -> None: - column_1 = Column("Col 1") - tc = BorderedTable([column_1]) - - # Data with too many columns - row_data = ['Data 1', 'Extra Column'] - with pytest.raises(ValueError, match="Length of row_data must match length of cols"): - tc.generate_data_row(row_data=row_data) - - -def test_alternating_table_creation() -> None: - column_1 = Column("Col 1", width=15) - column_2 = Column("Col 2", width=15) - - row_data = [] - row_data.append(["Col 1 Row 1", "Col 2 Row 1"]) - row_data.append(["Col 1 Row 2", "Col 2 Row 2"]) - - # Default options - at = AlternatingTable([column_1, column_2]) - table = at.generate_table(row_data) - assert table == ( - '╔═════════════════╤═════════════════╗\n' - '║ Col 1 │ Col 2 ║\n' - '╠═════════════════╪═════════════════╣\n' - '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' - '║\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 1 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m│\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 2 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m║\n' - '╚═════════════════╧═════════════════╝' - ) - - # No column borders - at = AlternatingTable([column_1, column_2], column_borders=False) - table = at.generate_table(row_data) - assert table == ( - '╔══════════════════════════════════╗\n' - '║ Col 1 Col 2 ║\n' - '╠══════════════════════════════════╣\n' - '║ Col 1 Row 1 Col 2 Row 1 ║\n' - '║\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 1 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 2 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m║\n' - '╚══════════════════════════════════╝' - ) - - # No header - at = AlternatingTable([column_1, column_2]) - table = at.generate_table(row_data, include_header=False) - assert table == ( - '╔═════════════════╤═════════════════╗\n' - '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' - '║\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 1 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m│\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 2 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m║\n' - '╚═════════════════╧═════════════════╝' - ) - - # Non-default padding - at = AlternatingTable([column_1, column_2], padding=2) - table = at.generate_table(row_data) - assert table == ( - '╔═══════════════════╤═══════════════════╗\n' - '║ Col 1 │ Col 2 ║\n' - '╠═══════════════════╪═══════════════════╣\n' - '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' - '║\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 1 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m│\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 2 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m║\n' - '╚═══════════════════╧═══════════════════╝' - ) - - # Invalid padding - with pytest.raises(ValueError, match="Padding cannot be less than 0"): - AlternatingTable([column_1, column_2], padding=-1) - - # Test border, header, and data colors - at = AlternatingTable([column_1, column_2], border_fg=Fg.LIGHT_YELLOW, border_bg=Bg.WHITE, - header_bg=Bg.GREEN, odd_bg=Bg.LIGHT_BLUE, even_bg=Bg.LIGHT_RED) - table = at.generate_table(row_data) - assert table == ( - '\x1b[93m\x1b[107m╔═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╤═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╗\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 1\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[93m\x1b[107m│\x1b[39m\x1b[49m\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 2\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m╠═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╪═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╣\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[93m\x1b[107m│\x1b[39m\x1b[49m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 2 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\x1b[101m \x1b[49m\x1b[0m\x1b[101mCol 1 Row 2\x1b[49m\x1b[0m\x1b[101m \x1b[49m\x1b[0m\x1b[101m \x1b[49m\x1b[93m\x1b[107m│\x1b[39m\x1b[49m\x1b[101m \x1b[49m\x1b[0m\x1b[101mCol 2 Row 2\x1b[49m\x1b[0m\x1b[101m \x1b[49m\x1b[0m\x1b[101m \x1b[49m\x1b[93m\x1b[107m║\x1b[39m\x1b[49m\n' - '\x1b[93m\x1b[107m╚═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╧═\x1b[39m\x1b[49m\x1b[0m\x1b[0m\x1b[93m\x1b[107m═══════════════\x1b[39m\x1b[49m\x1b[0m\x1b[93m\x1b[107m═╝\x1b[39m\x1b[49m' - ) - - # Make sure AlternatingTable respects style_header_text and style_data_text flags. - # Don't apply parent table's background colors to header or data text in second column. - at = AlternatingTable([column_1, Column("Col 2", width=15, style_header_text=False, style_data_text=False)], - header_bg=Bg.GREEN, odd_bg=Bg.LIGHT_BLUE, even_bg=Bg.LIGHT_RED) - table = at.generate_table(row_data) - assert table == ( - '╔═════════════════╤═════════════════╗\n' - '║\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 1\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m│\x1b[42m \x1b[49m\x1b[0mCol 2\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m║\n' - '╠═════════════════╪═════════════════╣\n' - '║\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m│\x1b[104m \x1b[49m\x1b[0mCol 2 Row 1\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m║\n' - '║\x1b[101m \x1b[49m\x1b[0m\x1b[101mCol 1 Row 2\x1b[49m\x1b[0m\x1b[101m \x1b[49m\x1b[0m\x1b[101m \x1b[49m│\x1b[101m \x1b[49m\x1b[0mCol 2 Row 2\x1b[0m\x1b[101m \x1b[49m\x1b[0m\x1b[101m \x1b[49m║\n' - '╚═════════════════╧═════════════════╝' - ) diff --git a/tests/test_terminal_utils.py b/tests/test_terminal_utils.py new file mode 100644 index 000000000..c7d8a22f3 --- /dev/null +++ b/tests/test_terminal_utils.py @@ -0,0 +1,81 @@ +"""Unit testing for cmd2/terminal_utils.py module""" + +import pytest + +from cmd2 import ( + Color, +) +from cmd2 import string_utils as su +from cmd2 import terminal_utils as tu + + +def test_set_title() -> None: + title = "Hello, world!" + assert tu.set_title_str(title) == tu.OSC + '2;' + title + tu.BEL + + +@pytest.mark.parametrize( + ('cols', 'prompt', 'line', 'cursor', 'msg', 'expected'), + [ + ( + 127, + '(Cmd) ', + 'help his', + 12, + su.stylize('Hello World!', style=Color.MAGENTA), + '\x1b[2K\r\x1b[35mHello World!\x1b[0m', + ), + (127, '\n(Cmd) ', 'help ', 5, 'foo', '\x1b[2K\x1b[1A\x1b[2K\rfoo'), + ( + 10, + '(Cmd) ', + 'help history of the american republic', + 4, + 'boo', + '\x1b[3B\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\rboo', + ), + ], +) +def test_async_alert_str(cols, prompt, line, cursor, msg, expected) -> None: + alert_str = tu.async_alert_str(terminal_columns=cols, prompt=prompt, line=line, cursor_offset=cursor, alert_msg=msg) + assert alert_str == expected + + +def test_clear_screen() -> None: + clear_type = 2 + assert tu.clear_screen_str(clear_type) == f"{tu.CSI}{clear_type}J" + + clear_type = -1 + expected_err = "clear_type must in an integer from 0 to 3" + with pytest.raises(ValueError, match=expected_err): + tu.clear_screen_str(clear_type) + + clear_type = 4 + with pytest.raises(ValueError, match=expected_err): + tu.clear_screen_str(clear_type) + + +def test_clear_line() -> None: + clear_type = 2 + assert tu.clear_line_str(clear_type) == f"{tu.CSI}{clear_type}K" + + clear_type = -1 + expected_err = "clear_type must in an integer from 0 to 2" + with pytest.raises(ValueError, match=expected_err): + tu.clear_line_str(clear_type) + + clear_type = 3 + with pytest.raises(ValueError, match=expected_err): + tu.clear_line_str(clear_type) + + +def test_cursor() -> None: + count = 1 + assert tu.Cursor.UP(count) == f"{tu.CSI}{count}A" + assert tu.Cursor.DOWN(count) == f"{tu.CSI}{count}B" + assert tu.Cursor.FORWARD(count) == f"{tu.CSI}{count}C" + assert tu.Cursor.BACK(count) == f"{tu.CSI}{count}D" + + x = 4 + y = 5 + assert tu.Cursor.SET_POS(x, y) == f"{tu.CSI}{y};{x}H" diff --git a/tests/test_utils.py b/tests/test_utils.py index 334b13007..a5a83ba13 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -11,29 +11,10 @@ import pytest import cmd2.utils as cu -from cmd2 import ( - ansi, - constants, -) -from cmd2.constants import ( - HORIZONTAL_ELLIPSIS, -) HELLO_WORLD = 'Hello, world!' -def test_strip_quotes_no_quotes() -> None: - base_str = HELLO_WORLD - stripped = cu.strip_quotes(base_str) - assert base_str == stripped - - -def test_strip_quotes_with_quotes() -> None: - base_str = '"' + HELLO_WORLD + '"' - stripped = cu.strip_quotes(base_str) - assert stripped == HELLO_WORLD - - def test_remove_duplicates_no_duplicates() -> None: no_dups = [5, 4, 3, 2, 1] assert cu.remove_duplicates(no_dups) == no_dups @@ -44,20 +25,6 @@ def test_remove_duplicates_with_duplicates() -> None: assert cu.remove_duplicates(duplicates) == [1, 2, 3, 9, 7, 8] -def test_unicode_normalization() -> None: - s1 = 'café' - s2 = 'cafe\u0301' - assert s1 != s2 - assert cu.norm_fold(s1) == cu.norm_fold(s2) - - -def test_unicode_casefold() -> None: - micro = 'µ' - micro_cf = micro.casefold() - assert micro != micro_cf - assert cu.norm_fold(micro) == cu.norm_fold(micro_cf) - - def test_alphabetical_sort() -> None: my_list = ['café', 'µ', 'A', 'micro', 'unity', 'cafeteria'] assert cu.alphabetical_sort(my_list) == ['A', 'cafeteria', 'café', 'micro', 'unity', 'µ'] @@ -92,54 +59,6 @@ def test_natural_sort() -> None: assert cu.natural_sort(my_list) == ['a1', 'A2', 'a3', 'A11', 'a22'] -def test_is_quoted_short() -> None: - my_str = '' - assert not cu.is_quoted(my_str) - your_str = '"' - assert not cu.is_quoted(your_str) - - -def test_is_quoted_yes() -> None: - my_str = '"This is a test"' - assert cu.is_quoted(my_str) - your_str = "'of the emergengy broadcast system'" - assert cu.is_quoted(your_str) - - -def test_is_quoted_no() -> None: - my_str = '"This is a test' - assert not cu.is_quoted(my_str) - your_str = "of the emergengy broadcast system'" - assert not cu.is_quoted(your_str) - simple_str = "hello world" - assert not cu.is_quoted(simple_str) - - -def test_quote_string() -> None: - my_str = "Hello World" - assert cu.quote_string(my_str) == '"' + my_str + '"' - - my_str = "'Hello World'" - assert cu.quote_string(my_str) == '"' + my_str + '"' - - my_str = '"Hello World"' - assert cu.quote_string(my_str) == "'" + my_str + "'" - - -def test_quote_string_if_needed_yes() -> None: - my_str = "Hello World" - assert cu.quote_string_if_needed(my_str) == '"' + my_str + '"' - your_str = '"foo" bar' - assert cu.quote_string_if_needed(your_str) == "'" + your_str + "'" - - -def test_quote_string_if_needed_no() -> None: - my_str = "HelloWorld" - assert cu.quote_string_if_needed(my_str) == my_str - your_str = "'Hello World'" - assert cu.quote_string_if_needed(your_str) == your_str - - @pytest.fixture def stdout_sim(): return cu.StdSim(sys.stdout, echo=True) @@ -329,484 +248,6 @@ def test_context_flag_exit_err(context_flag) -> None: context_flag.__exit__() -def test_remove_overridden_styles() -> None: - from cmd2 import ( - Bg, - EightBitBg, - EightBitFg, - Fg, - RgbBg, - RgbFg, - TextStyle, - ) - - def make_strs(styles_list: list[ansi.AnsiSequence]) -> list[str]: - return [str(s) for s in styles_list] - - # Test Reset All - styles_to_parse = make_strs([Fg.BLUE, TextStyle.UNDERLINE_DISABLE, TextStyle.INTENSITY_DIM, TextStyle.RESET_ALL]) - expected = make_strs([TextStyle.RESET_ALL]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([Fg.BLUE, TextStyle.UNDERLINE_DISABLE, TextStyle.INTENSITY_DIM, TextStyle.ALT_RESET_ALL]) - expected = make_strs([TextStyle.ALT_RESET_ALL]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - # Test colors - styles_to_parse = make_strs([Fg.BLUE, Fg.RED, Fg.GREEN, Bg.BLUE, Bg.RED, Bg.GREEN]) - expected = make_strs([Fg.GREEN, Bg.GREEN]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([EightBitFg.BLUE, EightBitFg.RED, EightBitBg.BLUE, EightBitBg.RED]) - expected = make_strs([EightBitFg.RED, EightBitBg.RED]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([RgbFg(0, 3, 4), RgbFg(5, 6, 7), RgbBg(8, 9, 10), RgbBg(11, 12, 13)]) - expected = make_strs([RgbFg(5, 6, 7), RgbBg(11, 12, 13)]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - # Test text styles - styles_to_parse = make_strs([TextStyle.INTENSITY_DIM, TextStyle.INTENSITY_NORMAL, TextStyle.ITALIC_ENABLE]) - expected = make_strs([TextStyle.INTENSITY_NORMAL, TextStyle.ITALIC_ENABLE]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([TextStyle.INTENSITY_DIM, TextStyle.ITALIC_ENABLE, TextStyle.ITALIC_DISABLE]) - expected = make_strs([TextStyle.INTENSITY_DIM, TextStyle.ITALIC_DISABLE]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([TextStyle.INTENSITY_BOLD, TextStyle.OVERLINE_DISABLE, TextStyle.OVERLINE_ENABLE]) - expected = make_strs([TextStyle.INTENSITY_BOLD, TextStyle.OVERLINE_ENABLE]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([TextStyle.OVERLINE_DISABLE, TextStyle.STRIKETHROUGH_DISABLE, TextStyle.STRIKETHROUGH_ENABLE]) - expected = make_strs([TextStyle.OVERLINE_DISABLE, TextStyle.STRIKETHROUGH_ENABLE]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([TextStyle.STRIKETHROUGH_DISABLE, TextStyle.UNDERLINE_DISABLE, TextStyle.UNDERLINE_ENABLE]) - expected = make_strs([TextStyle.STRIKETHROUGH_DISABLE, TextStyle.UNDERLINE_ENABLE]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - styles_to_parse = make_strs([TextStyle.UNDERLINE_DISABLE]) - expected = make_strs([TextStyle.UNDERLINE_DISABLE]) - assert cu._remove_overridden_styles(styles_to_parse) == expected - - # Test unrecognized styles - slow_blink = ansi.CSI + str(5) - rapid_blink = ansi.CSI + str(6) - styles_to_parse = [slow_blink, rapid_blink] - expected = styles_to_parse - assert cu._remove_overridden_styles(styles_to_parse) == expected - - -def test_truncate_line() -> None: - line = 'long' - max_width = 3 - truncated = cu.truncate_line(line, max_width) - assert truncated == 'lo' + HORIZONTAL_ELLIPSIS - - -def test_truncate_line_already_fits() -> None: - line = 'long' - max_width = 4 - truncated = cu.truncate_line(line, max_width) - assert truncated == line - - -def test_truncate_line_with_newline() -> None: - line = 'fo\no' - max_width = 2 - with pytest.raises(ValueError, match="text contains an unprintable character"): - cu.truncate_line(line, max_width) - - -def test_truncate_line_width_is_too_small() -> None: - line = 'foo' - max_width = 0 - with pytest.raises(ValueError, match="max_width must be at least 1"): - cu.truncate_line(line, max_width) - - -def test_truncate_line_wide_text() -> None: - line = '苹苹other' - max_width = 6 - truncated = cu.truncate_line(line, max_width) - assert truncated == '苹苹o' + HORIZONTAL_ELLIPSIS - - -def test_truncate_line_split_wide_text() -> None: - """Test when truncation results in a string which is shorter than max_width""" - line = '1苹2苹' - max_width = 3 - truncated = cu.truncate_line(line, max_width) - assert truncated == '1' + HORIZONTAL_ELLIPSIS - - -def test_truncate_line_tabs() -> None: - line = 'has\ttab' - max_width = 9 - truncated = cu.truncate_line(line, max_width) - assert truncated == 'has t' + HORIZONTAL_ELLIPSIS - - -def test_truncate_with_style() -> None: - from cmd2 import ( - Fg, - TextStyle, - ) - - before_text = Fg.BLUE + TextStyle.UNDERLINE_ENABLE - after_text = Fg.RESET + TextStyle.UNDERLINE_DISABLE + TextStyle.ITALIC_ENABLE + TextStyle.ITALIC_DISABLE - - # This is what the styles after the truncated text should look like since they will be - # filtered by _remove_overridden_styles. - filtered_after_text = Fg.RESET + TextStyle.UNDERLINE_DISABLE + TextStyle.ITALIC_DISABLE - - # Style only before truncated text - line = before_text + 'long' - max_width = 3 - truncated = cu.truncate_line(line, max_width) - assert truncated == before_text + 'lo' + HORIZONTAL_ELLIPSIS - - # Style before and after truncated text - line = before_text + 'long' + after_text - max_width = 3 - truncated = cu.truncate_line(line, max_width) - assert truncated == before_text + 'lo' + HORIZONTAL_ELLIPSIS + filtered_after_text - - # Style only after truncated text - line = 'long' + after_text - max_width = 3 - truncated = cu.truncate_line(line, max_width) - assert truncated == 'lo' + HORIZONTAL_ELLIPSIS + filtered_after_text - - -def test_align_text_fill_char_is_tab() -> None: - text = 'foo' - fill_char = '\t' - width = 5 - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - assert aligned == text + ' ' - - -def test_align_text_with_style() -> None: - from cmd2 import ( - Fg, - TextStyle, - style, - ) - - fill_char = '-' - styled_fill_char = style(fill_char, fg=Fg.LIGHT_YELLOW) - - # Single line with only left fill - text = style('line1', fg=Fg.LIGHT_BLUE) - width = 8 - - aligned = cu.align_text(text, cu.TextAlignment.RIGHT, fill_char=styled_fill_char, width=width) - - left_fill = TextStyle.RESET_ALL + Fg.LIGHT_YELLOW + (fill_char * 3) + Fg.RESET + TextStyle.RESET_ALL - right_fill = TextStyle.RESET_ALL - line_1_text = Fg.LIGHT_BLUE + 'line1' + Fg.RESET - - assert aligned == (left_fill + line_1_text + right_fill) - - # Single line with only right fill - text = style('line1', fg=Fg.LIGHT_BLUE) - width = 8 - - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=styled_fill_char, width=width) - - left_fill = TextStyle.RESET_ALL - right_fill = TextStyle.RESET_ALL + Fg.LIGHT_YELLOW + (fill_char * 3) + Fg.RESET + TextStyle.RESET_ALL - line_1_text = Fg.LIGHT_BLUE + 'line1' + Fg.RESET - - assert aligned == (left_fill + line_1_text + right_fill) - - # Multiple lines to show that style is preserved across all lines. Also has left and right fill. - text = style('line1\nline2', fg=Fg.LIGHT_BLUE) - width = 9 - - aligned = cu.align_text(text, cu.TextAlignment.CENTER, fill_char=styled_fill_char, width=width) - - left_fill = TextStyle.RESET_ALL + Fg.LIGHT_YELLOW + (fill_char * 2) + Fg.RESET + TextStyle.RESET_ALL - right_fill = TextStyle.RESET_ALL + Fg.LIGHT_YELLOW + (fill_char * 2) + Fg.RESET + TextStyle.RESET_ALL - line_1_text = Fg.LIGHT_BLUE + 'line1' - line_2_text = Fg.LIGHT_BLUE + 'line2' + Fg.RESET - - assert aligned == (left_fill + line_1_text + right_fill + '\n' + left_fill + line_2_text + right_fill) - - -def test_align_text_width_is_too_small() -> None: - text = 'foo' - fill_char = '-' - width = 0 - with pytest.raises(ValueError, match="width must be at least 1"): - cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - - -def test_align_text_fill_char_is_too_long() -> None: - text = 'foo' - fill_char = 'fill' - width = 5 - with pytest.raises(TypeError): - cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - - -def test_align_text_fill_char_is_newline() -> None: - text = 'foo' - fill_char = '\n' - width = 5 - with pytest.raises(ValueError, match="Fill character is an unprintable character"): - cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - - -def test_align_text_has_tabs() -> None: - text = '\t\tfoo' - fill_char = '-' - width = 10 - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width, tab_width=2) - assert aligned == ' ' + 'foo' + '---' - - -def test_align_text_blank() -> None: - text = '' - fill_char = '-' - width = 5 - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - assert aligned == fill_char * width - - -def test_align_text_wider_than_width() -> None: - text = 'long text field' - fill_char = '-' - width = 8 - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - assert aligned == text - - -def test_align_text_wider_than_width_truncate() -> None: - text = 'long text field' - fill_char = '-' - width = 8 - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width, truncate=True) - assert aligned == 'long te' + HORIZONTAL_ELLIPSIS - - -def test_align_text_wider_than_width_truncate_add_fill() -> None: - """Test when truncation results in a string which is shorter than width and align_text adds filler""" - text = '1苹2苹' - fill_char = '-' - width = 3 - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width, truncate=True) - assert aligned == '1' + HORIZONTAL_ELLIPSIS + fill_char - - -def test_align_text_has_unprintable() -> None: - text = 'foo\x02' - fill_char = '-' - width = 5 - with pytest.raises(ValueError, match="Text to align contains an unprintable character"): - cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - - -def test_align_text_term_width() -> None: - import shutil - - text = 'foo' - fill_char = ' ' - - # Prior to Python 3.11 this can return 0, so use a fallback, so - # use the same fallback that cu.align_text() does if needed. - term_width = shutil.get_terminal_size().columns or constants.DEFAULT_TERMINAL_WIDTH - expected_fill = (term_width - ansi.style_aware_wcswidth(text)) * fill_char - - aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char) - assert aligned == text + expected_fill - - -def test_align_left() -> None: - text = 'foo' - fill_char = '-' - width = 5 - aligned = cu.align_left(text, fill_char=fill_char, width=width) - assert aligned == text + fill_char + fill_char - - -def test_align_left_multiline() -> None: - # Without style - text = "foo\nshoes" - fill_char = '-' - width = 7 - aligned = cu.align_left(text, fill_char=fill_char, width=width) - assert aligned == 'foo----\nshoes--' - - # With style - reset_all = str(ansi.TextStyle.RESET_ALL) - blue = str(ansi.Fg.BLUE) - red = str(ansi.Fg.RED) - green = str(ansi.Fg.GREEN) - fg_reset = str(ansi.Fg.RESET) - - text = f"{blue}foo{red}moo\nshoes{fg_reset}" - fill_char = f"{green}-{fg_reset}" - width = 7 - aligned = cu.align_left(text, fill_char=fill_char, width=width) - - expected = f"{reset_all}{blue}foo{red}moo{reset_all}{green}-{fg_reset}{reset_all}\n" - expected += f"{reset_all}{red}shoes{fg_reset}{reset_all}{green}--{fg_reset}{reset_all}" - assert aligned == expected - - -def test_align_left_wide_text() -> None: - text = '苹' - fill_char = '-' - width = 4 - aligned = cu.align_left(text, fill_char=fill_char, width=width) - assert aligned == text + fill_char + fill_char - - -def test_align_left_wide_fill() -> None: - text = 'foo' - fill_char = '苹' - width = 5 - aligned = cu.align_left(text, fill_char=fill_char, width=width) - assert aligned == text + fill_char - - -def test_align_left_wide_fill_needs_padding() -> None: - """Test when fill_char's display width does not divide evenly into gap""" - text = 'foo' - fill_char = '苹' - width = 6 - aligned = cu.align_left(text, fill_char=fill_char, width=width) - assert aligned == text + fill_char + ' ' - - -def test_align_center() -> None: - text = 'foo' - fill_char = '-' - width = 5 - aligned = cu.align_center(text, fill_char=fill_char, width=width) - assert aligned == fill_char + text + fill_char - - -def test_align_center_multiline() -> None: - # Without style - text = "foo\nshoes" - fill_char = '-' - width = 7 - aligned = cu.align_center(text, fill_char=fill_char, width=width) - assert aligned == '--foo--\n-shoes-' - - # With style - reset_all = str(ansi.TextStyle.RESET_ALL) - blue = str(ansi.Fg.BLUE) - red = str(ansi.Fg.RED) - green = str(ansi.Fg.GREEN) - fg_reset = str(ansi.Fg.RESET) - - text = f"{blue}foo{red}moo\nshoes{fg_reset}" - fill_char = f"{green}-{fg_reset}" - width = 10 - aligned = cu.align_center(text, fill_char=fill_char, width=width) - - expected = f"{reset_all}{green}--{fg_reset}{reset_all}{blue}foo{red}moo{reset_all}{green}--{fg_reset}{reset_all}\n" - expected += f"{reset_all}{green}--{fg_reset}{reset_all}{red}shoes{fg_reset}{reset_all}{green}---{fg_reset}{reset_all}" - assert aligned == expected - - -def test_align_center_wide_text() -> None: - text = '苹' - fill_char = '-' - width = 4 - aligned = cu.align_center(text, fill_char=fill_char, width=width) - assert aligned == fill_char + text + fill_char - - -def test_align_center_wide_fill() -> None: - text = 'foo' - fill_char = '苹' - width = 7 - aligned = cu.align_center(text, fill_char=fill_char, width=width) - assert aligned == fill_char + text + fill_char - - -def test_align_center_wide_fill_needs_right_padding() -> None: - """Test when fill_char's display width does not divide evenly into right gap""" - text = 'foo' - fill_char = '苹' - width = 8 - aligned = cu.align_center(text, fill_char=fill_char, width=width) - assert aligned == fill_char + text + fill_char + ' ' - - -def test_align_center_wide_fill_needs_left_and_right_padding() -> None: - """Test when fill_char's display width does not divide evenly into either gap""" - text = 'foo' - fill_char = '苹' - width = 9 - aligned = cu.align_center(text, fill_char=fill_char, width=width) - assert aligned == fill_char + ' ' + text + fill_char + ' ' - - -def test_align_right() -> None: - text = 'foo' - fill_char = '-' - width = 5 - aligned = cu.align_right(text, fill_char=fill_char, width=width) - assert aligned == fill_char + fill_char + text - - -def test_align_right_multiline() -> None: - # Without style - text = "foo\nshoes" - fill_char = '-' - width = 7 - aligned = cu.align_right(text, fill_char=fill_char, width=width) - assert aligned == '----foo\n--shoes' - - # With style - reset_all = str(ansi.TextStyle.RESET_ALL) - blue = str(ansi.Fg.BLUE) - red = str(ansi.Fg.RED) - green = str(ansi.Fg.GREEN) - fg_reset = str(ansi.Fg.RESET) - - text = f"{blue}foo{red}moo\nshoes{fg_reset}" - fill_char = f"{green}-{fg_reset}" - width = 7 - aligned = cu.align_right(text, fill_char=fill_char, width=width) - - expected = f"{reset_all}{green}-{fg_reset}{reset_all}{blue}foo{red}moo{reset_all}\n" - expected += f"{reset_all}{green}--{fg_reset}{reset_all}{red}shoes{fg_reset}{reset_all}" - assert aligned == expected - - -def test_align_right_wide_text() -> None: - text = '苹' - fill_char = '-' - width = 4 - aligned = cu.align_right(text, fill_char=fill_char, width=width) - assert aligned == fill_char + fill_char + text - - -def test_align_right_wide_fill() -> None: - text = 'foo' - fill_char = '苹' - width = 5 - aligned = cu.align_right(text, fill_char=fill_char, width=width) - assert aligned == fill_char + text - - -def test_align_right_wide_fill_needs_padding() -> None: - """Test when fill_char's display width does not divide evenly into gap""" - text = 'foo' - fill_char = '苹' - width = 6 - aligned = cu.align_right(text, fill_char=fill_char, width=width) - assert aligned == fill_char + ' ' + text - - def test_to_bool_str_true() -> None: assert cu.to_bool('true') assert cu.to_bool('True')