cmd2 2.7.0__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/__init__.py +41 -38
- cmd2/argparse_completer.py +80 -81
- cmd2/argparse_custom.py +322 -123
- cmd2/clipboard.py +1 -1
- cmd2/cmd2.py +1264 -837
- cmd2/colors.py +270 -0
- cmd2/command_definition.py +13 -5
- cmd2/constants.py +0 -6
- cmd2/decorators.py +41 -104
- cmd2/exceptions.py +1 -1
- cmd2/history.py +7 -11
- cmd2/parsing.py +12 -17
- cmd2/plugin.py +1 -2
- cmd2/py_bridge.py +15 -10
- cmd2/rich_utils.py +451 -0
- cmd2/rl_utils.py +12 -8
- cmd2/string_utils.py +166 -0
- cmd2/styles.py +72 -0
- cmd2/terminal_utils.py +144 -0
- cmd2/transcript.py +7 -9
- cmd2/utils.py +88 -508
- {cmd2-2.7.0.dist-info → cmd2-3.0.0b1.dist-info}/METADATA +22 -44
- cmd2-3.0.0b1.dist-info/RECORD +27 -0
- cmd2/ansi.py +0 -1093
- cmd2/table_creator.py +0 -1122
- cmd2-2.7.0.dist-info/RECORD +0 -24
- {cmd2-2.7.0.dist-info → cmd2-3.0.0b1.dist-info}/WHEEL +0 -0
- {cmd2-2.7.0.dist-info → cmd2-3.0.0b1.dist-info}/licenses/LICENSE +0 -0
- {cmd2-2.7.0.dist-info → cmd2-3.0.0b1.dist-info}/top_level.txt +0 -0
cmd2/utils.py
CHANGED
@@ -12,14 +12,27 @@ import re
|
|
12
12
|
import subprocess
|
13
13
|
import sys
|
14
14
|
import threading
|
15
|
-
import
|
16
|
-
|
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
|
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 .
|
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:
|
74
|
+
val_type: type[Any] | Callable[[Any], Any],
|
99
75
|
description: str,
|
100
76
|
settable_object: object,
|
101
77
|
*,
|
102
|
-
settable_attrib_name:
|
103
|
-
onchange_cb:
|
104
|
-
choices:
|
105
|
-
choices_provider:
|
106
|
-
completer:
|
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
|
111
|
-
:param val_type: callable used to cast the string value from the
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
:param
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
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
|
-
|
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
|
-
|
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.
|
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
|
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) ->
|
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[
|
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] =
|
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() ->
|
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', '
|
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:
|
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:
|
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:
|
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:
|
686
|
-
|
687
|
-
pipe_proc_reader:
|
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
|
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.
|
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
|
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)
|
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]) ->
|
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__):
|
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:
|
1229
|
-
) ->
|
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
|
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
|
-
|
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
|
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 =
|
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
|