cmd2 2.5.11__py3-none-any.whl → 2.6.1__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
@@ -1,8 +1,8 @@
1
- # coding=utf-8
2
- """Shared utility functions"""
1
+ """Shared utility functions."""
3
2
 
4
3
  import argparse
5
4
  import collections
5
+ import contextlib
6
6
  import functools
7
7
  import glob
8
8
  import inspect
@@ -13,34 +13,13 @@ import subprocess
13
13
  import sys
14
14
  import threading
15
15
  import unicodedata
16
- from difflib import (
17
- SequenceMatcher,
18
- )
19
- from enum import (
20
- Enum,
21
- )
22
- from typing import (
23
- TYPE_CHECKING,
24
- Any,
25
- Callable,
26
- Dict,
27
- Iterable,
28
- List,
29
- Optional,
30
- TextIO,
31
- Type,
32
- TypeVar,
33
- Union,
34
- cast,
35
- )
36
-
37
- from . import (
38
- constants,
39
- )
40
- from .argparse_custom import (
41
- ChoicesProviderFunc,
42
- CompleterFunc,
43
- )
16
+ from collections.abc import Callable, Iterable
17
+ from difflib import SequenceMatcher
18
+ from enum import Enum
19
+ from typing import TYPE_CHECKING, Any, Optional, TextIO, TypeVar, Union, cast, get_type_hints
20
+
21
+ from . import constants
22
+ from .argparse_custom import ChoicesProviderFunc, CompleterFunc
44
23
 
45
24
  if TYPE_CHECKING: # pragma: no cover
46
25
  import cmd2 # noqa: F401
@@ -53,8 +32,7 @@ _T = TypeVar('_T')
53
32
 
54
33
 
55
34
  def is_quoted(arg: str) -> bool:
56
- """
57
- Checks if a string is quoted
35
+ """Check if a string is quoted.
58
36
 
59
37
  :param arg: the string being checked for quotes
60
38
  :return: True if a string is quoted
@@ -63,17 +41,14 @@ def is_quoted(arg: str) -> bool:
63
41
 
64
42
 
65
43
  def quote_string(arg: str) -> str:
66
- """Quote a string"""
67
- if '"' in arg:
68
- quote = "'"
69
- else:
70
- quote = '"'
44
+ """Quote a string."""
45
+ quote = "'" if '"' in arg else '"'
71
46
 
72
47
  return quote + arg + quote
73
48
 
74
49
 
75
50
  def quote_string_if_needed(arg: str) -> str:
76
- """Quote a string if it contains spaces and isn't already quoted"""
51
+ """Quote a string if it contains spaces and isn't already quoted."""
77
52
  if is_quoted(arg) or ' ' not in arg:
78
53
  return arg
79
54
 
@@ -94,7 +69,7 @@ def strip_quotes(arg: str) -> str:
94
69
 
95
70
 
96
71
  def to_bool(val: Any) -> bool:
97
- """Converts anything to a boolean based on its value.
72
+ """Convert anything to a boolean based on its value.
98
73
 
99
74
  Strings like "True", "true", "False", and "false" return True, True, False, and False
100
75
  respectively. All other values are converted using bool()
@@ -106,22 +81,21 @@ def to_bool(val: Any) -> bool:
106
81
  if isinstance(val, str):
107
82
  if val.capitalize() == str(True):
108
83
  return True
109
- elif val.capitalize() == str(False):
84
+ if val.capitalize() == str(False):
110
85
  return False
111
86
  raise ValueError("must be True or False (case-insensitive)")
112
- elif isinstance(val, bool):
87
+ if isinstance(val, bool):
113
88
  return val
114
- else:
115
- return bool(val)
89
+ return bool(val)
116
90
 
117
91
 
118
92
  class Settable:
119
- """Used to configure an attribute to be settable via the set command in the CLI"""
93
+ """Used to configure an attribute to be settable via the set command in the CLI."""
120
94
 
121
95
  def __init__(
122
96
  self,
123
97
  name: str,
124
- val_type: Union[Type[Any], Callable[[Any], Any]],
98
+ val_type: Union[type[Any], Callable[[Any], Any]],
125
99
  description: str,
126
100
  settable_object: object,
127
101
  *,
@@ -131,8 +105,7 @@ class Settable:
131
105
  choices_provider: Optional[ChoicesProviderFunc] = None,
132
106
  completer: Optional[CompleterFunc] = None,
133
107
  ) -> None:
134
- """
135
- Settable Initializer
108
+ """Settable Initializer.
136
109
 
137
110
  :param name: name of the instance attribute being made settable
138
111
  :param val_type: callable used to cast the string value from the command line into its proper type and
@@ -160,8 +133,8 @@ class Settable:
160
133
  """
161
134
  if val_type is bool:
162
135
 
163
- def get_bool_choices(_) -> List[str]: # type: ignore[no-untyped-def]
164
- """Used to tab complete lowercase boolean values"""
136
+ def get_bool_choices(_: str) -> list[str]:
137
+ """Tab complete lowercase boolean values."""
165
138
  return ['true', 'false']
166
139
 
167
140
  val_type = to_bool
@@ -182,8 +155,7 @@ class Settable:
182
155
  return getattr(self.settable_obj, self.settable_attrib_name)
183
156
 
184
157
  def set_value(self, value: Any) -> None:
185
- """
186
- Set the settable attribute on the specified destination object.
158
+ """Set the settable attribute on the specified destination object.
187
159
 
188
160
  :param value: new value to set
189
161
  """
@@ -205,7 +177,7 @@ class Settable:
205
177
 
206
178
 
207
179
  def is_text_file(file_path: str) -> bool:
208
- """Returns if a file contains only ASCII or UTF-8 encoded text and isn't empty.
180
+ """Return if a file contains only ASCII or UTF-8 encoded text and isn't empty.
209
181
 
210
182
  :param file_path: path to the file being checked
211
183
  :return: True if the file is a non-empty text file, otherwise False
@@ -231,8 +203,8 @@ def is_text_file(file_path: str) -> bool:
231
203
  return valid_text_file
232
204
 
233
205
 
234
- def remove_duplicates(list_to_prune: List[_T]) -> List[_T]:
235
- """Removes duplicates from a list while preserving order of the items.
206
+ def remove_duplicates(list_to_prune: list[_T]) -> list[_T]:
207
+ """Remove duplicates from a list while preserving order of the items.
236
208
 
237
209
  :param list_to_prune: the list being pruned of duplicates
238
210
  :return: The pruned list
@@ -253,7 +225,7 @@ def norm_fold(astr: str) -> str:
253
225
  return unicodedata.normalize('NFC', astr).casefold()
254
226
 
255
227
 
256
- def alphabetical_sort(list_to_sort: Iterable[str]) -> List[str]:
228
+ def alphabetical_sort(list_to_sort: Iterable[str]) -> list[str]:
257
229
  """Sorts a list of strings alphabetically.
258
230
 
259
231
  For example: ['a1', 'A11', 'A2', 'a22', 'a3']
@@ -269,10 +241,10 @@ def alphabetical_sort(list_to_sort: Iterable[str]) -> List[str]:
269
241
 
270
242
 
271
243
  def try_int_or_force_to_lower_case(input_str: str) -> Union[int, str]:
272
- """
273
- Tries to convert the passed-in string to an integer. If that fails, it converts it to lower case using norm_fold.
244
+ """Try to convert the passed-in string to an integer. If that fails, it converts it to lower case using norm_fold.
245
+
274
246
  :param input_str: string to convert
275
- :return: the string as an integer or a lower case version of the string
247
+ :return: the string as an integer or a lower case version of the string.
276
248
  """
277
249
  try:
278
250
  return int(input_str)
@@ -280,9 +252,8 @@ def try_int_or_force_to_lower_case(input_str: str) -> Union[int, str]:
280
252
  return norm_fold(input_str)
281
253
 
282
254
 
283
- def natural_keys(input_str: str) -> List[Union[int, str]]:
284
- """
285
- Converts a string into a list of integers and strings to support natural sorting (see natural_sort).
255
+ def natural_keys(input_str: str) -> list[Union[int, str]]:
256
+ """Convert a string into a list of integers and strings to support natural sorting (see natural_sort).
286
257
 
287
258
  For example: natural_keys('abc123def') -> ['abc', '123', 'def']
288
259
  :param input_str: string to convert
@@ -291,9 +262,8 @@ def natural_keys(input_str: str) -> List[Union[int, str]]:
291
262
  return [try_int_or_force_to_lower_case(substr) for substr in re.split(r'(\d+)', input_str)]
292
263
 
293
264
 
294
- def natural_sort(list_to_sort: Iterable[str]) -> List[str]:
295
- """
296
- Sorts a list of strings case insensitively as well as numerically.
265
+ def natural_sort(list_to_sort: Iterable[str]) -> list[str]:
266
+ """Sorts a list of strings case insensitively as well as numerically.
297
267
 
298
268
  For example: ['a1', 'A2', 'a3', 'A11', 'a22']
299
269
 
@@ -307,9 +277,8 @@ def natural_sort(list_to_sort: Iterable[str]) -> List[str]:
307
277
  return sorted(list_to_sort, key=natural_keys)
308
278
 
309
279
 
310
- def quote_specific_tokens(tokens: List[str], tokens_to_quote: List[str]) -> None:
311
- """
312
- Quote specific tokens in a list
280
+ def quote_specific_tokens(tokens: list[str], tokens_to_quote: list[str]) -> None:
281
+ """Quote specific tokens in a list.
313
282
 
314
283
  :param tokens: token list being edited
315
284
  :param tokens_to_quote: the tokens, which if present in tokens, to quote
@@ -319,9 +288,8 @@ def quote_specific_tokens(tokens: List[str], tokens_to_quote: List[str]) -> None
319
288
  tokens[i] = quote_string(token)
320
289
 
321
290
 
322
- def unquote_specific_tokens(tokens: List[str], tokens_to_unquote: List[str]) -> None:
323
- """
324
- Unquote specific tokens in a list
291
+ def unquote_specific_tokens(tokens: list[str], tokens_to_unquote: list[str]) -> None:
292
+ """Unquote specific tokens in a list.
325
293
 
326
294
  :param tokens: token list being edited
327
295
  :param tokens_to_unquote: the tokens, which if present in tokens, to unquote
@@ -333,8 +301,8 @@ def unquote_specific_tokens(tokens: List[str], tokens_to_unquote: List[str]) ->
333
301
 
334
302
 
335
303
  def expand_user(token: str) -> str:
336
- """
337
- Wrap os.expanduser() to support expanding ~ in quoted strings
304
+ """Wrap os.expanduser() to support expanding ~ in quoted strings.
305
+
338
306
  :param token: the string to expand
339
307
  """
340
308
  if token:
@@ -353,20 +321,20 @@ def expand_user(token: str) -> str:
353
321
  return token
354
322
 
355
323
 
356
- def expand_user_in_tokens(tokens: List[str]) -> None:
357
- """
358
- Call expand_user() on all tokens in a list of strings
359
- :param tokens: tokens to expand
324
+ def expand_user_in_tokens(tokens: list[str]) -> None:
325
+ """Call expand_user() on all tokens in a list of strings.
326
+
327
+ :param tokens: tokens to expand.
360
328
  """
361
329
  for index, _ in enumerate(tokens):
362
330
  tokens[index] = expand_user(tokens[index])
363
331
 
364
332
 
365
333
  def find_editor() -> Optional[str]:
366
- """
367
- Used to set cmd2.Cmd.DEFAULT_EDITOR. If EDITOR env variable is set, that will be used.
334
+ """Set cmd2.Cmd.DEFAULT_EDITOR. If EDITOR env variable is set, that will be used.
335
+
368
336
  Otherwise the function will look for a known editor in directories specified by PATH env variable.
369
- :return: Default editor or None
337
+ :return: Default editor or None.
370
338
  """
371
339
  editor = os.environ.get('EDITOR')
372
340
  if not editor:
@@ -377,17 +345,16 @@ def find_editor() -> Optional[str]:
377
345
 
378
346
  # Get a list of every directory in the PATH environment variable and ignore symbolic links
379
347
  env_path = os.getenv('PATH')
380
- if env_path is None:
381
- paths = []
382
- else:
383
- paths = [p for p in env_path.split(os.path.pathsep) if not os.path.islink(p)]
348
+ paths = [] if env_path is None else [p for p in env_path.split(os.path.pathsep) if not os.path.islink(p)]
384
349
 
385
- for editor, path in itertools.product(editors, paths):
386
- editor_path = os.path.join(path, editor)
350
+ for possible_editor, path in itertools.product(editors, paths):
351
+ editor_path = os.path.join(path, possible_editor)
387
352
  if os.path.isfile(editor_path) and os.access(editor_path, os.X_OK):
388
353
  if sys.platform[:3] == 'win':
389
354
  # Remove extension from Windows file names
390
- editor = os.path.splitext(editor)[0]
355
+ editor = os.path.splitext(possible_editor)[0]
356
+ else:
357
+ editor = possible_editor
391
358
  break
392
359
  else:
393
360
  editor = None
@@ -395,7 +362,7 @@ def find_editor() -> Optional[str]:
395
362
  return editor
396
363
 
397
364
 
398
- def files_from_glob_pattern(pattern: str, access: int = os.F_OK) -> List[str]:
365
+ def files_from_glob_pattern(pattern: str, access: int = os.F_OK) -> list[str]:
399
366
  """Return a list of file paths based on a glob pattern.
400
367
 
401
368
  Only files are returned, not directories, and optionally only files for which the user has a specified access to.
@@ -407,7 +374,7 @@ def files_from_glob_pattern(pattern: str, access: int = os.F_OK) -> List[str]:
407
374
  return [f for f in glob.glob(pattern) if os.path.isfile(f) and os.access(f, access)]
408
375
 
409
376
 
410
- def files_from_glob_patterns(patterns: List[str], access: int = os.F_OK) -> List[str]:
377
+ def files_from_glob_patterns(patterns: list[str], access: int = os.F_OK) -> list[str]:
411
378
  """Return a list of file paths based on a list of glob patterns.
412
379
 
413
380
  Only files are returned, not directories, and optionally only files for which the user has a specified access to.
@@ -423,8 +390,8 @@ def files_from_glob_patterns(patterns: List[str], access: int = os.F_OK) -> List
423
390
  return files
424
391
 
425
392
 
426
- def get_exes_in_path(starts_with: str) -> List[str]:
427
- """Returns names of executables in a user's path
393
+ def get_exes_in_path(starts_with: str) -> list[str]:
394
+ """Return names of executables in a user's path.
428
395
 
429
396
  :param starts_with: what the exes should start with. leave blank for all exes in path.
430
397
  :return: a list of matching exe names
@@ -437,10 +404,7 @@ def get_exes_in_path(starts_with: str) -> List[str]:
437
404
 
438
405
  # Get a list of every directory in the PATH environment variable and ignore symbolic links
439
406
  env_path = os.getenv('PATH')
440
- if env_path is None:
441
- paths = []
442
- else:
443
- paths = [p for p in env_path.split(os.path.pathsep) if not os.path.islink(p)]
407
+ paths = [] if env_path is None else [p for p in env_path.split(os.path.pathsep) if not os.path.islink(p)]
444
408
 
445
409
  # Use a set to store exe names since there can be duplicates
446
410
  exes_set = set()
@@ -457,8 +421,8 @@ def get_exes_in_path(starts_with: str) -> List[str]:
457
421
 
458
422
 
459
423
  class StdSim:
460
- """
461
- Class to simulate behavior of sys.stdout or sys.stderr.
424
+ """Class to simulate behavior of sys.stdout or sys.stderr.
425
+
462
426
  Stores contents in internal buffer and optionally echos to the inner stream it is simulating.
463
427
  """
464
428
 
@@ -470,8 +434,7 @@ class StdSim:
470
434
  encoding: str = 'utf-8',
471
435
  errors: str = 'replace',
472
436
  ) -> None:
473
- """
474
- StdSim Initializer
437
+ """StdSim Initializer.
475
438
 
476
439
  :param inner_stream: the wrapped stream. Should be a TextIO or StdSim instance.
477
440
  :param echo: if True, then all input will be echoed to inner_stream
@@ -486,8 +449,7 @@ class StdSim:
486
449
  self.buffer = ByteBuf(self)
487
450
 
488
451
  def write(self, s: str) -> None:
489
- """
490
- Add str to internal bytes buffer and if echo is True, echo contents to inner stream
452
+ """Add str to internal bytes buffer and if echo is True, echo contents to inner stream.
491
453
 
492
454
  :param s: String to write to the stream
493
455
  """
@@ -500,16 +462,15 @@ class StdSim:
500
462
  self.inner_stream.write(s)
501
463
 
502
464
  def getvalue(self) -> str:
503
- """Get the internal contents as a str"""
465
+ """Get the internal contents as a str."""
504
466
  return self.buffer.byte_buf.decode(encoding=self.encoding, errors=self.errors)
505
467
 
506
468
  def getbytes(self) -> bytes:
507
- """Get the internal contents as bytes"""
469
+ """Get the internal contents as bytes."""
508
470
  return bytes(self.buffer.byte_buf)
509
471
 
510
472
  def read(self, size: Optional[int] = -1) -> str:
511
- """
512
- Read from the internal contents as a str and then clear them out
473
+ """Read from the internal contents as a str and then clear them out.
513
474
 
514
475
  :param size: Number of bytes to read from the stream
515
476
  """
@@ -523,27 +484,26 @@ class StdSim:
523
484
  return result
524
485
 
525
486
  def readbytes(self) -> bytes:
526
- """Read from the internal contents as bytes and then clear them out"""
487
+ """Read from the internal contents as bytes and then clear them out."""
527
488
  result = self.getbytes()
528
489
  self.clear()
529
490
  return result
530
491
 
531
492
  def clear(self) -> None:
532
- """Clear the internal contents"""
493
+ """Clear the internal contents."""
533
494
  self.buffer.byte_buf.clear()
534
495
 
535
496
  def isatty(self) -> bool:
536
497
  """StdSim only considered an interactive stream if `echo` is True and `inner_stream` is a tty."""
537
498
  if self.echo:
538
499
  return self.inner_stream.isatty()
539
- else:
540
- return False
500
+ return False
541
501
 
542
502
  @property
543
503
  def line_buffering(self) -> bool:
544
- """
545
- Handle when the inner stream doesn't have a line_buffering attribute which is the case
546
- when running unit tests because pytest sets stdout to a pytest EncodedFile object.
504
+ """Handle when the inner stream doesn't have a line_buffering attribute.
505
+
506
+ Which is the case when running unit tests because pytest sets stdout to a pytest EncodedFile object.
547
507
  """
548
508
  try:
549
509
  return bool(self.inner_stream.line_buffering)
@@ -551,21 +511,20 @@ class StdSim:
551
511
  return False
552
512
 
553
513
  def __getattr__(self, item: str) -> Any:
514
+ """When an attribute lookup fails to find the attribute in the usual places, this special method is called."""
554
515
  if item in self.__dict__:
555
516
  return self.__dict__[item]
556
- else:
557
- return getattr(self.inner_stream, item)
517
+ return getattr(self.inner_stream, item)
558
518
 
559
519
 
560
520
  class ByteBuf:
561
- """
562
- Used by StdSim to write binary data and stores the actual bytes written
563
- """
521
+ """Used by StdSim to write binary data and stores the actual bytes written."""
564
522
 
565
523
  # Used to know when to flush the StdSim
566
- NEWLINES = [b'\n', b'\r']
524
+ NEWLINES = (b'\n', b'\r')
567
525
 
568
526
  def __init__(self, std_sim_instance: StdSim) -> None:
527
+ """Initialize the ByteBuf instance."""
569
528
  self.byte_buf = bytearray()
570
529
  self.std_sim_instance = std_sim_instance
571
530
 
@@ -582,23 +541,22 @@ class ByteBuf:
582
541
  # and the bytes being written contain a new line character. This is helpful when StdSim
583
542
  # is being used to capture output of a shell command because it causes the output to print
584
543
  # to the screen more often than if we waited for the stream to flush its buffer.
585
- if self.std_sim_instance.line_buffering:
586
- if any(newline in b for newline in ByteBuf.NEWLINES):
587
- self.std_sim_instance.flush()
544
+ if self.std_sim_instance.line_buffering and any(newline in b for newline in ByteBuf.NEWLINES):
545
+ self.std_sim_instance.flush()
588
546
 
589
547
 
590
548
  class ProcReader:
591
- """
592
- Used to capture stdout and stderr from a Popen process if any of those were set to subprocess.PIPE.
549
+ """Used to capture stdout and stderr from a Popen process if any of those were set to subprocess.PIPE.
550
+
593
551
  If neither are pipes, then the process will run normally and no output will be captured.
594
552
  """
595
553
 
596
554
  def __init__(self, proc: PopenTextIO, stdout: Union[StdSim, TextIO], stderr: Union[StdSim, TextIO]) -> None:
597
- """
598
- ProcReader initializer
555
+ """ProcReader initializer.
556
+
599
557
  :param proc: the Popen process being read from
600
558
  :param stdout: the stream to write captured stdout
601
- :param stderr: the stream to write captured stderr
559
+ :param stderr: the stream to write captured stderr.
602
560
  """
603
561
  self._proc = proc
604
562
  self._stdout = stdout
@@ -615,7 +573,7 @@ class ProcReader:
615
573
  self._err_thread.start()
616
574
 
617
575
  def send_sigint(self) -> None:
618
- """Send a SIGINT to the process similar to if <Ctrl>+C were pressed"""
576
+ """Send a SIGINT to the process similar to if <Ctrl>+C were pressed."""
619
577
  import signal
620
578
 
621
579
  if sys.platform.startswith('win'):
@@ -632,11 +590,11 @@ class ProcReader:
632
590
  return
633
591
 
634
592
  def terminate(self) -> None:
635
- """Terminate the process"""
593
+ """Terminate the process."""
636
594
  self._proc.terminate()
637
595
 
638
596
  def wait(self) -> None:
639
- """Wait for the process to finish"""
597
+ """Wait for the process to finish."""
640
598
  if self._out_thread.is_alive():
641
599
  self._out_thread.join()
642
600
  if self._err_thread.is_alive():
@@ -652,8 +610,8 @@ class ProcReader:
652
610
  self._write_bytes(self._stderr, err)
653
611
 
654
612
  def _reader_thread_func(self, read_stdout: bool) -> None:
655
- """
656
- Thread function that reads a stream from the process
613
+ """Thread function that reads a stream from the process.
614
+
657
615
  :param read_stdout: if True, then this thread deals with stdout. Otherwise it deals with stderr.
658
616
  """
659
617
  if read_stdout:
@@ -664,7 +622,8 @@ class ProcReader:
664
622
  write_stream = self._stderr
665
623
 
666
624
  # The thread should have been started only if this stream was a pipe
667
- assert read_stream is not None
625
+ if read_stream is None:
626
+ raise ValueError("read_stream is None")
668
627
 
669
628
  # Run until process completes
670
629
  while self._proc.poll() is None:
@@ -675,19 +634,17 @@ class ProcReader:
675
634
 
676
635
  @staticmethod
677
636
  def _write_bytes(stream: Union[StdSim, TextIO], to_write: Union[bytes, str]) -> None:
678
- """
679
- Write bytes to a stream
637
+ """Write bytes to a stream.
638
+
680
639
  :param stream: the stream being written to
681
- :param to_write: the bytes being written
640
+ :param to_write: the bytes being written.
682
641
  """
683
642
  if isinstance(to_write, str):
684
643
  to_write = to_write.encode()
685
644
 
686
- try:
645
+ # BrokenPipeError can occur if output is being piped to a process that closed
646
+ with contextlib.suppress(BrokenPipeError):
687
647
  stream.buffer.write(to_write)
688
- except BrokenPipeError:
689
- # This occurs if output is being piped to a process that closed
690
- pass
691
648
 
692
649
 
693
650
  class ContextFlag:
@@ -699,24 +656,29 @@ class ContextFlag:
699
656
  """
700
657
 
701
658
  def __init__(self) -> None:
702
- # When this flag has a positive value, it is considered set.
703
- # When it is 0, it is not set. It should never go below 0.
659
+ """When this flag has a positive value, it is considered set. When it is 0, it is not set.
660
+
661
+ It should never go below 0.
662
+ """
704
663
  self.__count = 0
705
664
 
706
665
  def __bool__(self) -> bool:
666
+ """Define the truth value of an object when it is used in a boolean context."""
707
667
  return self.__count > 0
708
668
 
709
669
  def __enter__(self) -> None:
670
+ """When a with block is entered, the __enter__ method of the context manager is called."""
710
671
  self.__count += 1
711
672
 
712
- def __exit__(self, *args: Any) -> None:
673
+ def __exit__(self, *args: object) -> None:
674
+ """When the execution flow exits a with statement block this is called, regardless of whether an exception occurred."""
713
675
  self.__count -= 1
714
676
  if self.__count < 0:
715
677
  raise ValueError("count has gone below 0")
716
678
 
717
679
 
718
680
  class RedirectionSavedState:
719
- """Created by each command to store information required to restore state after redirection"""
681
+ """Created by each command to store information required to restore state after redirection."""
720
682
 
721
683
  def __init__(
722
684
  self,
@@ -725,12 +687,12 @@ class RedirectionSavedState:
725
687
  pipe_proc_reader: Optional[ProcReader],
726
688
  saved_redirecting: bool,
727
689
  ) -> None:
728
- """
729
- RedirectionSavedState initializer
690
+ """RedirectionSavedState initializer.
691
+
730
692
  :param self_stdout: saved value of Cmd.stdout
731
693
  :param sys_stdout: saved value of sys.stdout
732
694
  :param pipe_proc_reader: saved value of Cmd._cur_pipe_proc_reader
733
- :param saved_redirecting: saved value of Cmd._redirecting
695
+ :param saved_redirecting: saved value of Cmd._redirecting.
734
696
  """
735
697
  # Tells if command is redirecting
736
698
  self.redirecting = False
@@ -744,10 +706,10 @@ class RedirectionSavedState:
744
706
  self.saved_redirecting = saved_redirecting
745
707
 
746
708
 
747
- def _remove_overridden_styles(styles_to_parse: List[str]) -> List[str]:
748
- """
749
- Utility function for align_text() / truncate_line() which filters a style list down
750
- to only those which would still be in effect if all were processed in order.
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().
751
713
 
752
714
  This is mainly used to reduce how many style strings are stored in memory when
753
715
  building large multiline strings with ANSI styles. We only need to carry over
@@ -761,11 +723,11 @@ def _remove_overridden_styles(styles_to_parse: List[str]) -> List[str]:
761
723
  )
762
724
 
763
725
  class StyleState:
764
- """Keeps track of what text styles are enabled"""
726
+ """Keeps track of what text styles are enabled."""
765
727
 
766
728
  def __init__(self) -> None:
767
729
  # Contains styles still in effect, keyed by their index in styles_to_parse
768
- self.style_dict: Dict[int, str] = dict()
730
+ self.style_dict: dict[int, str] = {}
769
731
 
770
732
  # Indexes into style_dict
771
733
  self.reset_all: Optional[int] = None
@@ -826,7 +788,7 @@ def _remove_overridden_styles(styles_to_parse: List[str]) -> List[str]:
826
788
 
827
789
 
828
790
  class TextAlignment(Enum):
829
- """Horizontal text alignment"""
791
+ """Horizontal text alignment."""
830
792
 
831
793
  LEFT = 1
832
794
  CENTER = 2
@@ -842,8 +804,8 @@ def align_text(
842
804
  tab_width: int = 4,
843
805
  truncate: bool = False,
844
806
  ) -> str:
845
- """
846
- Align text for display within a given width. Supports characters with display widths greater than 1.
807
+ """Align text for display within a given width. Supports characters with display widths greater than 1.
808
+
847
809
  ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned
848
810
  independently.
849
811
 
@@ -893,24 +855,21 @@ def align_text(
893
855
  # fill characters. Instead of repeating the style characters for each fill character, we'll wrap each sequence.
894
856
  fill_char_style_begin, fill_char_style_end = fill_char.split(stripped_fill_char)
895
857
 
896
- if text:
897
- lines = text.splitlines()
898
- else:
899
- lines = ['']
858
+ lines = text.splitlines() if text else ['']
900
859
 
901
860
  text_buf = io.StringIO()
902
861
 
903
862
  # ANSI style sequences that may affect subsequent lines will be cancelled by the fill_char's style.
904
863
  # To avoid this, we save styles which are still in effect so we can restore them when beginning the next line.
905
864
  # This also allows lines to be used independently and still have their style. TableCreator does this.
906
- previous_styles: List[str] = []
865
+ previous_styles: list[str] = []
907
866
 
908
867
  for index, line in enumerate(lines):
909
868
  if index > 0:
910
869
  text_buf.write('\n')
911
870
 
912
871
  if truncate:
913
- line = truncate_line(line, width)
872
+ line = truncate_line(line, width) # noqa: PLW2901
914
873
 
915
874
  line_width = ansi.style_aware_wcswidth(line)
916
875
  if line_width == -1:
@@ -920,12 +879,8 @@ def align_text(
920
879
  line_styles = list(get_styles_dict(line).values())
921
880
 
922
881
  # Calculate how wide each side of filling needs to be
923
- if line_width >= width:
924
- # Don't return here even though the line needs no fill chars.
925
- # There may be styles sequences to restore.
926
- total_fill_width = 0
927
- else:
928
- total_fill_width = width - line_width
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
929
884
 
930
885
  if alignment == TextAlignment.LEFT:
931
886
  left_fill_width = 0
@@ -969,8 +924,8 @@ def align_text(
969
924
  def align_left(
970
925
  text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4, truncate: bool = False
971
926
  ) -> str:
972
- """
973
- Left align text for display within a given width. Supports characters with display widths greater than 1.
927
+ """Left align text for display within a given width. Supports characters with display widths greater than 1.
928
+
974
929
  ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned
975
930
  independently.
976
931
 
@@ -992,8 +947,8 @@ def align_left(
992
947
  def align_center(
993
948
  text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4, truncate: bool = False
994
949
  ) -> str:
995
- """
996
- Center text for display within a given width. Supports characters with display widths greater than 1.
950
+ """Center text for display within a given width. Supports characters with display widths greater than 1.
951
+
997
952
  ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned
998
953
  independently.
999
954
 
@@ -1015,8 +970,8 @@ def align_center(
1015
970
  def align_right(
1016
971
  text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4, truncate: bool = False
1017
972
  ) -> str:
1018
- """
1019
- Right align text for display within a given width. Supports characters with display widths greater than 1.
973
+ """Right align text for display within a given width. Supports characters with display widths greater than 1.
974
+
1020
975
  ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned
1021
976
  independently.
1022
977
 
@@ -1036,10 +991,10 @@ def align_right(
1036
991
 
1037
992
 
1038
993
  def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str:
1039
- """
1040
- Truncate a single line to fit within a given display width. Any portion of the string that is truncated
1041
- is replaced by a '…' character. Supports characters with display widths greater than 1. ANSI style sequences
1042
- do not count toward the display width.
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.
1043
998
 
1044
999
  If there are ANSI style sequences in the string after where truncation occurs, this function will append them
1045
1000
  to the returned string.
@@ -1114,9 +1069,8 @@ def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str:
1114
1069
  return truncated_buf.getvalue()
1115
1070
 
1116
1071
 
1117
- def get_styles_dict(text: str) -> Dict[int, str]:
1118
- """
1119
- Return an OrderedDict containing all ANSI style sequences found in a string
1072
+ def get_styles_dict(text: str) -> dict[int, str]:
1073
+ """Return an OrderedDict containing all ANSI style sequences found in a string.
1120
1074
 
1121
1075
  The structure of the dictionary is:
1122
1076
  key: index where sequences begins
@@ -1153,7 +1107,6 @@ def categorize(func: Union[Callable[..., Any], Iterable[Callable[..., Any]]], ca
1153
1107
  :param category: category to put it in
1154
1108
 
1155
1109
  Example:
1156
-
1157
1110
  ```py
1158
1111
  import cmd2
1159
1112
  class MyApp(cmd2.Cmd):
@@ -1164,20 +1117,19 @@ def categorize(func: Union[Callable[..., Any], Iterable[Callable[..., Any]]], ca
1164
1117
  ```
1165
1118
 
1166
1119
  For an alternative approach to categorizing commands using a decorator, see [cmd2.decorators.with_category][]
1120
+
1167
1121
  """
1168
1122
  if isinstance(func, Iterable):
1169
1123
  for item in func:
1170
1124
  setattr(item, constants.CMD_ATTR_HELP_CATEGORY, category)
1125
+ elif inspect.ismethod(func):
1126
+ setattr(func.__func__, constants.CMD_ATTR_HELP_CATEGORY, category) # type: ignore[attr-defined]
1171
1127
  else:
1172
- if inspect.ismethod(func):
1173
- setattr(func.__func__, constants.CMD_ATTR_HELP_CATEGORY, category) # type: ignore[attr-defined]
1174
- else:
1175
- setattr(func, constants.CMD_ATTR_HELP_CATEGORY, category)
1128
+ setattr(func, constants.CMD_ATTR_HELP_CATEGORY, category)
1176
1129
 
1177
1130
 
1178
- def get_defining_class(meth: Callable[..., Any]) -> Optional[Type[Any]]:
1179
- """
1180
- Attempts to resolve the class that defined a method.
1131
+ def get_defining_class(meth: Callable[..., Any]) -> Optional[type[Any]]:
1132
+ """Attempt to resolve the class that defined a method.
1181
1133
 
1182
1134
  Inspired by implementation published here:
1183
1135
  https://stackoverflow.com/a/25959545/1956611
@@ -1188,7 +1140,7 @@ def get_defining_class(meth: Callable[..., Any]) -> Optional[Type[Any]]:
1188
1140
  if isinstance(meth, functools.partial):
1189
1141
  return get_defining_class(meth.func)
1190
1142
  if inspect.ismethod(meth) or (
1191
- inspect.isbuiltin(meth) and getattr(meth, '__self__') is not None and getattr(meth.__self__, '__class__')
1143
+ inspect.isbuiltin(meth) and hasattr(meth, '__self__') and hasattr(meth.__self__, '__class__')
1192
1144
  ):
1193
1145
  for cls in inspect.getmro(meth.__self__.__class__): # type: ignore[attr-defined]
1194
1146
  if meth.__name__ in cls.__dict__:
@@ -1202,7 +1154,7 @@ def get_defining_class(meth: Callable[..., Any]) -> Optional[Type[Any]]:
1202
1154
 
1203
1155
 
1204
1156
  class CompletionMode(Enum):
1205
- """Enum for what type of tab completion to perform in cmd2.Cmd.read_input()"""
1157
+ """Enum for what type of tab completion to perform in cmd2.Cmd.read_input()."""
1206
1158
 
1207
1159
  # Tab completion will be disabled during read_input() call
1208
1160
  # Use of custom up-arrow history supported
@@ -1220,11 +1172,10 @@ class CompletionMode(Enum):
1220
1172
 
1221
1173
 
1222
1174
  class CustomCompletionSettings:
1223
- """Used by cmd2.Cmd.complete() to tab complete strings other than command arguments"""
1175
+ """Used by cmd2.Cmd.complete() to tab complete strings other than command arguments."""
1224
1176
 
1225
1177
  def __init__(self, parser: argparse.ArgumentParser, *, preserve_quotes: bool = False) -> None:
1226
- """
1227
- Initializer
1178
+ """CustomCompletionSettings initializer.
1228
1179
 
1229
1180
  :param parser: arg parser defining format of string being tab completed
1230
1181
  :param preserve_quotes: if True, then quoted tokens will keep their quotes when processed by
@@ -1238,8 +1189,7 @@ class CustomCompletionSettings:
1238
1189
 
1239
1190
 
1240
1191
  def strip_doc_annotations(doc: str) -> str:
1241
- """
1242
- Strip annotations from a docstring leaving only the text description
1192
+ """Strip annotations from a docstring leaving only the text description.
1243
1193
 
1244
1194
  :param doc: documentation string
1245
1195
  """
@@ -1264,8 +1214,10 @@ def strip_doc_annotations(doc: str) -> str:
1264
1214
 
1265
1215
 
1266
1216
  def similarity_function(s1: str, s2: str) -> float:
1267
- # The ratio from s1,s2 may be different to s2,s1. We keep the max.
1268
- # See https://docs.python.org/3/library/difflib.html#difflib.SequenceMatcher.ratio
1217
+ """Ratio from s1,s2 may be different to s2,s1. We keep the max.
1218
+
1219
+ See https://docs.python.org/3/library/difflib.html#difflib.SequenceMatcher.ratio
1220
+ """
1269
1221
  return max(SequenceMatcher(None, s1, s2).ratio(), SequenceMatcher(None, s2, s1).ratio())
1270
1222
 
1271
1223
 
@@ -1275,15 +1227,13 @@ MIN_SIMIL_TO_CONSIDER = 0.7
1275
1227
  def suggest_similar(
1276
1228
  requested_command: str, options: Iterable[str], similarity_function_to_use: Optional[Callable[[str, str], float]] = None
1277
1229
  ) -> Optional[str]:
1278
- """
1279
- Given a requested command and an iterable of possible options returns the most similar (if any is similar)
1230
+ """Given a requested command and an iterable of possible options returns the most similar (if any is similar).
1280
1231
 
1281
1232
  :param requested_command: The command entered by the user
1282
1233
  :param options: The list of available commands to search for the most similar
1283
1234
  :param similarity_function_to_use: An optional callable to use to compare commands
1284
1235
  :return: The most similar command or None if no one is similar
1285
1236
  """
1286
-
1287
1237
  proposed_command = None
1288
1238
  best_simil = MIN_SIMIL_TO_CONSIDER
1289
1239
  requested_command_to_compare = requested_command.lower()
@@ -1294,3 +1244,27 @@ def suggest_similar(
1294
1244
  best_simil = simil
1295
1245
  proposed_command = each
1296
1246
  return proposed_command
1247
+
1248
+
1249
+ 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.
1254
+
1255
+ TODO: Once cmd2 only supports Python 3.10+, change to use inspect.get_annotations(eval_str=True)
1256
+
1257
+ :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
1260
+ """
1261
+ try:
1262
+ type_hints = get_type_hints(func_or_method) # Get dictionary of type hints
1263
+ except TypeError as exc:
1264
+ raise ValueError("Argument passed to get_types should be a function or method") from exc
1265
+ ret_ann = type_hints.pop('return', None) # Pop off the return annotation if it exists
1266
+ if inspect.ismethod(func_or_method):
1267
+ 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
+ return type_hints, ret_ann