cmd2 2.4.3__py3-none-any.whl → 2.5.0__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.
@@ -2,6 +2,7 @@
2
2
  """
3
3
  Supports the definition of commands in separate classes to be composed into cmd2.Cmd
4
4
  """
5
+
5
6
  from typing import (
6
7
  TYPE_CHECKING,
7
8
  Callable,
@@ -165,3 +166,12 @@ class CommandSet(object):
165
166
  del self._settables[name]
166
167
  except KeyError:
167
168
  raise KeyError(name + " is not a settable parameter")
169
+
170
+ def sigint_handler(self) -> bool:
171
+ """
172
+ Handle a SIGINT that occurred for a command in this CommandSet.
173
+
174
+ :return: True if this completes the interrupt handling and no KeyboardInterrupt will be raised.
175
+ False to raise a KeyboardInterrupt.
176
+ """
177
+ return False
cmd2/decorators.py CHANGED
@@ -1,5 +1,6 @@
1
1
  # coding=utf-8
2
2
  """Decorators for ``cmd2`` commands"""
3
+
3
4
  import argparse
4
5
  from typing import (
5
6
  TYPE_CHECKING,
@@ -10,7 +11,9 @@ from typing import (
10
11
  Optional,
11
12
  Sequence,
12
13
  Tuple,
14
+ TypeVar,
13
15
  Union,
16
+ overload,
14
17
  )
15
18
 
16
19
  from . import (
@@ -29,9 +32,6 @@ from .exceptions import (
29
32
  from .parsing import (
30
33
  Statement,
31
34
  )
32
- from .utils import (
33
- strip_doc_annotations,
34
- )
35
35
 
36
36
  if TYPE_CHECKING: # pragma: no cover
37
37
  import cmd2
@@ -72,7 +72,10 @@ def with_category(category: str) -> Callable[[CommandFunc], CommandFunc]:
72
72
  ##########################
73
73
 
74
74
 
75
- RawCommandFuncOptionalBoolReturn = Callable[[Union[CommandSet, 'cmd2.Cmd'], Union[Statement, str]], Optional[bool]]
75
+ CommandParent = TypeVar('CommandParent', bound=Union['cmd2.Cmd', CommandSet])
76
+
77
+
78
+ RawCommandFuncOptionalBoolReturn = Callable[[CommandParent, Union[Statement, str]], Optional[bool]]
76
79
 
77
80
 
78
81
  def _parse_positionals(args: Tuple[Any, ...]) -> Tuple['cmd2.Cmd', Union[Statement, str]]:
@@ -116,38 +119,36 @@ def _arg_swap(args: Union[Sequence[Any]], search_arg: Any, *replace_arg: Any) ->
116
119
 
117
120
  #: Function signature for an Command Function that accepts a pre-processed argument list from user input
118
121
  #: and optionally returns a boolean
119
- ArgListCommandFuncOptionalBoolReturn = Union[
120
- Callable[['cmd2.Cmd', List[str]], Optional[bool]],
121
- Callable[[CommandSet, List[str]], Optional[bool]],
122
- ]
122
+ ArgListCommandFuncOptionalBoolReturn = Callable[[CommandParent, List[str]], Optional[bool]]
123
123
  #: Function signature for an Command Function that accepts a pre-processed argument list from user input
124
124
  #: and returns a boolean
125
- ArgListCommandFuncBoolReturn = Union[
126
- Callable[['cmd2.Cmd', List[str]], bool],
127
- Callable[[CommandSet, List[str]], bool],
128
- ]
125
+ ArgListCommandFuncBoolReturn = Callable[[CommandParent, List[str]], bool]
129
126
  #: Function signature for an Command Function that accepts a pre-processed argument list from user input
130
127
  #: and returns Nothing
131
- ArgListCommandFuncNoneReturn = Union[
132
- Callable[['cmd2.Cmd', List[str]], None],
133
- Callable[[CommandSet, List[str]], None],
134
- ]
128
+ ArgListCommandFuncNoneReturn = Callable[[CommandParent, List[str]], None]
135
129
 
136
130
  #: Aggregate of all accepted function signatures for Command Functions that accept a pre-processed argument list
137
- ArgListCommandFunc = Union[ArgListCommandFuncOptionalBoolReturn, ArgListCommandFuncBoolReturn, ArgListCommandFuncNoneReturn]
131
+ ArgListCommandFunc = Union[
132
+ ArgListCommandFuncOptionalBoolReturn[CommandParent],
133
+ ArgListCommandFuncBoolReturn[CommandParent],
134
+ ArgListCommandFuncNoneReturn[CommandParent],
135
+ ]
138
136
 
139
137
 
140
138
  def with_argument_list(
141
- func_arg: Optional[ArgListCommandFunc] = None,
139
+ func_arg: Optional[ArgListCommandFunc[CommandParent]] = None,
142
140
  *,
143
141
  preserve_quotes: bool = False,
144
- ) -> Union[RawCommandFuncOptionalBoolReturn, Callable[[ArgListCommandFunc], RawCommandFuncOptionalBoolReturn]]:
142
+ ) -> Union[
143
+ RawCommandFuncOptionalBoolReturn[CommandParent],
144
+ Callable[[ArgListCommandFunc[CommandParent]], RawCommandFuncOptionalBoolReturn[CommandParent]],
145
+ ]:
145
146
  """
146
147
  A decorator to alter the arguments passed to a ``do_*`` method. Default
147
148
  passes a string of whatever the user typed. With this decorator, the
148
149
  decorated method will receive a list of arguments parsed from user input.
149
150
 
150
- :param func_arg: Single-element positional argument list containing ``do_*`` method
151
+ :param func_arg: Single-element positional argument list containing ``doi_*`` method
151
152
  this decorator is wrapping
152
153
  :param preserve_quotes: if ``True``, then argument quotes will not be stripped
153
154
  :return: function that gets passed a list of argument strings
@@ -161,7 +162,7 @@ def with_argument_list(
161
162
  """
162
163
  import functools
163
164
 
164
- def arg_decorator(func: ArgListCommandFunc) -> RawCommandFuncOptionalBoolReturn:
165
+ def arg_decorator(func: ArgListCommandFunc[CommandParent]) -> RawCommandFuncOptionalBoolReturn[CommandParent]:
165
166
  """
166
167
  Decorator function that ingests an Argument List function and returns a raw command function.
167
168
  The returned function will process the raw input into an argument list to be passed to the wrapped function.
@@ -191,14 +192,11 @@ def with_argument_list(
191
192
  return cmd_wrapper
192
193
 
193
194
  if callable(func_arg):
194
- # noinspection PyTypeChecker
195
195
  return arg_decorator(func_arg)
196
196
  else:
197
- # noinspection PyTypeChecker
198
197
  return arg_decorator
199
198
 
200
199
 
201
- # noinspection PyProtectedMember
202
200
  def _set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None:
203
201
  """
204
202
  Recursively set prog attribute of a parser and all of its subparsers so that the root command
@@ -209,6 +207,7 @@ def _set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None:
209
207
  """
210
208
  # Set the prog value for this parser
211
209
  parser.prog = prog
210
+ req_args: List[str] = []
212
211
 
213
212
  # Set the prog value for the parser's subcommands
214
213
  for action in parser._actions:
@@ -233,52 +232,70 @@ def _set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None:
233
232
  if subcmd_parser in processed_parsers:
234
233
  continue
235
234
 
236
- subcmd_prog = parser.prog + ' ' + subcmd_name
235
+ subcmd_prog = parser.prog
236
+ if req_args:
237
+ subcmd_prog += " " + " ".join(req_args)
238
+ subcmd_prog += " " + subcmd_name
237
239
  _set_parser_prog(subcmd_parser, subcmd_prog)
238
240
  processed_parsers.append(subcmd_parser)
239
241
 
240
242
  # We can break since argparse only allows 1 group of subcommands per level
241
243
  break
242
244
 
245
+ # Need to save required args so they can be prepended to the subcommand usage
246
+ elif action.required:
247
+ req_args.append(action.dest)
248
+
243
249
 
244
250
  #: Function signature for a Command Function that uses an argparse.ArgumentParser to process user input
245
251
  #: and optionally returns a boolean
246
- ArgparseCommandFuncOptionalBoolReturn = Union[
247
- Callable[['cmd2.Cmd', argparse.Namespace], Optional[bool]],
248
- Callable[[CommandSet, argparse.Namespace], Optional[bool]],
249
- ]
252
+ ArgparseCommandFuncOptionalBoolReturn = Callable[[CommandParent, argparse.Namespace], Optional[bool]]
250
253
  #: Function signature for a Command Function that uses an argparse.ArgumentParser to process user input
251
254
  #: and returns a boolean
252
- ArgparseCommandFuncBoolReturn = Union[
253
- Callable[['cmd2.Cmd', argparse.Namespace], bool],
254
- Callable[[CommandSet, argparse.Namespace], bool],
255
- ]
255
+ ArgparseCommandFuncBoolReturn = Callable[[CommandParent, argparse.Namespace], bool]
256
256
  #: Function signature for an Command Function that uses an argparse.ArgumentParser to process user input
257
257
  #: and returns nothing
258
- ArgparseCommandFuncNoneReturn = Union[
259
- Callable[['cmd2.Cmd', argparse.Namespace], None],
260
- Callable[[CommandSet, argparse.Namespace], None],
261
- ]
258
+ ArgparseCommandFuncNoneReturn = Callable[[CommandParent, argparse.Namespace], None]
262
259
 
263
260
  #: Aggregate of all accepted function signatures for an argparse Command Function
264
261
  ArgparseCommandFunc = Union[
265
- ArgparseCommandFuncOptionalBoolReturn,
266
- ArgparseCommandFuncBoolReturn,
267
- ArgparseCommandFuncNoneReturn,
262
+ ArgparseCommandFuncOptionalBoolReturn[CommandParent],
263
+ ArgparseCommandFuncBoolReturn[CommandParent],
264
+ ArgparseCommandFuncNoneReturn[CommandParent],
268
265
  ]
269
266
 
270
267
 
268
+ @overload
271
269
  def with_argparser(
272
270
  parser: argparse.ArgumentParser,
273
271
  *,
274
272
  ns_provider: Optional[Callable[..., argparse.Namespace]] = None,
275
273
  preserve_quotes: bool = False,
276
274
  with_unknown_args: bool = False,
277
- ) -> Callable[[ArgparseCommandFunc], RawCommandFuncOptionalBoolReturn]:
275
+ ) -> Callable[[ArgparseCommandFunc[CommandParent]], RawCommandFuncOptionalBoolReturn[CommandParent]]: ... # pragma: no cover
276
+
277
+
278
+ @overload
279
+ def with_argparser(
280
+ parser: Callable[[], argparse.ArgumentParser],
281
+ *,
282
+ ns_provider: Optional[Callable[..., argparse.Namespace]] = None,
283
+ preserve_quotes: bool = False,
284
+ with_unknown_args: bool = False,
285
+ ) -> Callable[[ArgparseCommandFunc[CommandParent]], RawCommandFuncOptionalBoolReturn[CommandParent]]: ... # pragma: no cover
286
+
287
+
288
+ def with_argparser(
289
+ parser: Union[argparse.ArgumentParser, Callable[[], argparse.ArgumentParser]],
290
+ *,
291
+ ns_provider: Optional[Callable[..., argparse.Namespace]] = None,
292
+ preserve_quotes: bool = False,
293
+ with_unknown_args: bool = False,
294
+ ) -> Callable[[ArgparseCommandFunc[CommandParent]], RawCommandFuncOptionalBoolReturn[CommandParent]]:
278
295
  """A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments
279
296
  with the given instance of argparse.ArgumentParser.
280
297
 
281
- :param parser: unique instance of ArgumentParser
298
+ :param parser: unique instance of ArgumentParser or a callable that returns an ArgumentParser
282
299
  :param ns_provider: An optional function that accepts a cmd2.Cmd or cmd2.CommandSet object as an argument and returns an
283
300
  argparse.Namespace. This is useful if the Namespace needs to be prepopulated with state data that
284
301
  affects parsing.
@@ -320,7 +337,7 @@ def with_argparser(
320
337
  """
321
338
  import functools
322
339
 
323
- def arg_decorator(func: ArgparseCommandFunc) -> RawCommandFuncOptionalBoolReturn:
340
+ def arg_decorator(func: ArgparseCommandFunc[CommandParent]) -> RawCommandFuncOptionalBoolReturn[CommandParent]:
324
341
  """
325
342
  Decorator function that ingests an Argparse Command Function and returns a raw command function.
326
343
  The returned function will process the raw input into an argparse Namespace to be passed to the wrapped function.
@@ -346,6 +363,10 @@ def with_argparser(
346
363
  statement, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(
347
364
  command_name, statement_arg, preserve_quotes
348
365
  )
366
+ arg_parser = cmd2_app._command_parsers.get(command_name, None)
367
+ if arg_parser is None:
368
+ # This shouldn't be possible to reach
369
+ raise ValueError(f'No argument parser found for {command_name}') # pragma: no cover
349
370
 
350
371
  if ns_provider is None:
351
372
  namespace = None
@@ -359,9 +380,9 @@ def with_argparser(
359
380
  try:
360
381
  new_args: Union[Tuple[argparse.Namespace], Tuple[argparse.Namespace, List[str]]]
361
382
  if with_unknown_args:
362
- new_args = parser.parse_known_args(parsed_arglist, namespace)
383
+ new_args = arg_parser.parse_known_args(parsed_arglist, namespace)
363
384
  else:
364
- new_args = (parser.parse_args(parsed_arglist, namespace),)
385
+ new_args = (arg_parser.parse_args(parsed_arglist, namespace),)
365
386
  ns = new_args[0]
366
387
  except SystemExit:
367
388
  raise Cmd2ArgparseError
@@ -381,16 +402,7 @@ def with_argparser(
381
402
  args_list = _arg_swap(args, statement_arg, *new_args)
382
403
  return func(*args_list, **kwargs) # type: ignore[call-arg]
383
404
 
384
- # argparser defaults the program name to sys.argv[0], but we want it to be the name of our command
385
405
  command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX) :]
386
- _set_parser_prog(parser, command_name)
387
-
388
- # If the description has not been set, then use the method docstring if one exists
389
- if parser.description is None and func.__doc__:
390
- parser.description = strip_doc_annotations(func.__doc__)
391
-
392
- # Set the command's help text as argparser.description (which can be None)
393
- cmd_wrapper.__doc__ = parser.description
394
406
 
395
407
  # Set some custom attributes for this command
396
408
  setattr(cmd_wrapper, constants.CMD_ATTR_ARGPARSER, parser)
@@ -398,10 +410,10 @@ def with_argparser(
398
410
 
399
411
  return cmd_wrapper
400
412
 
401
- # noinspection PyTypeChecker
402
413
  return arg_decorator
403
414
 
404
415
 
416
+ @overload
405
417
  def as_subcommand_to(
406
418
  command: str,
407
419
  subcommand: str,
@@ -409,7 +421,28 @@ def as_subcommand_to(
409
421
  *,
410
422
  help: Optional[str] = None,
411
423
  aliases: Optional[List[str]] = None,
412
- ) -> Callable[[ArgparseCommandFunc], ArgparseCommandFunc]:
424
+ ) -> Callable[[ArgparseCommandFunc[CommandParent]], ArgparseCommandFunc[CommandParent]]: ... # pragma: no cover
425
+
426
+
427
+ @overload
428
+ def as_subcommand_to(
429
+ command: str,
430
+ subcommand: str,
431
+ parser: Callable[[], argparse.ArgumentParser],
432
+ *,
433
+ help: Optional[str] = None,
434
+ aliases: Optional[List[str]] = None,
435
+ ) -> Callable[[ArgparseCommandFunc[CommandParent]], ArgparseCommandFunc[CommandParent]]: ... # pragma: no cover
436
+
437
+
438
+ def as_subcommand_to(
439
+ command: str,
440
+ subcommand: str,
441
+ parser: Union[argparse.ArgumentParser, Callable[[], argparse.ArgumentParser]],
442
+ *,
443
+ help: Optional[str] = None,
444
+ aliases: Optional[List[str]] = None,
445
+ ) -> Callable[[ArgparseCommandFunc[CommandParent]], ArgparseCommandFunc[CommandParent]]:
413
446
  """
414
447
  Tag this method as a subcommand to an existing argparse decorated command.
415
448
 
@@ -423,13 +456,7 @@ def as_subcommand_to(
423
456
  :return: Wrapper function that can receive an argparse.Namespace
424
457
  """
425
458
 
426
- def arg_decorator(func: ArgparseCommandFunc) -> ArgparseCommandFunc:
427
- _set_parser_prog(parser, command + ' ' + subcommand)
428
-
429
- # If the description has not been set, then use the method docstring if one exists
430
- if parser.description is None and func.__doc__:
431
- parser.description = func.__doc__
432
-
459
+ def arg_decorator(func: ArgparseCommandFunc[CommandParent]) -> ArgparseCommandFunc[CommandParent]:
433
460
  # Set some custom attributes for this command
434
461
  setattr(func, constants.SUBCMD_ATTR_COMMAND, command)
435
462
  setattr(func, constants.CMD_ATTR_ARGPARSER, parser)
@@ -446,5 +473,4 @@ def as_subcommand_to(
446
473
 
447
474
  return func
448
475
 
449
- # noinspection PyTypeChecker
450
476
  return arg_decorator
cmd2/exceptions.py CHANGED
@@ -61,7 +61,6 @@ class CompletionError(Exception):
61
61
  """
62
62
  self.apply_style = apply_style
63
63
 
64
- # noinspection PyArgumentList
65
64
  super().__init__(*args)
66
65
 
67
66
 
cmd2/history.py CHANGED
@@ -8,6 +8,9 @@ import re
8
8
  from collections import (
9
9
  OrderedDict,
10
10
  )
11
+ from dataclasses import (
12
+ dataclass,
13
+ )
11
14
  from typing import (
12
15
  Any,
13
16
  Callable,
@@ -19,17 +22,54 @@ from typing import (
19
22
  overload,
20
23
  )
21
24
 
22
- import attr
23
-
24
25
  from . import (
25
26
  utils,
26
27
  )
27
28
  from .parsing import (
28
29
  Statement,
30
+ shlex_split,
29
31
  )
30
32
 
31
33
 
32
- @attr.s(auto_attribs=True, frozen=True)
34
+ def single_line_format(statement: Statement) -> str:
35
+ """
36
+ Format a command line to display on a single line.
37
+
38
+ Spaces and newlines in quotes are preserved so those strings will span multiple lines.
39
+
40
+ :param statement: Statement being formatted.
41
+ :return: formatted command line
42
+ """
43
+ if not statement.raw:
44
+ return ""
45
+
46
+ lines = statement.raw.splitlines()
47
+ formatted_command = lines[0]
48
+
49
+ # Append any remaining lines to the command.
50
+ for line in lines[1:]:
51
+ try:
52
+ shlex_split(formatted_command)
53
+ except ValueError:
54
+ # We are in quotes, so restore the newline.
55
+ separator = "\n"
56
+ else:
57
+ # Don't add a space before line if one already exists or if it begins with the terminator.
58
+ if (
59
+ formatted_command.endswith(" ")
60
+ or line.startswith(" ")
61
+ or (statement.terminator and line.startswith(statement.terminator))
62
+ ):
63
+ separator = ""
64
+ else:
65
+ separator = " "
66
+
67
+ formatted_command += separator + line
68
+
69
+ return formatted_command
70
+
71
+
72
+ @dataclass(frozen=True)
33
73
  class HistoryItem:
34
74
  """Class used to represent one command in the history list"""
35
75
 
@@ -39,10 +79,10 @@ class HistoryItem:
39
79
  # Used in JSON dictionaries
40
80
  _statement_field = 'statement'
41
81
 
42
- statement: Statement = attr.ib(default=None, validator=attr.validators.instance_of(Statement))
82
+ statement: Statement
43
83
 
44
84
  def __str__(self) -> str:
45
- """A convenient human readable representation of the history item"""
85
+ """A convenient human-readable representation of the history item"""
46
86
  return self.statement.raw
47
87
 
48
88
  @property
@@ -84,15 +124,7 @@ class HistoryItem:
84
124
  if expanded:
85
125
  ret_str = self.expanded
86
126
  else:
87
- ret_str = self.raw.rstrip()
88
-
89
- # In non-verbose mode, display raw multiline commands on 1 line
90
- if self.statement.multiline_command:
91
- # This is an approximation and not meant to be a perfect piecing together of lines.
92
- # All newlines will be converted to spaces, including the ones in quoted strings that
93
- # are considered literals. Also if the final line starts with a terminator, then the
94
- # terminator will have an extra space before it in the 1 line version.
95
- ret_str = ret_str.replace('\n', ' ')
127
+ ret_str = single_line_format(self.statement).rstrip()
96
128
 
97
129
  # Display a numbered list if not writing to a script
98
130
  if not script:
@@ -144,7 +176,6 @@ class History(List[HistoryItem]):
144
176
  """Start a new session, thereby setting the next index as the first index in the new session."""
145
177
  self.session_start_index = len(self)
146
178
 
147
- # noinspection PyMethodMayBeStatic
148
179
  def _zero_based_index(self, onebased: Union[int, str]) -> int:
149
180
  """Convert a one-based index to a zero-based index."""
150
181
  result = int(onebased)
@@ -153,12 +184,10 @@ class History(List[HistoryItem]):
153
184
  return result
154
185
 
155
186
  @overload
156
- def append(self, new: HistoryItem) -> None:
157
- ... # pragma: no cover
187
+ def append(self, new: HistoryItem) -> None: ... # pragma: no cover
158
188
 
159
189
  @overload
160
- def append(self, new: Statement) -> None:
161
- ... # pragma: no cover
190
+ def append(self, new: Statement) -> None: ... # pragma: no cover
162
191
 
163
192
  def append(self, new: Union[Statement, HistoryItem]) -> None:
164
193
  """Append a new statement to the end of the History list.
cmd2/parsing.py CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  import re
6
6
  import shlex
7
+ from dataclasses import (
8
+ dataclass,
9
+ field,
10
+ )
7
11
  from typing import (
8
12
  Any,
9
13
  Dict,
@@ -14,8 +18,6 @@ from typing import (
14
18
  Union,
15
19
  )
16
20
 
17
- import attr
18
-
19
21
  from . import (
20
22
  constants,
21
23
  utils,
@@ -36,7 +38,7 @@ def shlex_split(str_to_split: str) -> List[str]:
36
38
  return shlex.split(str_to_split, comments=False, posix=False)
37
39
 
38
40
 
39
- @attr.s(auto_attribs=True, frozen=True)
41
+ @dataclass(frozen=True)
40
42
  class MacroArg:
41
43
  """
42
44
  Information used to replace or unescape arguments in a macro value when the macro is resolved
@@ -45,15 +47,15 @@ class MacroArg:
45
47
  """
46
48
 
47
49
  # The starting index of this argument in the macro value
48
- start_index: int = attr.ib(validator=attr.validators.instance_of(int))
50
+ start_index: int
49
51
 
50
52
  # The number string that appears between the braces
51
53
  # This is a string instead of an int because we support unicode digits and must be able
52
54
  # to reproduce this string later
53
- number_str: str = attr.ib(validator=attr.validators.instance_of(str))
55
+ number_str: str
54
56
 
55
57
  # Tells if this argument is escaped and therefore needs to be unescaped
56
- is_escaped: bool = attr.ib(validator=attr.validators.instance_of(bool))
58
+ is_escaped: bool
57
59
 
58
60
  # Pattern used to find normal argument
59
61
  # Digits surrounded by exactly 1 brace on a side and 1 or more braces on the opposite side
@@ -69,24 +71,24 @@ class MacroArg:
69
71
  digit_pattern = re.compile(r'\d+')
70
72
 
71
73
 
72
- @attr.s(auto_attribs=True, frozen=True)
74
+ @dataclass(frozen=True)
73
75
  class Macro:
74
76
  """Defines a cmd2 macro"""
75
77
 
76
78
  # Name of the macro
77
- name: str = attr.ib(validator=attr.validators.instance_of(str))
79
+ name: str
78
80
 
79
81
  # The string the macro resolves to
80
- value: str = attr.ib(validator=attr.validators.instance_of(str))
82
+ value: str
81
83
 
82
84
  # The minimum number of args the user has to pass to this macro
83
- minimum_arg_count: int = attr.ib(validator=attr.validators.instance_of(int))
85
+ minimum_arg_count: int
84
86
 
85
87
  # Used to fill in argument placeholders in the macro
86
- arg_list: List[MacroArg] = attr.ib(default=attr.Factory(list), validator=attr.validators.instance_of(list))
88
+ arg_list: List[MacroArg] = field(default_factory=list)
87
89
 
88
90
 
89
- @attr.s(auto_attribs=True, frozen=True)
91
+ @dataclass(frozen=True)
90
92
  class Statement(str): # type: ignore[override]
91
93
  """String subclass with additional attributes to store the results of parsing.
92
94
 
@@ -118,34 +120,34 @@ class Statement(str): # type: ignore[override]
118
120
  """
119
121
 
120
122
  # the arguments, but not the command, nor the output redirection clauses.
121
- args: str = attr.ib(default='', validator=attr.validators.instance_of(str))
123
+ args: str = ''
122
124
 
123
125
  # string containing exactly what we input by the user
124
- raw: str = attr.ib(default='', validator=attr.validators.instance_of(str))
126
+ raw: str = ''
125
127
 
126
128
  # the command, i.e. the first whitespace delimited word
127
- command: str = attr.ib(default='', validator=attr.validators.instance_of(str))
129
+ command: str = ''
128
130
 
129
131
  # list of arguments to the command, not including any output redirection or terminators; quoted args remain quoted
130
- arg_list: List[str] = attr.ib(default=attr.Factory(list), validator=attr.validators.instance_of(list))
132
+ arg_list: List[str] = field(default_factory=list)
131
133
 
132
134
  # if the command is a multiline command, the name of the command, otherwise empty
133
- multiline_command: str = attr.ib(default='', validator=attr.validators.instance_of(str))
135
+ multiline_command: str = ''
134
136
 
135
137
  # the character which terminated the multiline command, if there was one
136
- terminator: str = attr.ib(default='', validator=attr.validators.instance_of(str))
138
+ terminator: str = ''
137
139
 
138
140
  # characters appearing after the terminator but before output redirection, if any
139
- suffix: str = attr.ib(default='', validator=attr.validators.instance_of(str))
141
+ suffix: str = ''
140
142
 
141
143
  # if output was piped to a shell command, the shell command as a string
142
- pipe_to: str = attr.ib(default='', validator=attr.validators.instance_of(str))
144
+ pipe_to: str = ''
143
145
 
144
146
  # if output was redirected, the redirection token, i.e. '>>'
145
- output: str = attr.ib(default='', validator=attr.validators.instance_of(str))
147
+ output: str = ''
146
148
 
147
149
  # if output was redirected, the destination file token (quotes preserved)
148
- output_to: str = attr.ib(default='', validator=attr.validators.instance_of(str))
150
+ output_to: str = ''
149
151
 
150
152
  # Used in JSON dictionaries
151
153
  _args_field = 'args'
@@ -156,7 +158,7 @@ class Statement(str): # type: ignore[override]
156
158
  We must override __new__ because we are subclassing `str` which is
157
159
  immutable and takes a different number of arguments as Statement.
158
160
 
159
- NOTE: attrs takes care of initializing other members in the __init__ it
161
+ NOTE: @dataclass takes care of initializing other members in the __init__ it
160
162
  generates.
161
163
  """
162
164
  stmt = super().__new__(cls, value)
@@ -348,7 +350,7 @@ class StatementParser:
348
350
  return False, 'cannot start with the comment character'
349
351
 
350
352
  if not is_subcommand:
351
- for (shortcut, _) in self.shortcuts:
353
+ for shortcut, _ in self.shortcuts:
352
354
  if word.startswith(shortcut):
353
355
  # Build an error string with all shortcuts listed
354
356
  errmsg = 'cannot start with a shortcut: '
@@ -481,7 +483,6 @@ class StatementParser:
481
483
 
482
484
  # Check if output should be piped to a shell command
483
485
  if pipe_index < redir_index and pipe_index < append_index:
484
-
485
486
  # Get the tokens for the pipe command and expand ~ where needed
486
487
  pipe_to_tokens = tokens[pipe_index + 1 :]
487
488
  utils.expand_user_in_tokens(pipe_to_tokens)
@@ -580,8 +581,15 @@ class StatementParser:
580
581
 
581
582
  # take everything from the end of the first match group to
582
583
  # the end of the line as the arguments (stripping leading
583
- # and trailing spaces)
584
- args = line[match.end(1) :].strip()
584
+ # and unquoted trailing whitespace)
585
+ args = line[match.end(1) :].lstrip()
586
+ try:
587
+ shlex_split(args)
588
+ except ValueError:
589
+ # Unclosed quote. Leave trailing whitespace.
590
+ pass
591
+ else:
592
+ args = args.rstrip()
585
593
  # if the command is empty that means the input was either empty
586
594
  # or something weird like '>'. args should be empty if we couldn't
587
595
  # parse a command
@@ -656,7 +664,7 @@ class StatementParser:
656
664
  keep_expanding = bool(remaining_aliases)
657
665
 
658
666
  # expand shortcuts
659
- for (shortcut, expansion) in self.shortcuts:
667
+ for shortcut, expansion in self.shortcuts:
660
668
  if line.startswith(shortcut):
661
669
  # If the next character after the shortcut isn't a space, then insert one
662
670
  shortcut_len = len(shortcut)
@@ -701,7 +709,6 @@ class StatementParser:
701
709
  punctuated_tokens = []
702
710
 
703
711
  for cur_initial_token in tokens:
704
-
705
712
  # Save tokens up to 1 character in length or quoted tokens. No need to parse these.
706
713
  if len(cur_initial_token) <= 1 or cur_initial_token[0] in constants.QUOTES:
707
714
  punctuated_tokens.append(cur_initial_token)
@@ -716,7 +723,6 @@ class StatementParser:
716
723
 
717
724
  while True:
718
725
  if cur_char not in punctuation:
719
-
720
726
  # Keep appending to new_token until we hit a punctuation char
721
727
  while cur_char not in punctuation:
722
728
  new_token += cur_char