cmd2 2.6.2__py3-none-any.whl → 3.0.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cmd2/utils.py CHANGED
@@ -12,14 +12,27 @@ import re
12
12
  import subprocess
13
13
  import sys
14
14
  import threading
15
- import unicodedata
16
- from collections.abc import Callable, Iterable
15
+ from collections.abc import (
16
+ Callable,
17
+ Iterable,
18
+ )
17
19
  from difflib import SequenceMatcher
18
20
  from enum import Enum
19
- from typing import TYPE_CHECKING, Any, Optional, TextIO, TypeVar, Union, cast, get_type_hints
21
+ from typing import (
22
+ TYPE_CHECKING,
23
+ Any,
24
+ TextIO,
25
+ TypeVar,
26
+ Union,
27
+ cast,
28
+ )
20
29
 
21
30
  from . import constants
22
- from .argparse_custom import ChoicesProviderFunc, CompleterFunc
31
+ from . import string_utils as su
32
+ from .argparse_custom import (
33
+ ChoicesProviderFunc,
34
+ CompleterFunc,
35
+ )
23
36
 
24
37
  if TYPE_CHECKING: # pragma: no cover
25
38
  import cmd2 # noqa: F401
@@ -31,43 +44,6 @@ else:
31
44
  _T = TypeVar('_T')
32
45
 
33
46
 
34
- def is_quoted(arg: str) -> bool:
35
- """Check if a string is quoted.
36
-
37
- :param arg: the string being checked for quotes
38
- :return: True if a string is quoted
39
- """
40
- return len(arg) > 1 and arg[0] == arg[-1] and arg[0] in constants.QUOTES
41
-
42
-
43
- def quote_string(arg: str) -> str:
44
- """Quote a string."""
45
- quote = "'" if '"' in arg else '"'
46
-
47
- return quote + arg + quote
48
-
49
-
50
- def quote_string_if_needed(arg: str) -> str:
51
- """Quote a string if it contains spaces and isn't already quoted."""
52
- if is_quoted(arg) or ' ' not in arg:
53
- return arg
54
-
55
- return quote_string(arg)
56
-
57
-
58
- def strip_quotes(arg: str) -> str:
59
- """Strip outer quotes from a string.
60
-
61
- Applies to both single and double quotes.
62
-
63
- :param arg: string to strip outer quotes from
64
- :return: same string with potentially outer quotes stripped
65
- """
66
- if is_quoted(arg):
67
- arg = arg[1:-1]
68
- return arg
69
-
70
-
71
47
  def to_bool(val: Any) -> bool:
72
48
  """Convert anything to a boolean based on its value.
73
49
 
@@ -95,37 +71,44 @@ class Settable:
95
71
  def __init__(
96
72
  self,
97
73
  name: str,
98
- val_type: Union[type[Any], Callable[[Any], Any]],
74
+ val_type: type[Any] | Callable[[Any], Any],
99
75
  description: str,
100
76
  settable_object: object,
101
77
  *,
102
- settable_attrib_name: Optional[str] = None,
103
- onchange_cb: Optional[Callable[[str, _T, _T], Any]] = None,
104
- choices: Optional[Iterable[Any]] = None,
105
- choices_provider: Optional[ChoicesProviderFunc] = None,
106
- completer: Optional[CompleterFunc] = None,
78
+ settable_attrib_name: str | None = None,
79
+ onchange_cb: Callable[[str, _T, _T], Any] | None = None,
80
+ choices: Iterable[Any] | None = None,
81
+ choices_provider: ChoicesProviderFunc | None = None,
82
+ completer: CompleterFunc | None = None,
107
83
  ) -> None:
108
84
  """Settable Initializer.
109
85
 
110
- :param name: name of the instance attribute being made settable
111
- :param val_type: callable used to cast the string value from the command line into its proper type and
112
- even validate its value. Setting this to bool provides tab completion for true/false and
113
- validation using to_bool(). The val_type function should raise an exception if it fails.
114
- This exception will be caught and printed by Cmd.do_set().
115
- :param description: string describing this setting
116
- :param settable_object: object to which the instance attribute belongs (e.g. self)
117
- :param settable_attrib_name: name which displays to the user in the output of the set command.
118
- Defaults to `name` if not specified.
119
- :param onchange_cb: optional function or method to call when the value of this settable is altered
120
- by the set command. (e.g. onchange_cb=self.debug_changed)
121
-
122
- Cmd.do_set() passes the following 3 arguments to onchange_cb:
123
- param_name: str - name of the changed parameter
124
- old_value: Any - the value before being changed
125
- new_value: Any - the value after being changed
126
-
127
- The following optional settings provide tab completion for a parameter's values. They correspond to the
128
- same settings in argparse-based tab completion. A maximum of one of these should be provided.
86
+ :param name: The user-facing name for this setting in the CLI.
87
+ :param val_type: A callable used to cast the string value from the CLI into its
88
+ proper type and validate it. This function should raise an
89
+ exception (like ValueError or TypeError) if the conversion or
90
+ validation fails, which will be caught and displayed to the user
91
+ by the set command. For example, setting this to int ensures the
92
+ input is a valid integer. Specifying bool automatically provides
93
+ tab completion for 'true' and 'false' and uses a built-in function
94
+ for conversion and validation.
95
+ :param description: A concise string that describes the purpose of this setting.
96
+ :param settable_object: The object that owns the attribute being made settable (e.g. self).
97
+ :param settable_attrib_name: The name of the attribute on the settable_object that
98
+ will be modified. This defaults to the value of the name
99
+ parameter if not specified.
100
+ :param onchange_cb: An optional function or method to call when the value of this
101
+ setting is altered by the set command. The callback is invoked
102
+ only if the new value is different from the old one.
103
+
104
+ It receives three arguments:
105
+ param_name: str - name of the parameter
106
+ old_value: Any - the parameter's old value
107
+ new_value: Any - the parameter's new value
108
+
109
+ The following optional settings provide tab completion for a parameter's values.
110
+ They correspond to the same settings in argparse-based tab completion. A maximum
111
+ of one of these should be provided.
129
112
 
130
113
  :param choices: iterable of accepted values
131
114
  :param choices_provider: function that provides choices for this argument
@@ -150,11 +133,13 @@ class Settable:
150
133
  self.choices_provider = choices_provider
151
134
  self.completer = completer
152
135
 
153
- def get_value(self) -> Any:
136
+ @property
137
+ def value(self) -> Any:
154
138
  """Get the value of the settable attribute."""
155
139
  return getattr(self.settable_obj, self.settable_attrib_name)
156
140
 
157
- def set_value(self, value: Any) -> None:
141
+ @value.setter
142
+ def value(self, value: Any) -> None:
158
143
  """Set the settable attribute on the specified destination object.
159
144
 
160
145
  :param value: new value to set
@@ -168,7 +153,7 @@ class Settable:
168
153
  raise ValueError(f"invalid choice: {new_value!r} (choose from {choices_str})")
169
154
 
170
155
  # Try to update the settable's value
171
- orig_value = self.get_value()
156
+ orig_value = self.value
172
157
  setattr(self.settable_obj, self.settable_attrib_name, new_value)
173
158
 
174
159
  # Check if we need to call an onchange callback
@@ -183,14 +168,12 @@ def is_text_file(file_path: str) -> bool:
183
168
  :return: True if the file is a non-empty text file, otherwise False
184
169
  :raises OSError: if file can't be read
185
170
  """
186
- import codecs
187
-
188
171
  expanded_path = os.path.abspath(os.path.expanduser(file_path.strip()))
189
172
  valid_text_file = False
190
173
 
191
174
  # Only need to check for utf-8 compliance since that covers ASCII, too
192
175
  try:
193
- with codecs.open(expanded_path, encoding='utf-8', errors='strict') as f:
176
+ with open(expanded_path, encoding='utf-8', errors='strict') as f:
194
177
  # Make sure the file has only utf-8 text and is not empty
195
178
  if sum(1 for _ in f) > 0:
196
179
  valid_text_file = True
@@ -216,15 +199,6 @@ def remove_duplicates(list_to_prune: list[_T]) -> list[_T]:
216
199
  return list(temp_dict.keys())
217
200
 
218
201
 
219
- def norm_fold(astr: str) -> str:
220
- """Normalize and casefold Unicode strings for saner comparisons.
221
-
222
- :param astr: input unicode string
223
- :return: a normalized and case-folded version of the input string
224
- """
225
- return unicodedata.normalize('NFC', astr).casefold()
226
-
227
-
228
202
  def alphabetical_sort(list_to_sort: Iterable[str]) -> list[str]:
229
203
  """Sorts a list of strings alphabetically.
230
204
 
@@ -237,10 +211,10 @@ def alphabetical_sort(list_to_sort: Iterable[str]) -> list[str]:
237
211
  :param list_to_sort: the list being sorted
238
212
  :return: the sorted list
239
213
  """
240
- return sorted(list_to_sort, key=norm_fold)
214
+ return sorted(list_to_sort, key=su.norm_fold)
241
215
 
242
216
 
243
- def try_int_or_force_to_lower_case(input_str: str) -> Union[int, str]:
217
+ def try_int_or_force_to_lower_case(input_str: str) -> int | str:
244
218
  """Try to convert the passed-in string to an integer. If that fails, it converts it to lower case using norm_fold.
245
219
 
246
220
  :param input_str: string to convert
@@ -249,10 +223,10 @@ def try_int_or_force_to_lower_case(input_str: str) -> Union[int, str]:
249
223
  try:
250
224
  return int(input_str)
251
225
  except ValueError:
252
- return norm_fold(input_str)
226
+ return su.norm_fold(input_str)
253
227
 
254
228
 
255
- def natural_keys(input_str: str) -> list[Union[int, str]]:
229
+ def natural_keys(input_str: str) -> list[int | str]:
256
230
  """Convert a string into a list of integers and strings to support natural sorting (see natural_sort).
257
231
 
258
232
  For example: natural_keys('abc123def') -> ['abc', '123', 'def']
@@ -285,7 +259,7 @@ def quote_specific_tokens(tokens: list[str], tokens_to_quote: list[str]) -> None
285
259
  """
286
260
  for i, token in enumerate(tokens):
287
261
  if token in tokens_to_quote:
288
- tokens[i] = quote_string(token)
262
+ tokens[i] = su.quote(token)
289
263
 
290
264
 
291
265
  def unquote_specific_tokens(tokens: list[str], tokens_to_unquote: list[str]) -> None:
@@ -295,7 +269,7 @@ def unquote_specific_tokens(tokens: list[str], tokens_to_unquote: list[str]) ->
295
269
  :param tokens_to_unquote: the tokens, which if present in tokens, to unquote
296
270
  """
297
271
  for i, token in enumerate(tokens):
298
- unquoted_token = strip_quotes(token)
272
+ unquoted_token = su.strip_quotes(token)
299
273
  if unquoted_token in tokens_to_unquote:
300
274
  tokens[i] = unquoted_token
301
275
 
@@ -306,9 +280,9 @@ def expand_user(token: str) -> str:
306
280
  :param token: the string to expand
307
281
  """
308
282
  if token:
309
- if is_quoted(token):
283
+ if su.is_quoted(token):
310
284
  quote_char = token[0]
311
- token = strip_quotes(token)
285
+ token = su.strip_quotes(token)
312
286
  else:
313
287
  quote_char = ''
314
288
 
@@ -330,7 +304,7 @@ def expand_user_in_tokens(tokens: list[str]) -> None:
330
304
  tokens[index] = expand_user(tokens[index])
331
305
 
332
306
 
333
- def find_editor() -> Optional[str]:
307
+ def find_editor() -> str | None:
334
308
  """Set cmd2.Cmd.DEFAULT_EDITOR. If EDITOR env variable is set, that will be used.
335
309
 
336
310
  Otherwise the function will look for a known editor in directories specified by PATH env variable.
@@ -339,9 +313,9 @@ def find_editor() -> Optional[str]:
339
313
  editor = os.environ.get('EDITOR')
340
314
  if not editor:
341
315
  if sys.platform[:3] == 'win':
342
- editors = ['code.cmd', 'notepad++.exe', 'notepad.exe']
316
+ editors = ['edit', 'code.cmd', 'notepad++.exe', 'notepad.exe']
343
317
  else:
344
- editors = ['vim', 'vi', 'emacs', 'nano', 'pico', 'joe', 'code', 'subl', 'atom', 'gedit', 'geany', 'kate']
318
+ editors = ['vim', 'vi', 'emacs', 'nano', 'pico', 'joe', 'code', 'subl', 'gedit', 'kate']
345
319
 
346
320
  # Get a list of every directory in the PATH environment variable and ignore symbolic links
347
321
  env_path = os.getenv('PATH')
@@ -469,7 +443,7 @@ class StdSim:
469
443
  """Get the internal contents as bytes."""
470
444
  return bytes(self.buffer.byte_buf)
471
445
 
472
- def read(self, size: Optional[int] = -1) -> str:
446
+ def read(self, size: int | None = -1) -> str:
473
447
  """Read from the internal contents as a str and then clear them out.
474
448
 
475
449
  :param size: Number of bytes to read from the stream
@@ -551,7 +525,7 @@ class ProcReader:
551
525
  If neither are pipes, then the process will run normally and no output will be captured.
552
526
  """
553
527
 
554
- def __init__(self, proc: PopenTextIO, stdout: Union[StdSim, TextIO], stderr: Union[StdSim, TextIO]) -> None:
528
+ def __init__(self, proc: PopenTextIO, stdout: StdSim | TextIO, stderr: StdSim | TextIO) -> None:
555
529
  """ProcReader initializer.
556
530
 
557
531
  :param proc: the Popen process being read from
@@ -633,7 +607,7 @@ class ProcReader:
633
607
  self._write_bytes(write_stream, available)
634
608
 
635
609
  @staticmethod
636
- def _write_bytes(stream: Union[StdSim, TextIO], to_write: Union[bytes, str]) -> None:
610
+ def _write_bytes(stream: StdSim | TextIO, to_write: bytes | str) -> None:
637
611
  """Write bytes to a stream.
638
612
 
639
613
  :param stream: the stream being written to
@@ -682,422 +656,31 @@ class RedirectionSavedState:
682
656
 
683
657
  def __init__(
684
658
  self,
685
- self_stdout: Union[StdSim, TextIO],
686
- sys_stdout: Union[StdSim, TextIO],
687
- pipe_proc_reader: Optional[ProcReader],
659
+ self_stdout: StdSim | TextIO,
660
+ stdouts_match: bool,
661
+ pipe_proc_reader: ProcReader | None,
688
662
  saved_redirecting: bool,
689
663
  ) -> None:
690
664
  """RedirectionSavedState initializer.
691
665
 
692
666
  :param self_stdout: saved value of Cmd.stdout
693
- :param sys_stdout: saved value of sys.stdout
667
+ :param stdouts_match: True if Cmd.stdout is equal to sys.stdout
694
668
  :param pipe_proc_reader: saved value of Cmd._cur_pipe_proc_reader
695
669
  :param saved_redirecting: saved value of Cmd._redirecting.
696
670
  """
697
671
  # Tells if command is redirecting
698
672
  self.redirecting = False
699
673
 
700
- # Used to restore values after redirection ends
674
+ # Used to restore stdout values after redirection ends
701
675
  self.saved_self_stdout = self_stdout
702
- self.saved_sys_stdout = sys_stdout
676
+ self.stdouts_match = stdouts_match
703
677
 
704
678
  # Used to restore values after command ends regardless of whether the command redirected
705
679
  self.saved_pipe_proc_reader = pipe_proc_reader
706
680
  self.saved_redirecting = saved_redirecting
707
681
 
708
682
 
709
- def _remove_overridden_styles(styles_to_parse: list[str]) -> list[str]:
710
- """Filter a style list down to only those which would still be in effect if all were processed in order.
711
-
712
- Utility function for align_text() / truncate_line().
713
-
714
- This is mainly used to reduce how many style strings are stored in memory when
715
- building large multiline strings with ANSI styles. We only need to carry over
716
- styles from previous lines that are still in effect.
717
-
718
- :param styles_to_parse: list of styles to evaluate.
719
- :return: list of styles that are still in effect.
720
- """
721
- from . import (
722
- ansi,
723
- )
724
-
725
- class StyleState:
726
- """Keeps track of what text styles are enabled."""
727
-
728
- def __init__(self) -> None:
729
- # Contains styles still in effect, keyed by their index in styles_to_parse
730
- self.style_dict: dict[int, str] = {}
731
-
732
- # Indexes into style_dict
733
- self.reset_all: Optional[int] = None
734
- self.fg: Optional[int] = None
735
- self.bg: Optional[int] = None
736
- self.intensity: Optional[int] = None
737
- self.italic: Optional[int] = None
738
- self.overline: Optional[int] = None
739
- self.strikethrough: Optional[int] = None
740
- self.underline: Optional[int] = None
741
-
742
- # Read the previous styles in order and keep track of their states
743
- style_state = StyleState()
744
-
745
- for index, style in enumerate(styles_to_parse):
746
- # For styles types that we recognize, only keep their latest value from styles_to_parse.
747
- # All unrecognized style types will be retained and their order preserved.
748
- if style in (str(ansi.TextStyle.RESET_ALL), str(ansi.TextStyle.ALT_RESET_ALL)):
749
- style_state = StyleState()
750
- style_state.reset_all = index
751
- elif ansi.STD_FG_RE.match(style) or ansi.EIGHT_BIT_FG_RE.match(style) or ansi.RGB_FG_RE.match(style):
752
- if style_state.fg is not None:
753
- style_state.style_dict.pop(style_state.fg)
754
- style_state.fg = index
755
- elif ansi.STD_BG_RE.match(style) or ansi.EIGHT_BIT_BG_RE.match(style) or ansi.RGB_BG_RE.match(style):
756
- if style_state.bg is not None:
757
- style_state.style_dict.pop(style_state.bg)
758
- style_state.bg = index
759
- elif style in (
760
- str(ansi.TextStyle.INTENSITY_BOLD),
761
- str(ansi.TextStyle.INTENSITY_DIM),
762
- str(ansi.TextStyle.INTENSITY_NORMAL),
763
- ):
764
- if style_state.intensity is not None:
765
- style_state.style_dict.pop(style_state.intensity)
766
- style_state.intensity = index
767
- elif style in (str(ansi.TextStyle.ITALIC_ENABLE), str(ansi.TextStyle.ITALIC_DISABLE)):
768
- if style_state.italic is not None:
769
- style_state.style_dict.pop(style_state.italic)
770
- style_state.italic = index
771
- elif style in (str(ansi.TextStyle.OVERLINE_ENABLE), str(ansi.TextStyle.OVERLINE_DISABLE)):
772
- if style_state.overline is not None:
773
- style_state.style_dict.pop(style_state.overline)
774
- style_state.overline = index
775
- elif style in (str(ansi.TextStyle.STRIKETHROUGH_ENABLE), str(ansi.TextStyle.STRIKETHROUGH_DISABLE)):
776
- if style_state.strikethrough is not None:
777
- style_state.style_dict.pop(style_state.strikethrough)
778
- style_state.strikethrough = index
779
- elif style in (str(ansi.TextStyle.UNDERLINE_ENABLE), str(ansi.TextStyle.UNDERLINE_DISABLE)):
780
- if style_state.underline is not None:
781
- style_state.style_dict.pop(style_state.underline)
782
- style_state.underline = index
783
-
784
- # Store this style and its location in the dictionary
785
- style_state.style_dict[index] = style
786
-
787
- return list(style_state.style_dict.values())
788
-
789
-
790
- class TextAlignment(Enum):
791
- """Horizontal text alignment."""
792
-
793
- LEFT = 1
794
- CENTER = 2
795
- RIGHT = 3
796
-
797
-
798
- def align_text(
799
- text: str,
800
- alignment: TextAlignment,
801
- *,
802
- fill_char: str = ' ',
803
- width: Optional[int] = None,
804
- tab_width: int = 4,
805
- truncate: bool = False,
806
- ) -> str:
807
- """Align text for display within a given width. Supports characters with display widths greater than 1.
808
-
809
- ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned
810
- independently.
811
-
812
- There are convenience wrappers around this function: align_left(), align_center(), and align_right()
813
-
814
- :param text: text to align (can contain multiple lines)
815
- :param alignment: how to align the text
816
- :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
817
- :param width: display width of the aligned text. Defaults to width of the terminal.
818
- :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
819
- be converted to one space.
820
- :param truncate: if True, then each line will be shortened to fit within the display width. The truncated
821
- portions are replaced by a '…' character. Defaults to False.
822
- :return: aligned text
823
- :raises TypeError: if fill_char is more than one character (not including ANSI style sequences)
824
- :raises ValueError: if text or fill_char contains an unprintable character
825
- :raises ValueError: if width is less than 1
826
- """
827
- import io
828
- import shutil
829
-
830
- from . import (
831
- ansi,
832
- )
833
-
834
- if width is None:
835
- # Prior to Python 3.11 this can return 0, so use a fallback if needed.
836
- width = shutil.get_terminal_size().columns or constants.DEFAULT_TERMINAL_WIDTH
837
-
838
- if width < 1:
839
- raise ValueError("width must be at least 1")
840
-
841
- # Convert tabs to spaces
842
- text = text.replace('\t', ' ' * tab_width)
843
- fill_char = fill_char.replace('\t', ' ')
844
-
845
- # Save fill_char with no styles for use later
846
- stripped_fill_char = ansi.strip_style(fill_char)
847
- if len(stripped_fill_char) != 1:
848
- raise TypeError("Fill character must be exactly one character long")
849
-
850
- fill_char_width = ansi.style_aware_wcswidth(fill_char)
851
- if fill_char_width == -1:
852
- raise (ValueError("Fill character is an unprintable character"))
853
-
854
- # Isolate the style chars before and after the fill character. We will use them when building sequences of
855
- # fill characters. Instead of repeating the style characters for each fill character, we'll wrap each sequence.
856
- fill_char_style_begin, fill_char_style_end = fill_char.split(stripped_fill_char)
857
-
858
- lines = text.splitlines() if text else ['']
859
-
860
- text_buf = io.StringIO()
861
-
862
- # ANSI style sequences that may affect subsequent lines will be cancelled by the fill_char's style.
863
- # To avoid this, we save styles which are still in effect so we can restore them when beginning the next line.
864
- # This also allows lines to be used independently and still have their style. TableCreator does this.
865
- previous_styles: list[str] = []
866
-
867
- for index, line in enumerate(lines):
868
- if index > 0:
869
- text_buf.write('\n')
870
-
871
- if truncate:
872
- line = truncate_line(line, width) # noqa: PLW2901
873
-
874
- line_width = ansi.style_aware_wcswidth(line)
875
- if line_width == -1:
876
- raise (ValueError("Text to align contains an unprintable character"))
877
-
878
- # Get list of styles in this line
879
- line_styles = list(get_styles_dict(line).values())
880
-
881
- # Calculate how wide each side of filling needs to be
882
- total_fill_width = 0 if line_width >= width else width - line_width
883
- # Even if the line needs no fill chars, there may be styles sequences to restore
884
-
885
- if alignment == TextAlignment.LEFT:
886
- left_fill_width = 0
887
- right_fill_width = total_fill_width
888
- elif alignment == TextAlignment.CENTER:
889
- left_fill_width = total_fill_width // 2
890
- right_fill_width = total_fill_width - left_fill_width
891
- else:
892
- left_fill_width = total_fill_width
893
- right_fill_width = 0
894
-
895
- # Determine how many fill characters are needed to cover the width
896
- left_fill = (left_fill_width // fill_char_width) * stripped_fill_char
897
- right_fill = (right_fill_width // fill_char_width) * stripped_fill_char
898
-
899
- # In cases where the fill character display width didn't divide evenly into
900
- # the gap being filled, pad the remainder with space.
901
- left_fill += ' ' * (left_fill_width - ansi.style_aware_wcswidth(left_fill))
902
- right_fill += ' ' * (right_fill_width - ansi.style_aware_wcswidth(right_fill))
903
-
904
- # Don't allow styles in fill characters and text to affect one another
905
- if fill_char_style_begin or fill_char_style_end or previous_styles or line_styles:
906
- if left_fill:
907
- left_fill = ansi.TextStyle.RESET_ALL + fill_char_style_begin + left_fill + fill_char_style_end
908
- left_fill += ansi.TextStyle.RESET_ALL
909
-
910
- if right_fill:
911
- right_fill = ansi.TextStyle.RESET_ALL + fill_char_style_begin + right_fill + fill_char_style_end
912
- right_fill += ansi.TextStyle.RESET_ALL
913
-
914
- # Write the line and restore styles from previous lines which are still in effect
915
- text_buf.write(left_fill + ''.join(previous_styles) + line + right_fill)
916
-
917
- # Update list of styles that are still in effect for the next line
918
- previous_styles.extend(line_styles)
919
- previous_styles = _remove_overridden_styles(previous_styles)
920
-
921
- return text_buf.getvalue()
922
-
923
-
924
- def align_left(
925
- text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4, truncate: bool = False
926
- ) -> str:
927
- """Left align text for display within a given width. Supports characters with display widths greater than 1.
928
-
929
- ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned
930
- independently.
931
-
932
- :param text: text to left align (can contain multiple lines)
933
- :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
934
- :param width: display width of the aligned text. Defaults to width of the terminal.
935
- :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
936
- be converted to one space.
937
- :param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is
938
- replaced by a '…' character. Defaults to False.
939
- :return: left-aligned text
940
- :raises TypeError: if fill_char is more than one character (not including ANSI style sequences)
941
- :raises ValueError: if text or fill_char contains an unprintable character
942
- :raises ValueError: if width is less than 1
943
- """
944
- return align_text(text, TextAlignment.LEFT, fill_char=fill_char, width=width, tab_width=tab_width, truncate=truncate)
945
-
946
-
947
- def align_center(
948
- text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4, truncate: bool = False
949
- ) -> str:
950
- """Center text for display within a given width. Supports characters with display widths greater than 1.
951
-
952
- ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned
953
- independently.
954
-
955
- :param text: text to center (can contain multiple lines)
956
- :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
957
- :param width: display width of the aligned text. Defaults to width of the terminal.
958
- :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
959
- be converted to one space.
960
- :param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is
961
- replaced by a '…' character. Defaults to False.
962
- :return: centered text
963
- :raises TypeError: if fill_char is more than one character (not including ANSI style sequences)
964
- :raises ValueError: if text or fill_char contains an unprintable character
965
- :raises ValueError: if width is less than 1
966
- """
967
- return align_text(text, TextAlignment.CENTER, fill_char=fill_char, width=width, tab_width=tab_width, truncate=truncate)
968
-
969
-
970
- def align_right(
971
- text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4, truncate: bool = False
972
- ) -> str:
973
- """Right align text for display within a given width. Supports characters with display widths greater than 1.
974
-
975
- ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned
976
- independently.
977
-
978
- :param text: text to right align (can contain multiple lines)
979
- :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
980
- :param width: display width of the aligned text. Defaults to width of the terminal.
981
- :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
982
- be converted to one space.
983
- :param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is
984
- replaced by a '…' character. Defaults to False.
985
- :return: right-aligned text
986
- :raises TypeError: if fill_char is more than one character (not including ANSI style sequences)
987
- :raises ValueError: if text or fill_char contains an unprintable character
988
- :raises ValueError: if width is less than 1
989
- """
990
- return align_text(text, TextAlignment.RIGHT, fill_char=fill_char, width=width, tab_width=tab_width, truncate=truncate)
991
-
992
-
993
- def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str:
994
- """Truncate a single line to fit within a given display width.
995
-
996
- Any portion of the string that is truncated is replaced by a '…' character. Supports characters with display widths greater
997
- than 1. ANSI style sequences do not count toward the display width.
998
-
999
- If there are ANSI style sequences in the string after where truncation occurs, this function will append them
1000
- to the returned string.
1001
-
1002
- This is done to prevent issues caused in cases like: truncate_line(Fg.BLUE + hello + Fg.RESET, 3)
1003
- In this case, "hello" would be truncated before Fg.RESET resets the color from blue. Appending the remaining style
1004
- sequences makes sure the style is in the same state had the entire string been printed. align_text() relies on this
1005
- behavior when preserving style over multiple lines.
1006
-
1007
- :param line: text to truncate
1008
- :param max_width: the maximum display width the resulting string is allowed to have
1009
- :param tab_width: any tabs in the text will be replaced with this many spaces
1010
- :return: line that has a display width less than or equal to width
1011
- :raises ValueError: if text contains an unprintable character like a newline
1012
- :raises ValueError: if max_width is less than 1
1013
- """
1014
- import io
1015
-
1016
- from . import (
1017
- ansi,
1018
- )
1019
-
1020
- # Handle tabs
1021
- line = line.replace('\t', ' ' * tab_width)
1022
-
1023
- if ansi.style_aware_wcswidth(line) == -1:
1024
- raise (ValueError("text contains an unprintable character"))
1025
-
1026
- if max_width < 1:
1027
- raise ValueError("max_width must be at least 1")
1028
-
1029
- if ansi.style_aware_wcswidth(line) <= max_width:
1030
- return line
1031
-
1032
- # Find all style sequences in the line
1033
- styles_dict = get_styles_dict(line)
1034
-
1035
- # Add characters one by one and preserve all style sequences
1036
- done = False
1037
- index = 0
1038
- total_width = 0
1039
- truncated_buf = io.StringIO()
1040
-
1041
- while not done:
1042
- # Check if a style sequence is at this index. These don't count toward display width.
1043
- if index in styles_dict:
1044
- truncated_buf.write(styles_dict[index])
1045
- style_len = len(styles_dict[index])
1046
- styles_dict.pop(index)
1047
- index += style_len
1048
- continue
1049
-
1050
- char = line[index]
1051
- char_width = ansi.style_aware_wcswidth(char)
1052
-
1053
- # This char will make the text too wide, add the ellipsis instead
1054
- if char_width + total_width >= max_width:
1055
- char = constants.HORIZONTAL_ELLIPSIS
1056
- char_width = ansi.style_aware_wcswidth(char)
1057
- done = True
1058
-
1059
- total_width += char_width
1060
- truncated_buf.write(char)
1061
- index += 1
1062
-
1063
- # Filter out overridden styles from the remaining ones
1064
- remaining_styles = _remove_overridden_styles(list(styles_dict.values()))
1065
-
1066
- # Append the remaining styles to the truncated text
1067
- truncated_buf.write(''.join(remaining_styles))
1068
-
1069
- return truncated_buf.getvalue()
1070
-
1071
-
1072
- def get_styles_dict(text: str) -> dict[int, str]:
1073
- """Return an OrderedDict containing all ANSI style sequences found in a string.
1074
-
1075
- The structure of the dictionary is:
1076
- key: index where sequences begins
1077
- value: ANSI style sequence found at index in text
1078
-
1079
- Keys are in ascending order
1080
-
1081
- :param text: text to search for style sequences
1082
- """
1083
- from . import (
1084
- ansi,
1085
- )
1086
-
1087
- start = 0
1088
- styles = collections.OrderedDict()
1089
-
1090
- while True:
1091
- match = ansi.ANSI_STYLE_RE.search(text, start)
1092
- if match is None:
1093
- break
1094
- styles[match.start()] = match.group()
1095
- start += len(match.group())
1096
-
1097
- return styles
1098
-
1099
-
1100
- def categorize(func: Union[Callable[..., Any], Iterable[Callable[..., Any]]], category: str) -> None:
683
+ def categorize(func: Callable[..., Any] | Iterable[Callable[..., Any]], category: str) -> None:
1101
684
  """Categorize a function.
1102
685
 
1103
686
  The help command output will group the passed function under the
@@ -1123,12 +706,12 @@ def categorize(func: Union[Callable[..., Any], Iterable[Callable[..., Any]]], ca
1123
706
  for item in func:
1124
707
  setattr(item, constants.CMD_ATTR_HELP_CATEGORY, category)
1125
708
  elif inspect.ismethod(func):
1126
- setattr(func.__func__, constants.CMD_ATTR_HELP_CATEGORY, category) # type: ignore[attr-defined]
709
+ setattr(func.__func__, constants.CMD_ATTR_HELP_CATEGORY, category)
1127
710
  else:
1128
711
  setattr(func, constants.CMD_ATTR_HELP_CATEGORY, category)
1129
712
 
1130
713
 
1131
- def get_defining_class(meth: Callable[..., Any]) -> Optional[type[Any]]:
714
+ def get_defining_class(meth: Callable[..., Any]) -> type[Any] | None:
1132
715
  """Attempt to resolve the class that defined a method.
1133
716
 
1134
717
  Inspired by implementation published here:
@@ -1142,7 +725,7 @@ def get_defining_class(meth: Callable[..., Any]) -> Optional[type[Any]]:
1142
725
  if inspect.ismethod(meth) or (
1143
726
  inspect.isbuiltin(meth) and hasattr(meth, '__self__') and hasattr(meth.__self__, '__class__')
1144
727
  ):
1145
- for cls in inspect.getmro(meth.__self__.__class__): # type: ignore[attr-defined]
728
+ for cls in inspect.getmro(meth.__self__.__class__):
1146
729
  if meth.__name__ in cls.__dict__:
1147
730
  return cls
1148
731
  meth = getattr(meth, '__func__', meth) # fallback to __qualname__ parsing
@@ -1225,8 +808,8 @@ MIN_SIMIL_TO_CONSIDER = 0.7
1225
808
 
1226
809
 
1227
810
  def suggest_similar(
1228
- requested_command: str, options: Iterable[str], similarity_function_to_use: Optional[Callable[[str, str], float]] = None
1229
- ) -> Optional[str]:
811
+ requested_command: str, options: Iterable[str], similarity_function_to_use: Callable[[str, str], float] | None = None
812
+ ) -> str | None:
1230
813
  """Given a requested command and an iterable of possible options returns the most similar (if any is similar).
1231
814
 
1232
815
  :param requested_command: The command entered by the user
@@ -1247,24 +830,21 @@ def suggest_similar(
1247
830
 
1248
831
 
1249
832
  def get_types(func_or_method: Callable[..., Any]) -> tuple[dict[str, Any], Any]:
1250
- """Use typing.get_type_hints() to extract type hints for parameters and return value.
1251
-
1252
- This exists because the inspect module doesn't have a safe way of doing this that works
1253
- both with and without importing annotations from __future__ until Python 3.10.
833
+ """Use inspect.get_annotations() to extract type hints for parameters and return value.
1254
834
 
1255
- TODO: Once cmd2 only supports Python 3.10+, change to use inspect.get_annotations(eval_str=True)
835
+ This is a thin convenience wrapper around inspect.get_annotations() that treats the return value
836
+ annotation separately.
1256
837
 
1257
838
  :param func_or_method: Function or method to return the type hints for
1258
- :return tuple with first element being dictionary mapping param names to type hints
1259
- and second element being return type hint, unspecified, returns None
839
+ :return: tuple with first element being dictionary mapping param names to type hints
840
+ and second element being the return type hint or None if there is no return value type hint
841
+ :raises ValueError: if the `func_or_method` argument is not a valid object to pass to `inspect.get_annotations`
1260
842
  """
1261
843
  try:
1262
- type_hints = get_type_hints(func_or_method) # Get dictionary of type hints
844
+ type_hints = inspect.get_annotations(func_or_method, eval_str=True) # Get dictionary of type hints
1263
845
  except TypeError as exc:
1264
846
  raise ValueError("Argument passed to get_types should be a function or method") from exc
1265
847
  ret_ann = type_hints.pop('return', None) # Pop off the return annotation if it exists
1266
848
  if inspect.ismethod(func_or_method):
1267
849
  type_hints.pop('self', None) # Pop off `self` hint for methods
1268
- if ret_ann is type(None):
1269
- ret_ann = None # Simplify logic to just return None instead of NoneType
1270
850
  return type_hints, ret_ann