", 1)[0].rsplit(".", 1)[0])
if isinstance(cls, type):
return cls
- return cast(type, getattr(meth, '__objclass__', None)) # handle special descriptor objects
-
-
-class CompletionMode(Enum):
- """Enum for what type of tab completion to perform in cmd2.Cmd.read_input()."""
-
- # Tab completion will be disabled during read_input() call
- # Use of custom up-arrow history supported
- NONE = 1
-
- # read_input() will tab complete cmd2 commands and their arguments
- # cmd2's command line history will be used for up arrow if history is not provided.
- # Otherwise use of custom up-arrow history supported.
- COMMANDS = 2
-
- # read_input() will tab complete based on one of its following parameters:
- # choices, choices_provider, completer, parser
- # Use of custom up-arrow history supported
- CUSTOM = 3
+ return cast(type, getattr(meth, "__objclass__", None)) # handle special descriptor objects
class CustomCompletionSettings:
- """Used by cmd2.Cmd.complete() to tab complete strings other than command arguments."""
+ """Used by cmd2.Cmd.complete() to complete strings other than command arguments."""
- def __init__(self, parser: argparse.ArgumentParser, *, preserve_quotes: bool = False) -> None:
+ def __init__(self, parser: "Cmd2ArgumentParser", *, preserve_quotes: bool = False) -> None:
"""CustomCompletionSettings initializer.
- :param parser: arg parser defining format of string being tab completed
+ :param parser: arg parser defining format of string being completed
:param preserve_quotes: if True, then quoted tokens will keep their quotes when processed by
- ArgparseCompleter. This is helpful in cases when you're tab completing
+ ArgparseCompleter. This is helpful in cases when you're completing
flag-like tokens (e.g. -o, --option) and you don't want them to be
treated as argparse flags when quoted. Set this to True if you plan
on passing the string to argparse with the tokens still quoted.
@@ -773,13 +755,13 @@ def strip_doc_annotations(doc: str) -> str:
:param doc: documentation string
"""
# Attempt to locate the first documentation block
- cmd_desc = ''
+ cmd_desc = ""
found_first = False
for doc_line in doc.splitlines():
stripped_line = doc_line.strip()
# Don't include :param type lines
- if stripped_line.startswith(':'):
+ if stripped_line.startswith(":"):
if found_first:
break
elif stripped_line:
@@ -840,7 +822,22 @@ def get_types(func_or_method: Callable[..., Any]) -> tuple[dict[str, Any], Any]:
type_hints = inspect.get_annotations(func_or_method, eval_str=True) # Get dictionary of type hints
except TypeError as exc:
raise ValueError("Argument passed to get_types should be a function or method") from exc
- ret_ann = type_hints.pop('return', None) # Pop off the return annotation if it exists
+ ret_ann = type_hints.pop("return", None) # Pop off the return annotation if it exists
if inspect.ismethod(func_or_method):
- type_hints.pop('self', None) # Pop off `self` hint for methods
+ type_hints.pop("self", None) # Pop off `self` hint for methods
return type_hints, ret_ann
+
+
+# Sorting keys for strings
+ALPHABETICAL_SORT_KEY = su.norm_fold
+NATURAL_SORT_KEY = natural_keys
+
+# Application-wide sort key for strings
+# Set it using cmd2.set_default_str_sort_key().
+DEFAULT_STR_SORT_KEY: Callable[[str], str] = ALPHABETICAL_SORT_KEY
+
+
+def set_default_str_sort_key(sort_key: Callable[[str], str]) -> None:
+ """Set the application-wide sort key for strings."""
+ global DEFAULT_STR_SORT_KEY # noqa: PLW0603
+ DEFAULT_STR_SORT_KEY = sort_key
diff --git a/docs/api/argparse_custom.md b/docs/api/argparse_custom.md
deleted file mode 100644
index 53f97a629..000000000
--- a/docs/api/argparse_custom.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# cmd2.argparse_custom
-
-::: cmd2.argparse_custom
diff --git a/docs/api/argparse_utils.md b/docs/api/argparse_utils.md
new file mode 100644
index 000000000..192e55c8f
--- /dev/null
+++ b/docs/api/argparse_utils.md
@@ -0,0 +1,3 @@
+# cmd2.argparse_utils
+
+::: cmd2.argparse_utils
diff --git a/docs/api/command_definition.md b/docs/api/command_definition.md
deleted file mode 100644
index 36a1e026c..000000000
--- a/docs/api/command_definition.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# cmd2.command_definition
-
-::: cmd2.command_definition
diff --git a/docs/api/command_set.md b/docs/api/command_set.md
new file mode 100644
index 000000000..300ccf95a
--- /dev/null
+++ b/docs/api/command_set.md
@@ -0,0 +1,3 @@
+# cmd2.command_set
+
+::: cmd2.command_set
diff --git a/docs/api/completion.md b/docs/api/completion.md
new file mode 100644
index 000000000..7cd7b6111
--- /dev/null
+++ b/docs/api/completion.md
@@ -0,0 +1,3 @@
+# cmd2.completion
+
+::: cmd2.completion
diff --git a/docs/api/index.md b/docs/api/index.md
index 36789dc49..c386df05a 100644
--- a/docs/api/index.md
+++ b/docs/api/index.md
@@ -13,24 +13,22 @@ incremented according to the [Semantic Version Specification](https://semver.org
- [cmd2.Cmd](./cmd.md) - functions and attributes of the main class in this library
- [cmd2.argparse_completer](./argparse_completer.md) - classes for `argparse`-based tab completion
-- [cmd2.argparse_custom](./argparse_custom.md) - classes and functions for extending `argparse`
+- [cmd2.argparse_utils](./argparse_utils.md) - classes and functions for extending `argparse`
- [cmd2.clipboard](./clipboard.md) - functions to copy from and paste to the clipboard/pastebuffer
- [cmd2.colors](./colors.md) - StrEnum of all color names supported by the Rich library
-- [cmd2.command_definition](./command_definition.md) - supports the definition of commands in
- separate classes to be composed into cmd2.Cmd
+- [cmd2.command_set](./command_set.md) - supports the definition of commands in separate classes to
+ be composed into cmd2.Cmd
+- [cmd2.completion](./completion.md) - classes and functions related to command-line completion
- [cmd2.constants](./constants.md) - constants used in `cmd2`
- [cmd2.decorators](./decorators.md) - decorators for `cmd2` commands
- [cmd2.exceptions](./exceptions.md) - custom `cmd2` exceptions
- [cmd2.history](./history.md) - classes for storing the history of previously entered commands
- [cmd2.parsing](./parsing.md) - classes for parsing and storing user input
- [cmd2.plugin](./plugin.md) - data classes for hook methods
+- [cmd2.pt_utils](./pt_utils.md) - utilities related to prompt-toolkit
- [cmd2.py_bridge](./py_bridge.md) - classes for bridging calls from the embedded python environment
to the host app
- [cmd2.rich_utils](./rich_utils.md) - common utilities to support Rich in cmd2 applications
-- [cmd2.rl_utils](./rl_utils.md) - imports the proper Readline for the platform and provides utility
- functions for it
- [cmd2.string_utils](./string_utils.md) - string utility functions
- [cmd2.styles](./styles.md) - cmd2-specific Rich styles and a StrEnum of their corresponding names
-- [cmd2.terminal_utils](./terminal_utils.md) - support for terminal control escape sequences
-- [cmd2.transcript](./transcript.md) - functions and classes for running and validating transcripts
- [cmd2.utils](./utils.md) - various utility classes and functions
diff --git a/docs/api/pt_utils.md b/docs/api/pt_utils.md
new file mode 100644
index 000000000..f5cd2358a
--- /dev/null
+++ b/docs/api/pt_utils.md
@@ -0,0 +1,3 @@
+# cmd2.pt_utils
+
+::: cmd2.pt_utils
diff --git a/docs/api/rl_utils.md b/docs/api/rl_utils.md
deleted file mode 100644
index 52beb31ba..000000000
--- a/docs/api/rl_utils.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# cmd2.rl_utils
-
-::: cmd2.rl_utils
diff --git a/docs/api/terminal_utils.md b/docs/api/terminal_utils.md
deleted file mode 100644
index 919f36dd5..000000000
--- a/docs/api/terminal_utils.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# cmd2.terminal_utils
-
-::: cmd2.terminal_utils
diff --git a/docs/api/transcript.md b/docs/api/transcript.md
deleted file mode 100644
index bde72d371..000000000
--- a/docs/api/transcript.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# cmd2.transcript
-
-::: cmd2.transcript
diff --git a/docs/doc_conventions.md b/docs/doc_conventions.md
index 9981b75be..022eb7738 100644
--- a/docs/doc_conventions.md
+++ b/docs/doc_conventions.md
@@ -49,7 +49,7 @@ or [The Markdown Guide](https://www.markdownguide.org/) for a more complete refe
Code blocks can be created in two ways:
-- Indent the block - this will show as a monospace code block, but won't include highighting
+- Indent the block - this will show as a monospace code block, but won't include highlighting
- use the triple backticks followed by the code language, e.g. `python` and close with triple
backticks
diff --git a/docs/examples/alternate_event_loops.md b/docs/examples/alternate_event_loops.md
index 3a6ebc9d8..0dbe1f01d 100644
--- a/docs/examples/alternate_event_loops.md
+++ b/docs/examples/alternate_event_loops.md
@@ -21,6 +21,15 @@ Many Python concurrency libraries involve or require an event loop which they ar
such as [asyncio](https://docs.python.org/3/library/asyncio.html), [gevent](http://www.gevent.org/),
[Twisted](https://twistedmatrix.com), etc.
+!!! warning
+
+ As of version **4.0**, `cmd2` depends on `prompt-toolkit` which in turn uses
+ [asyncio natively](https://python-prompt-toolkit.readthedocs.io/en/stable/pages/advanced_topics/asyncio.html)
+ and starts its own `asyncio` event loop.
+
+ The [async_call.py](https://github.com/python-cmd2/cmd2/blob/main/examples/async_call.py) example shows how
+ to make a call to an async function from a cmd2 command.
+
`cmd2` applications can be executed in a way where `cmd2` doesn't own the main loop for the program
by using code like the following:
@@ -69,5 +78,4 @@ integrate with any specific event loop is beyond the scope of this documentation
running in this fashion comes with several disadvantages, including:
- Requires the developer to write more code
-- Does not support transcript testing
- Does not allow commands at invocation via command-line arguments
diff --git a/docs/examples/getting_started.md b/docs/examples/getting_started.md
index 6be85b6e3..070158891 100644
--- a/docs/examples/getting_started.md
+++ b/docs/examples/getting_started.md
@@ -12,6 +12,7 @@ example application which demonstrates many features of `cmd2`:
- [Shortcuts](../features/shortcuts_aliases_macros.md#shortcuts)
- [Multiline Commands](../features/multiline_commands.md)
- [History](../features/history.md)
+- [Bottom Toolbar](../features/prompt.md#bottom-toolbar)
If you don't want to type as we go, here is the complete source (you can click to expand and then
click the **Copy** button in the top-right):
@@ -151,7 +152,6 @@ The last thing you'll notice is that we used the `self.poutput()` method to disp
1. Allows the user to redirect output to a text file or pipe it to a shell process
1. Gracefully handles `BrokenPipeError` exceptions for redirected output
-1. Makes the output show up in a [transcript](../features/transcripts.md)
1. Honors the setting to [strip embedded ANSI sequences](../features/settings.md#allow_style)
(typically used for background and foreground colors)
@@ -266,15 +266,21 @@ persist between invocations of your application, you'll need to do a little work
Users can access command history using two methods:
-- The [readline](https://docs.python.org/3/library/readline.html) library which provides a Python
- interface to the [GNU readline library](https://en.wikipedia.org/wiki/GNU_Readline)
+- The [prompt-toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit) library which
+ provides a pure Python replacement for the
+ [GNU readline library](https://en.wikipedia.org/wiki/GNU_Readline) which is fully cross-platform
+ compatible
- The `history` command which is built-in to `cmd2`
From the prompt in a `cmd2`-based application, you can press `Control-p` to move to the previously
entered command, and `Control-n` to move to the next command. You can also search through the
-command history using `Control-r`. The
-[GNU Readline User Manual](http://man7.org/linux/man-pages/man3/readline.3.html) has all the
-details, including all the available commands, and instructions for customizing the key bindings.
+command history using `Control-r`.
+
+By default, `prompt-toolkit` provides Emacs-style key bindings which will be familiar to users of
+the GNU Readline library. You can refer to the
+[readline cheat sheet](http://readline.kablamo.org/emacs.html) or you can dig into the
+[Prompt Toolkit User Manual](https://python-prompt-toolkit.readthedocs.io/en/stable/pages/advanced_topics/key_bindings.html)
+for all the details, including instructions for customizing the key bindings.
The `history` command allows a user to view the command history, and select commands from history by
number, range, string search, or regular expression. With the selected commands, users can:
diff --git a/docs/features/argument_processing.md b/docs/features/argument_processing.md
index 00a9b94c6..a0a577380 100644
--- a/docs/features/argument_processing.md
+++ b/docs/features/argument_processing.md
@@ -6,13 +6,13 @@ following for you:
1. Parsing input and quoted strings in a manner similar to how POSIX shells do it
1. Parse the resulting argument list using an instance of
- [argparse.ArgumentParser](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser)
+ [Cmd2ArgumentParser](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser)
that you provide
1. Passes the resulting
[argparse.Namespace](https://docs.python.org/3/library/argparse.html#argparse.Namespace) object
to your command function. The `Namespace` includes the [Statement][cmd2.Statement] object that
- was created when parsing the command line. It can be retrieved by calling `cmd2_statement.get()`
- on the `Namespace`.
+ was created when parsing the command line. It is accessible via the `cmd2_statement` attribute on
+ the `Namespace`.
1. Adds the usage message from the argument parser to your command's help.
1. Checks if the `-h/--help` option is present, and if so, displays the help message for the command
@@ -39,9 +39,9 @@ command which might have its own argument parsing.
The [@with_argparser][cmd2.with_argparser] decorator can accept the following for its first
argument:
-1. An existing instance of `argparse.ArgumentParser`
-2. A function or static method which returns an instance of `argparse.ArgumentParser`
-3. Cmd or CommandSet class method which returns an instance of `argparse.ArgumentParser`
+1. An existing instance of `Cmd2ArgumentParser`
+2. A function or static method which returns an instance of `Cmd2ArgumentParser`
+3. Cmd or CommandSet class method which returns an instance of `Cmd2ArgumentParser`
In all cases the `@with_argparser` decorator creates a deep copy of the parser instance which it
stores internally. A consequence is that parsers don't need to be unique across commands.
@@ -50,16 +50,16 @@ stores internally. A consequence is that parsers don't need to be unique across
Since the `@with_argparser` decorator is making a deep-copy of the parser provided, if you wish
to dynamically modify this parser at a later time, you need to retrieve this deep copy. This can
- be done using `self._command_parsers.get(self.do_commandname)`.
+ be done using `self.command_parsers.get(self.do_commandname)`.
## Argument Parsing
For each command in the `cmd2.Cmd` subclass which requires argument parsing, create an instance of
-`argparse.ArgumentParser()` which can parse the input appropriately for the command (or provide a
+`Cmd2ArgumentParser` which can parse the input appropriately for the command (or provide a
function/method that returns such a parser). Then decorate the command method with the
`@with_argparser` decorator, passing the argument parser as the first parameter to the decorator.
This changes the second argument of the command method, which will contain the results of
-`ArgumentParser.parse_args()`.
+`Cmd2ArgumentParser.parse_args()`.
Here's what it looks like:
@@ -97,7 +97,7 @@ def do_speak(self, opts):
By default, `cmd2` uses the docstring of the command method when a user asks for help on the
command. When you use the `@with_argparser` decorator, the docstring for the `do_*` method is used
-to set the description for the `argparse.ArgumentParser`.
+to set the description for the `Cmd2ArgumentParser`.
!!! tip "description and epilog fields are rich objects"
@@ -135,8 +135,8 @@ optional arguments:
-h, --help show this help message and exit
```
-If you would prefer, you can set the `description` while instantiating the `argparse.ArgumentParser`
-and leave the docstring on your method blank:
+If you would prefer, you can set the `description` while instantiating the `Cmd2ArgumentParser` and
+leave the docstring on your method blank:
```py
from cmd2 import Cmd2ArgumentParser, with_argparser
@@ -212,14 +212,14 @@ benefit is that your `cmd2` applications now have more aesthetically pleasing he
color to make it quicker and easier to visually parse help text. This works for all supported
versions of Python.
-- [Cmd2HelpFormatter][cmd2.argparse_custom.Cmd2HelpFormatter] - default help formatter class
-- [ArgumentDefaultsCmd2HelpFormatter][cmd2.argparse_custom.ArgumentDefaultsCmd2HelpFormatter] - adds
+- [Cmd2HelpFormatter][cmd2.argparse_utils.Cmd2HelpFormatter] - default help formatter class
+- [ArgumentDefaultsCmd2HelpFormatter][cmd2.argparse_utils.ArgumentDefaultsCmd2HelpFormatter] - adds
default values to argument help
-- [MetavarTypeCmd2HelpFormatter][cmd2.argparse_custom.MetavarTypeCmd2HelpFormatter] - uses the
+- [MetavarTypeCmd2HelpFormatter][cmd2.argparse_utils.MetavarTypeCmd2HelpFormatter] - uses the
argument 'type' as the default metavar value (instead of the argument 'dest')
-- [RawDescriptionCmd2HelpFormatter][cmd2.argparse_custom.RawDescriptionCmd2HelpFormatter] - retains
+- [RawDescriptionCmd2HelpFormatter][cmd2.argparse_utils.RawDescriptionCmd2HelpFormatter] - retains
any formatting in descriptions and epilogs
-- [RawTextCmd2HelpFormatter][cmd2.argparse_custom.RawTextCmd2HelpFormatter] - retains formatting of
+- [RawTextCmd2HelpFormatter][cmd2.argparse_utils.RawTextCmd2HelpFormatter] - retains formatting of
all help text
The default `Cmd2HelpFormatter` class inherits from `argparse.HelpFormatter`. If you want a
@@ -397,11 +397,7 @@ example demonstrates both above cases in a concrete fashion.
## Reserved Argument Names
`cmd2`'s `@with_argparser` decorator adds the following attributes to argparse Namespaces. To avoid
-naming collisions, do not use any of the names for your argparse arguments.
-
-- `cmd2_statement` - `cmd2.Cmd2AttributeWrapper` object containing the `cmd2.Statement` object that
- was created when parsing the command line.
-- `cmd2_handler` - `cmd2.Cmd2AttributeWrapper` object containing a subcommand handler function or
- `None` if one was not set.
-- `__subcmd_handler__` - used by cmd2 to identify the handler for a subcommand created with the
- `@cmd2.as_subcommand_to` decorator.
+naming collisions, do not use any of these names for your argparse arguments.
+
+- `cmd2_statement` - [cmd2.Statement][] object that was created when parsing the command line.
+- `cmd2_subcmd_handler` - subcommand handler function or `None` if one was not set.
diff --git a/docs/features/async_commands.md b/docs/features/async_commands.md
new file mode 100644
index 000000000..c8430d4ae
--- /dev/null
+++ b/docs/features/async_commands.md
@@ -0,0 +1,74 @@
+# Async Commands
+
+`cmd2` is built on top of the Python Standard Library's `cmd` module, which is inherently
+synchronous. This means that `do_*` command methods are expected to be synchronous functions.
+
+However, you can still integrate asynchronous code (using `asyncio` and `async`/`await`) into your
+`cmd2` application by running an `asyncio` event loop in a background thread and bridging calls to
+it.
+
+## The `with_async_loop` Decorator
+
+A clean way to handle this is to define a decorator that wraps your `async def` commands. This
+decorator handles:
+
+1. Starting a background thread with an `asyncio` loop (if not already running).
+2. Submitting the command's coroutine to that loop.
+3. Waiting for the result (synchronously) so that the `cmd2` interface behaves as expected
+ (blocking until the command completes).
+
+### Example Implementation
+
+Here is an example of how to implement such a decorator and use it in your application.
+
+```python
+import asyncio
+import functools
+import threading
+from typing import Any, Callable
+import cmd2
+
+# Global event loop and lock
+_event_loop = None
+_event_lock = threading.Lock()
+
+def _get_event_loop() -> asyncio.AbstractEventLoop:
+ """Get or create the background event loop."""
+ global _event_loop
+
+ if _event_loop is None:
+ with _event_lock:
+ if _event_loop is None:
+ _event_loop = asyncio.new_event_loop()
+ thread = threading.Thread(
+ target=_event_loop.run_forever,
+ name='Async Runner',
+ daemon=True,
+ )
+ thread.start()
+ return _event_loop
+
+def with_async_loop(func: Callable[..., Any]) -> Callable[..., Any]:
+ """Decorator to run a command method asynchronously in a background thread."""
+ @functools.wraps(func)
+ def wrapper(self: cmd2.Cmd, *args: Any, **kwargs: Any) -> Any:
+ loop = _get_event_loop()
+ coro = func(self, *args, **kwargs)
+ future = asyncio.run_coroutine_threadsafe(coro, loop)
+ return future.result()
+ return wrapper
+
+class AsyncApp(cmd2.Cmd):
+ @with_async_loop
+ async def do_my_async(self, _: cmd2.Statement) -> None:
+ self.poutput("Starting async work...")
+ await asyncio.sleep(1.0)
+ self.poutput("Async work complete!")
+```
+
+## See Also
+
+- [async_commands.py](https://github.com/python-cmd2/cmd2/blob/main/examples/async_commands.py) -
+ Full example code.
+- [async_call.py](https://github.com/python-cmd2/cmd2/blob/main/examples/async_call.py) - An
+ alternative example showing how to make individual async calls without a decorator.
diff --git a/docs/features/builtin_commands.md b/docs/features/builtin_commands.md
index f2bc71820..ee92ec201 100644
--- a/docs/features/builtin_commands.md
+++ b/docs/features/builtin_commands.md
@@ -77,19 +77,19 @@ application:
```text
(Cmd) set
- Name Value Description
-───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
- allow_style Terminal Allow ANSI text style sequences in output (valid values: Always, Never, Terminal)
- always_show_hint False Display tab completion hint even when completion suggestions print
- debug False Show full traceback on exception
- echo False Echo command issued into output
- editor vim Program used by 'edit'
- feedback_to_output False Include nonessentials in '|' and '>' results
- foreground_color cyan Foreground color to use with echo command
- max_completion_items 50 Maximum number of CompletionItems to display during tab completion
- quiet False Don't print nonessential feedback
- scripts_add_to_history True Scripts and pyscripts add commands to history
- timing False Report execution times
+ Name Value Description
+──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+ allow_style Terminal Allow ANSI text style sequences in output (valid values: Always, Never, Terminal)
+ debug False Show full traceback on exception
+ echo False Echo command issued into output
+ editor vim Program used by 'edit'
+ feedback_to_output False Include nonessentials in '|' and '>' results
+ max_column_completion_results 7 Maximum number of completion results to display in a single column
+ max_completion_table_items 50 Maximum number of completion results allowed for a completion table to appear
+ quiet False Don't print nonessential feedback
+ scripts_add_to_history True Scripts and pyscripts add commands to history
+ timing False Report execution times
+ traceback_show_locals False Display local variables in tracebacks
```
Any of these user-settable parameters can be set while running your app with the `set` command like
diff --git a/docs/features/completion.md b/docs/features/completion.md
index 36e8a8f48..85143650b 100644
--- a/docs/features/completion.md
+++ b/docs/features/completion.md
@@ -20,8 +20,8 @@ from `cmd2.Cmd`:
complete_foo = cmd2.Cmd.path_complete
```
-This will effectively define the `complete_foo` readline completer method in your class and make it
-utilize the same path completion logic as the built-in commands.
+This will effectively define the `complete_foo` prompt-toolkit completer method in your class and
+make it utilize the same path completion logic as the built-in commands.
The built-in logic allows for a few more advanced path completion capabilities, such as cases where
you only want to match directories. Suppose you have a custom command `bar` implemented by the
@@ -56,19 +56,6 @@ complete_bar = functools.partialmethod(cmd2.Cmd.path_complete, path_filter=os.pa
> [basic_completion](https://github.com/python-cmd2/cmd2/blob/main/examples/basic_completion.py)
> example for a demonstration of how to use this feature
-- [flag_based_complete][cmd2.Cmd.flag_based_complete] - helper method for tab completion based on a
- particular flag preceding the token being completed
-
-- [index_based_complete][cmd2.Cmd.index_based_complete] - helper method for tab completion based on
- a fixed position in the input string
-
- > - See the
- > [basic_completion](https://github.com/python-cmd2/cmd2/blob/main/examples/basic_completion.py)
- > example for a demonstration of how to use these features
- > - `flag_based_complete()` and `index_based_complete()` are basic methods and should only be
- > used if you are not familiar with argparse. The recommended approach for tab completing
- > positional tokens and flags is to use [argparse-based](#argparse-based) completion.
-
## Raising Exceptions During Completion
There are times when an error occurs while tab completing and a message needs to be reported to the
@@ -90,7 +77,7 @@ When using `cmd2`'s [@with_argparser][cmd2.with_argparser] decorator, `cmd2` pro
completion of flag names.
Tab completion of argument values can be configured by using one of three parameters to
-[argparse.ArgumentParser.add_argument](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.add_argument)
+`Cmd2ArgumentParser.add_argument()`.
- `choices`
- `choices_provider`
@@ -131,5 +118,5 @@ demonstration.
## For More Information
-See [cmd2's argparse_custom API](../api/argparse_custom.md) for a more detailed discussion of
-argparse completion.
+See [cmd2's argparse_utils API](../api/argparse_utils.md) for a more detailed discussion of argparse
+completion.
diff --git a/docs/features/generating_output.md b/docs/features/generating_output.md
index da685208b..b93b7c2b9 100644
--- a/docs/features/generating_output.md
+++ b/docs/features/generating_output.md
@@ -40,10 +40,12 @@ complex output:
- `end`: string to write at end of printed text. Defaults to a newline
- `style`: optional style to apply to output
- `soft_wrap`: Enable soft wrap mode. If True, lines of text will not be word-wrapped or cropped to fit the terminal width. Defaults to True
+ - `justify`: justify method ("left", "center", "right", "full"). Defaults to None.
- `emoji`: If True, Rich will replace emoji codes (e.g., 😃) with their corresponding Unicode characters. Defaults to False
- `markup`: If True, Rich will interpret strings with tags (e.g., [bold]hello[/bold]) as styled output. Defaults to False
- `highlight`: If True, Rich will automatically apply highlighting to elements within strings, such as common Python data types like numbers, booleans, or None.
- - `rich_print_kwargs`: optional additional keyword arguments to pass to Rich's `Console.print()`
+ - `rich_print_kwargs`: optional additional keyword arguments to pass to `console.print()`
+ - `kwargs`: arbitrary keyword arguments to support extending the print methods. These are not passed to `console.print()`.
## Ordinary Output
@@ -121,7 +123,6 @@ following sections:
- [cmd2.colors][]
- [cmd2.rich_utils][]
- [cmd2.string_utils][]
-- [cmd2.terminal_utils][]
The [color.py](https://github.com/python-cmd2/cmd2/blob/main/examples/color.py) example demonstrates
all colors available to your `cmd2` application.
@@ -163,7 +164,7 @@ each line is aligned independently.
!!! tip "Advanced alignment customization"
- You can also control output alignment using the `rich_print_kwargs.justify` member when calling
+ You can also control output alignment using the `justify` parameter when calling
`cmd2`'s print methods.
## Columnar Output
diff --git a/docs/features/help.md b/docs/features/help.md
index 6def1f5b1..11150ee46 100644
--- a/docs/features/help.md
+++ b/docs/features/help.md
@@ -50,48 +50,74 @@ not use an `argparse` decorator because we didn't want different output for `hel
## Categorizing Commands
-By default, the `help` command displays:
+In `cmd2`, the `help` command organizes its output into categories. Every command belongs to a
+category, and the display is driven by the `DEFAULT_CATEGORY` class variable.
- Documented Commands
- ───────────────────
- alias help ipy py run_pyscript set shortcuts
- edit history macro quit run_script shell
+There are 3 methods of specifying command categories:
-If you have a large number of commands, you can optionally group your commands into categories.
-Here's the output from the example
-[help_categories.py](https://github.com/python-cmd2/cmd2/blob/main/examples/help_categories.py):
+1. Using the `DEFAULT_CATEGORY` class variable (Automatic)
+1. Using the [@with_category][cmd2.with_category] decorator (Manual)
+1. Using the [categorize()][cmd2.categorize] function (Manual)
- Application Management
- ──────────────────────
- deploy findleakers redeploy sessions stop
- expire list restart start undeploy
+### Automatic Categorization
- Command Management
- ──────────────────
- disable_commands enable_commands
+The most efficient way to categorize commands is by defining the `DEFAULT_CATEGORY` class variable
+in your `Cmd` or `CommandSet` class. Any command defined in that class that does not have an
+explicit category override will automatically be placed in this category.
- Connecting
- ──────────
- connect which
+By default, `cmd2.Cmd` defines its `DEFAULT_CATEGORY` as `"Cmd2 Commands"`.
- Server Information
- ──────────────────
- resources serverinfo sslconnectorciphers status thread_dump vminfo
+```py
+class MyApp(cmd2.Cmd):
+ # All commands defined in this class will be grouped here
+ DEFAULT_CATEGORY = 'Application Commands'
- Other
- ─────
- alias edit history quit run_script shell version
- config help macro run_pyscript set shortcuts
+ def do_echo(self, arg):
+ """Echo command"""
+ self.poutput(arg)
+```
+
+This also works for [Command Sets](./modular_commands.md):
+
+```py
+class Plugin(cmd2.CommandSet):
+ DEFAULT_CATEGORY = 'Plugin Commands'
+
+ def do_plugin_cmd(self, _):
+ """Plugin command"""
+ self._cmd.poutput('Plugin')
+```
+
+When using inheritance, `cmd2` uses the `DEFAULT_CATEGORY` of the class where the command was
+actually defined. This means built-in commands (like `help`, `history`, and `quit`) stay in the
+`"Cmd2 Commands"` category, while your commands move to your custom category.
+
+If you want to rename the built-in category itself, you can do so by reassigning
+`cmd2.Cmd.DEFAULT_CATEGORY` at the class level within your `Cmd` subclass:
+
+```py
+class MyApp(cmd2.Cmd):
+ # Rename the framework's built-in category
+ cmd2.Cmd.DEFAULT_CATEGORY = 'Shell Commands'
+
+ # Set the category for your own commands
+ DEFAULT_CATEGORY = 'Application Commands'
+```
+
+For a complete demonstration of this functionality, see the
+[default_categories.py](https://github.com/python-cmd2/cmd2/blob/main/examples/default_categories.py)
+example.
+
+### Manual Categorization
-There are 2 methods of specifying command categories, using the [@with_category][cmd2.with_category]
-decorator or with the [categorize()][cmd2.categorize] function. Once a single command category is
-detected, the help output switches to a categorized mode of display. All commands without an
-explicit category defined default to the category `Other`.
+If you need to move an individual command to a different category than the class default, you can
+use the `@with_category` decorator or the `categorize()` function. These manual settings always take
+precedence over the `DEFAULT_CATEGORY`.
Using the `@with_category` decorator:
```py
-@with_category(CMD_CAT_CONNECTING)
+@with_category('Connecting')
def do_which(self, _):
"""Which command"""
self.poutput('Which')
diff --git a/docs/features/history.md b/docs/features/history.md
index 09b962b39..c9ece9765 100644
--- a/docs/features/history.md
+++ b/docs/features/history.md
@@ -2,12 +2,14 @@
## For Developers
-The `cmd` module from the Python standard library includes `readline` history.
+Previously, `cmd2` relied on the GNU Readline library for command history. As of version 4.0.0,
+`cmd2` has migrated to [prompt-toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit) for
+all input and history handling.
-[cmd2.Cmd][] offers the same `readline` capabilities, but also maintains its own data structures for
-the history of all commands entered by the user. When the class is initialized, it creates an
-instance of the [cmd2.history.History][] class (which is a subclass of `list`) as
-`cmd2.Cmd.history`.
+[cmd2.Cmd][] uses `prompt-toolkit` to provide familiar command-line history capabilities while also
+maintaining its own data structures for the history of all commands entered by the user. When the
+class is initialized, it creates an instance of the [cmd2.history.History][] class (which is a
+subclass of `list`) as `cmd2.Cmd.history`.
Each time a command is executed (this gets complex, see
[Command Processing Loop](./hooks.md#command-processing-loop) for exactly when) the parsed
@@ -20,9 +22,9 @@ this format instead of plain text to preserve the complete `cmd2.Statement` obje
!!! note
- `readline` saves everything you type, whether it is a valid command or not. `cmd2` only saves input to internal history if the command parses successfully and is a valid command. This design choice was intentional, because the contents of history can be saved to a file as a script, or can be re-run. Not saving invalid input reduces unintentional errors when doing so.
+ `prompt-toolkit` saves everything you type, whether it is a valid command or not. `cmd2` only saves input to internal history if the command parses successfully and is a valid command. This design choice was intentional, because the contents of history can be saved to a file as a script, or can be re-run. Not saving invalid input reduces unintentional errors when doing so.
- However, this design choice causes an inconsistency between the `readline` history and the `cmd2` history when you enter an invalid command: it is saved to the `readline` history, but not to the `cmd2` history.
+ However, this design choice causes an inconsistency between the `prompt-toolkit` history and the `cmd2` history when you enter an invalid command: it is saved to the `prompt-toolkit` history, but not to the `cmd2` history.
The `cmd2.Cmd.history` attribute, the `cmd2.history.History` class, and the
[cmd2.history.HistoryItem][] class are all part of the public API for `cmd2.Cmd`. You could use
@@ -34,13 +36,14 @@ built-in `history` command works).
You can use the :arrow_up: up and :arrow_down: down arrow keys to move through the history of
previously entered commands.
-If the `readline` module is installed, you can press `Control-p` to move to the previously entered
-command, and `Control-n` to move to the next command. You can also search through the command
-history using `Control-r`.
+You can press `Control-p` to move to the previously entered command, and `Control-n` to move to the
+next command. You can also search through the command history using `Control-r`.
-You can refer to the [readline cheat sheet](http://readline.kablamo.org/emacs.html) or you can dig
-into the [GNU Readline User Manual](http://man7.org/linux/man-pages/man3/readline.3.html) for all
-the details, including instructions for customizing the key bindings.
+By default, `prompt-toolkit` provides Emacs-style key bindings which will be familiar to users of
+the GNU Readline library. You can refer to the
+[readline cheat sheet](http://readline.kablamo.org/emacs.html) or you can dig into the
+[Prompt Toolkit User Manual](https://python-prompt-toolkit.readthedocs.io/en/stable/pages/advanced_topics/key_bindings.html)
+for all the details, including instructions for customizing the key bindings.
`cmd2` makes a third type of history access available with the `history` command. Each time the user
enters a command, `cmd2` saves the input. The `history` command lets you do interesting things with
@@ -164,20 +167,6 @@ text file:
(Cmd) history :5 -o history.txt
-The `history` command can also save both the commands and their output to a text file. This is
-called a transcript. See [Transcripts](./transcripts.md) for more information on how transcripts
-work, and what you can use them for. To create a transcript use the `-t` or `--transcription`
-option:
-
- (Cmd) history 2:3 --transcript transcript.txt
-
-The `--transcript` option implies `--run`: the commands must be re-run in order to capture their
-output to the transcript file.
-
-!!! warning
-
- Unlike the `-o`/`--output-file` option, the `-t`/`--transcript` option will actually run the selected history commands again. This is necessary for creating a transcript file since the history saves the commands themselves but does not save their output. Please note that a side-effect of this is that the commands will appear again at the end of the history.
-
The last action the history command can perform is to clear the command history using `-c` or
`--clear`:
@@ -186,11 +175,11 @@ The last action the history command can perform is to clear the command history
In addition to these five actions, the `history` command also has some options to control how the
output is formatted. With no arguments, the `history` command displays the command number before
each command. This is great when displaying history to the screen because it gives you an easy
-reference to identify previously entered commands. However, when creating a script or a transcript,
-the command numbers would prevent the script from loading properly. The `-s` or `--script` option
-instructs the `history` command to suppress the line numbers. This option is automatically set by
-the `--output-file`, `--transcript`, and `--edit` options. If you want to output the history
-commands with line numbers to a file, you can do it with output redirection:
+reference to identify previously entered commands. However, when creating a script, the command
+numbers would prevent the script from loading properly. The `-s` or `--script` option instructs the
+`history` command to suppress the line numbers. This option is automatically set by the
+`--output-file` and `--edit` options. If you want to output the history commands with line numbers
+to a file, you can do it with output redirection:
(Cmd) history 1:4 > history.txt
diff --git a/docs/features/hooks.md b/docs/features/hooks.md
index 2b877ccf4..6fa59bb38 100644
--- a/docs/features/hooks.md
+++ b/docs/features/hooks.md
@@ -74,7 +74,6 @@ loop behavior:
- `allow_cli_args` - allows commands to be specified on the operating system command line which are
executed before the command processing loop begins
-- `transcript_files` - see [Transcripts](./transcripts.md) for more information
- `startup_script` - run a script on initialization. See [Scripting](./scripting.md) for more
information
@@ -83,6 +82,9 @@ loop behavior:
When you call [cmd2.Cmd.cmdloop][], the following sequence of events are repeated until the
application exits:
+1. Start the `prompt-toolkit` event loop
+1. Call [cmd2.Cmd.pre_prompt][] for any behavior that should happen after event loop starts but
+ before prompt is displayed
1. Output the prompt
1. Accept user input
1. Parse user input into a [cmd2.Statement][] object
diff --git a/docs/features/index.md b/docs/features/index.md
index 9cbf65072..619626ef3 100644
--- a/docs/features/index.md
+++ b/docs/features/index.md
@@ -3,6 +3,7 @@
- [Argument Processing](argument_processing.md)
+- [Async Commands](async_commands.md)
- [Builtin Commands](builtin_commands.md)
- [Clipboard Integration](clipboard.md)
- [Commands](commands.md)
@@ -28,6 +29,5 @@
- [Startup Commands](startup_commands.md)
- [Table Creation](table_creation.md)
- [Theme](theme.md)
-- [Transcripts](transcripts.md)
diff --git a/docs/features/initialization.md b/docs/features/initialization.md
index 9d1201a6d..c1ad6e2c0 100644
--- a/docs/features/initialization.md
+++ b/docs/features/initialization.md
@@ -16,6 +16,15 @@ Certain things must be initialized within the `__init__()` method of your class
::: cmd2.Cmd.__init__
+## Cmd class variables
+
+The `cmd2.Cmd` class provides several class-level variables that can be overridden in subclasses to change default behavior across all instances of that class.
+
+- **DEFAULT_CATEGORY**: The default help category for commands defined in the class which haven't been explicitly categorized. (Default: `"Cmd2 Commands"`)
+- **DEFAULT_EDITOR**: The default editor program used by the `edit` command.
+- **DEFAULT_PROMPT**: The default prompt string. (Default: `"(Cmd) "`)
+- **MISC_HEADER**: Header for the help section listing miscellaneous help topics. (Default: `"Miscellaneous Help Topics"`)
+
## Cmd instance attributes
The `cmd2.Cmd` class provides a large number of public instance attributes which allow developers to customize a `cmd2` application further beyond the options provided by the `__init__()` method.
@@ -24,16 +33,12 @@ The `cmd2.Cmd` class provides a large number of public instance attributes which
Here are instance attributes of `cmd2.Cmd` which developers might wish to override:
-- **always_show_hint**: if `True`, display tab completion hint even when completion suggestions print (Default: `False`)
+- **bottom_toolbar**: if `True`, then a bottom toolbar will be displayed (Default: `False`)
- **broken_pipe_warning**: if non-empty, this string will be displayed if a broken pipe error occurs
- **continuation_prompt**: used for multiline commands on 2nd+ line of input
- **debug**: if `True`, show full stack trace on error (Default: `False`)
-- **default_category**: if any command has been categorized, then all other commands that haven't been categorized will display under this section in the help output.
- **default_error**: the error that prints when a non-existent command is run
-- **default_sort_key**: the default key for sorting string results. Its default value performs a case-insensitive alphabetical sort.
-- **default_to_shell**: if `True`, attempt to run unrecognized commands as shell commands (Default: `False`)
- **disabled_commands**: commands that have been disabled from use. This is to support commands that are only available during specific states of the application. This dictionary's keys are the command names and its values are DisabledCommand objects.
-- **doc_header**: Set the header used for the help function's listing of documented functions
- **echo**: if `True`, each command the user issues will be repeated to the screen before it is executed. This is particularly useful when running scripts. This behavior does not occur when running a command at the prompt. (Default: `False`)
- **editor**: text editor program to use with _edit_ command (e.g. `vim`)
- **exclude_from_history**: commands to exclude from the _history_ command
@@ -43,7 +48,8 @@ Here are instance attributes of `cmd2.Cmd` which developers might wish to overri
- **hidden_commands**: commands to exclude from the help menu and tab completion
- **last_result**: stores results from the last command run to enable usage of results in a Python script or interactive console. Built-in commands don't make use of this. It is purely there for user-defined commands and convenience.
- **macros**: dictionary of macro names and their values
-- **max_completion_items**: max number of CompletionItems to display during tab completion (Default: 50)
+- **max_column_completion_results**: The maximum number of completion results to display in a single column (Default: 7)
+- **max_completion_table_items**: The maximum number of completion results allowed for a completion table to appear (Default: 50)
- **pager**: sets the pager command used by the `Cmd.ppaged()` method for displaying wrapped output using a pager
- **pager_chop**: sets the pager command used by the `Cmd.ppaged()` method for displaying chopped/truncated output using a pager
- **py_bridge_name**: name by which embedded Python environments and scripts refer to the `cmd2` application by in order to call commands (Default: `app`)
diff --git a/docs/features/misc.md b/docs/features/misc.md
index 1915b3302..7e5fa9628 100644
--- a/docs/features/misc.md
+++ b/docs/features/misc.md
@@ -34,6 +34,10 @@ Sauce? 2
wheaties with salty sauce, yum!
```
+See the `do_eat` method in the
+[read_input.py](https://github.com/python-cmd2/cmd2/blob/main/examples/read_input.py) file for a
+example of how to use `select.
+
## Disabling Commands
`cmd2` supports disabling commands during runtime. This is useful if certain commands should only be
@@ -54,23 +58,3 @@ See the definitions of these functions for descriptions of their arguments.
See the `do_enable_commands()` and `do_disable_commands()` functions in the
[help_categories.py](https://github.com/python-cmd2/cmd2/blob/main/examples/help_categories.py)
example for a demonstration.
-
-## Default to shell
-
-Every `cmd2` application can execute operating-system level (shell) commands with `shell` or a `!`
-shortcut:
-
- (Cmd) shell which python
- /usr/bin/python
- (Cmd) !which python
- /usr/bin/python
-
-However, if the parameter `default_to_shell` is `True`, then _every_ thing entered which doesn't
-match another command will be attempted on the operating system. Only if that attempt fails (i.e.,
-produces a nonzero return value) will the application's own `default` method be called.
-
- (Cmd) which python
- /usr/bin/python
- (Cmd) my dog has fleas
- sh: my: not found
- *** Unknown syntax: my dog has fleas
diff --git a/docs/features/modular_commands.md b/docs/features/modular_commands.md
index 3ba8e994d..2380f4ec6 100644
--- a/docs/features/modular_commands.md
+++ b/docs/features/modular_commands.md
@@ -40,11 +40,6 @@ CommandSets group multiple commands together. The plugin will inspect functions
`CommandSet` using the same rules as when they're defined in `cmd2.Cmd`. Commands must be prefixed
with `do_`, help functions with `help_`, and completer functions with `complete_`.
-The [@with_default_category][cmd2.with_default_category] decorator is provided to categorize all
-commands within a CommandSet class in the same command category. Individual commands in a CommandSet
-class may override the default category by using the [@with_category][cmd2.with_category] decorator
-on that method.
-
CommandSet command methods will always expect the same parameters as when defined in a `cmd2.Cmd`
sub-class, except that `self` will now refer to the `CommandSet` instead of the cmd2 instance. The
cmd2 instance can be accessed through `self._cmd` that is populated when the `CommandSet` is
@@ -55,18 +50,7 @@ initializer arguments, see [Manual CommandSet Construction](#manual-commandset-c
```py
import cmd2
-from cmd2 import CommandSet, with_default_category
-
-@with_default_category('My Category')
-class AutoLoadCommandSet(CommandSet):
- def __init__(self):
- super().__init__()
-
- def do_hello(self, _: cmd2.Statement):
- self._cmd.poutput('Hello')
-
- def do_world(self, _: cmd2.Statement):
- self._cmd.poutput('World')
+from cmd2 import CommandSet
class ExampleApp(cmd2.Cmd):
"""
@@ -76,7 +60,22 @@ class ExampleApp(cmd2.Cmd):
super().__init__(*args, auto_load_commands=True, **kwargs)
def do_something(self, arg):
+ """Something Command."""
self.poutput('this is the something command')
+
+class AutoLoadCommandSet(CommandSet[ExampleApp]):
+ DEFAULT_CATEGORY = 'My Category'
+
+ def __init__(self):
+ super().__init__()
+
+ def do_hello(self, _: cmd2.Statement):
+ """Hello Command."""
+ self._cmd.poutput('Hello')
+
+ def do_world(self, _: cmd2.Statement):
+ """World Command."""
+ self._cmd.poutput('World')
```
### Manual CommandSet Construction
@@ -86,21 +85,7 @@ construct CommandSets and pass in the initializer to Cmd2.
```py
import cmd2
-from cmd2 import CommandSet, with_default_category
-
-@with_default_category('My Category')
-class CustomInitCommandSet(CommandSet):
- def __init__(self, arg1, arg2):
- super().__init__()
-
- self._arg1 = arg1
- self._arg2 = arg2
-
- def do_show_arg1(self, _: cmd2.Statement):
- self._cmd.poutput(f'Arg1: {self._arg1}')
-
- def do_show_arg2(self, _: cmd2.Statement):
- self._cmd.poutput(f'Arg2: {self._arg2}')
+from cmd2 import CommandSet
class ExampleApp(cmd2.Cmd):
"""
@@ -111,9 +96,27 @@ class ExampleApp(cmd2.Cmd):
super().__init__(*args, auto_load_commands=True, **kwargs)
def do_something(self, arg):
+ """Something Command."""
self.last_result = 5
self.poutput('this is the something command')
+class CustomInitCommandSet(CommandSet[ExampleApp]):
+ DEFAULT_CATEGORY = 'My Category'
+
+ def __init__(self, arg1, arg2):
+ super().__init__()
+
+ self._arg1 = arg1
+ self._arg2 = arg2
+
+ def do_show_arg1(self, _: cmd2.Statement):
+ """Show Arg 1."""
+ self._cmd.poutput(f'Arg1: {self._arg1}')
+
+ def do_show_arg2(self, _: cmd2.Statement):
+ """Show Arg 2."""
+ self._cmd.poutput(f'Arg2: {self._arg2}')
+
def main():
my_commands = CustomInitCommandSet(1, 2)
@@ -121,6 +124,33 @@ def main():
app.cmdloop()
```
+### Type Hinting and self.\_cmd
+
+When a `CommandSet` is registered, its `_cmd` property is populated with a reference to the
+`cmd2.Cmd` instance. `CommandSet` is a
+[generic](https://docs.python.org/3/library/typing.html#typing.Generic) class, allowing you to
+specify the specific `cmd2.Cmd` subclass it expects to be loaded into.
+
+By parameterizing the inheritance with your application class, your IDE and static analysis tools
+(like Mypy) will know the exact type of `self._cmd`. This provides full autocompletion and type
+validation when accessing custom attributes or methods on your main application instance.
+
+```py
+import cmd2
+from cmd2 import CommandSet
+
+class MyApp(cmd2.Cmd):
+ def __init__(self):
+ super().__init__()
+ self.custom_state = "Some important data"
+
+class MyCommands(CommandSet[MyApp]):
+ def do_check_state(self, _: cmd2.Statement):
+ # Type checkers know self._cmd is an instance of MyApp
+ # and can validate the 'custom_state' attribute exists.
+ self._cmd.poutput(f"State: {self._cmd.custom_state}")
+```
+
### Dynamic Commands
You can also dynamically load and unload commands by installing and removing CommandSets at runtime.
@@ -131,30 +161,36 @@ You may need to disable command auto-loading if you need to dynamically load com
```py
import argparse
import cmd2
-from cmd2 import CommandSet, with_argparser, with_category, with_default_category
+from cmd2 import CommandSet, with_argparser, with_category
+
+class LoadableFruits(CommandSet["ExampleApp"]):
+ DEFAULT_CATEGORY = 'Fruits'
-@with_default_category('Fruits')
-class LoadableFruits(CommandSet):
def __init__(self):
super().__init__()
def do_apple(self, _: cmd2.Statement):
+ """Apple Command."""
self._cmd.poutput('Apple')
def do_banana(self, _: cmd2.Statement):
+ """Banana Command."""
self._cmd.poutput('Banana')
-@with_default_category('Vegetables')
-class LoadableVegetables(CommandSet):
+class LoadableVegetables(CommandSet["ExampleApp"]):
+ DEFAULT_CATEGORY = 'Vegetables'
+
def __init__(self):
super().__init__()
def do_arugula(self, _: cmd2.Statement):
+ """Arugula Command."""
self._cmd.poutput('Arugula')
def do_bokchoy(self, _: cmd2.Statement):
+ """Bok Choy Command."""
self._cmd.poutput('Bok Choy')
@@ -176,6 +212,7 @@ class ExampleApp(cmd2.Cmd):
@with_argparser(load_parser)
@with_category('Command Loading')
def do_load(self, ns: argparse.Namespace):
+ """Load Command."""
if ns.cmds == 'fruits':
try:
self.register_command_set(self._fruits)
@@ -192,6 +229,7 @@ class ExampleApp(cmd2.Cmd):
@with_argparser(load_parser)
def do_unload(self, ns: argparse.Namespace):
+ """Unload Command."""
if ns.cmds == 'fruits':
self.unregister_command_set(self._fruits)
self.poutput('Fruits unloaded')
@@ -211,21 +249,21 @@ if __name__ == '__main__':
The following functions are called at different points in the [CommandSet][cmd2.CommandSet] life
cycle.
-[on_register][cmd2.command_definition.CommandSet.on_register] - Called by `cmd2.Cmd` as the first
-step to registering a `CommandSet`. The commands defined in this class have not be added to the CLI
-object at this point. Subclasses can override this to perform any initialization requiring access to
-the Cmd object (e.g. configure commands and their parsers based on CLI state data).
+[on_register][cmd2.command_set.CommandSet.on_register] - Called by `cmd2.Cmd` as the first step to
+registering a `CommandSet`. The commands defined in this class have not be added to the CLI object
+at this point. Subclasses can override this to perform any initialization requiring access to the
+Cmd object (e.g. configure commands and their parsers based on CLI state data).
-[on_registered][cmd2.command_definition.CommandSet.on_registered] - Called by `cmd2.Cmd` after a
+[on_registered][cmd2.command_set.CommandSet.on_registered] - Called by `cmd2.Cmd` after a
`CommandSet` is registered and all its commands have been added to the CLI. Subclasses can override
this to perform custom steps related to the newly added commands (e.g. setting them to a disabled
state).
-[on_unregister][cmd2.command_definition.CommandSet.on_unregister] - Called by `cmd2.Cmd` as the
-first step to unregistering a `CommandSet`. Subclasses can override this to perform any cleanup
-steps which require their commands being registered in the CLI.
+[on_unregister][cmd2.command_set.CommandSet.on_unregister] - Called by `cmd2.Cmd` as the first step
+to unregistering a `CommandSet`. Subclasses can override this to perform any cleanup steps which
+require their commands being registered in the CLI.
-[on_unregistered][cmd2.command_definition.CommandSet.on_unregistered] - Called by `cmd2.Cmd` after a
+[on_unregistered][cmd2.command_set.CommandSet.on_unregistered] - Called by `cmd2.Cmd` after a
`CommandSet` has been unregistered and all its commands removed from the CLI. Subclasses can
override this to perform remaining cleanup steps.
@@ -254,15 +292,17 @@ a base command and each CommandSet adds a subcommand to it.
```py
import argparse
import cmd2
-from cmd2 import CommandSet, with_argparser, with_category, with_default_category
+from cmd2 import CommandSet, with_argparser, with_category
+
+class LoadableFruits(CommandSet["ExampleApp"]):
+ DEFAULT_CATEGORY = 'Fruits'
-@with_default_category('Fruits')
-class LoadableFruits(CommandSet):
def __init__(self):
super().__init__()
def do_apple(self, _: cmd2.Statement):
+ """Apple Command."""
self._cmd.poutput('Apple')
banana_parser = cmd2.Cmd2ArgumentParser()
@@ -274,12 +314,14 @@ class LoadableFruits(CommandSet):
self._cmd.poutput('cutting banana: ' + ns.direction)
-@with_default_category('Vegetables')
-class LoadableVegetables(CommandSet):
+class LoadableVegetables(CommandSet["ExampleApp"]):
+ DEFAULT_CATEGORY = 'Vegetables'
+
def __init__(self):
super().__init__()
def do_arugula(self, _: cmd2.Statement):
+ """Arugula Command."""
self._cmd.poutput('Arugula')
bokchoy_parser = cmd2.Cmd2ArgumentParser()
@@ -287,6 +329,7 @@ class LoadableVegetables(CommandSet):
@cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser)
def cut_bokchoy(self, _: argparse.Namespace):
+ """Cut bok choy."""
self._cmd.poutput('Bok Choy')
@@ -308,6 +351,7 @@ class ExampleApp(cmd2.Cmd):
@with_argparser(load_parser)
@with_category('Command Loading')
def do_load(self, ns: argparse.Namespace):
+ """Load Command."""
if ns.cmds == 'fruits':
try:
self.register_command_set(self._fruits)
@@ -324,6 +368,7 @@ class ExampleApp(cmd2.Cmd):
@with_argparser(load_parser)
def do_unload(self, ns: argparse.Namespace):
+ """Unload Command."""
if ns.cmds == 'fruits':
self.unregister_command_set(self._fruits)
self.poutput('Fruits unloaded')
@@ -337,7 +382,8 @@ class ExampleApp(cmd2.Cmd):
@with_argparser(cut_parser)
def do_cut(self, ns: argparse.Namespace):
- handler = ns.cmd2_handler.get()
+ """Cut Command."""
+ handler = ns.cmd2_subcmd_handler
if handler is not None:
# Call whatever subcommand function was selected
handler(ns)
diff --git a/docs/features/os.md b/docs/features/os.md
index 4dad65b11..114e38dec 100644
--- a/docs/features/os.md
+++ b/docs/features/os.md
@@ -77,23 +77,23 @@ user to enter commands, which are then executed by your program.
You may want to execute commands in your program without prompting the user for any input. There are
several ways you might accomplish this task. The easiest one is to pipe commands and their arguments
into your program via standard input. You don't need to do anything to your program in order to use
-this technique. Here's a demonstration using the `examples/transcript_example.py` included in the
+this technique. Here's a demonstration using the `examples/cmd_as_argument.py` included in the
source code of `cmd2`:
- $ echo "speak -p some words" | python examples/transcript_example.py
+ $ echo "speak -p some words" | python examples/cmd_as_argument.py
omesay ordsway
Using this same approach you could create a text file containing the commands you would like to run,
one command per line in the file. Say your file was called `somecmds.txt`. To run the commands in
the text file using your `cmd2` program (from a Windows command prompt):
- c:\cmd2> type somecmds.txt | python.exe examples/transcript_example.py
+ c:\cmd2> type somecmds.txt | python.exe examples/cmd_as_argument.py
omesay ordsway
By default, `cmd2` programs also look for commands passed as arguments from the operating system
shell, and execute those commands before entering the command loop:
- $ python examples/transcript_example.py help
+ $ python examples/cmd_as_argument.py help
Documented Commands
───────────────────
@@ -107,8 +107,8 @@ example, you might have a command inside your `cmd2` program which itself accept
maybe even option strings. Say you wanted to run the `speak` command from the operating system
shell, but have it say it in pig latin:
- $ python examples/transcript_example.py speak -p hello there
- python transcript_example.py speak -p hello there
+ $ python examples/cmd_as_argument.py speak -p hello there
+ python cmd_as_argument.py speak -p hello there
usage: speak [-h] [-p] [-s] [-r REPEAT] words [words ...]
speak: error: the following arguments are required: words
*** Unknown syntax: -p
@@ -131,7 +131,7 @@ Check the source code of this example, especially the `main()` function, to see
Alternatively you can simply wrap the command plus arguments in quotes (either single or double
quotes):
- $ python examples/transcript_example.py "speak -p hello there"
+ $ python examples/cmd_as_argument.py "speak -p hello there"
ellohay heretay
(Cmd)
@@ -157,6 +157,6 @@ quits while returning an exit code:
Here is another example using `quit`:
- $ python examples/transcript_example.py "speak -p hello there" quit
+ $ python examples/cmd_as_argument.py "speak -p hello there" quit
ellohay heretay
$
diff --git a/docs/features/prompt.md b/docs/features/prompt.md
index 2ff3ae0d4..fdb4e2391 100644
--- a/docs/features/prompt.md
+++ b/docs/features/prompt.md
@@ -28,23 +28,72 @@ for an example of dynamically updating the prompt.
## Asynchronous Feedback
-`cmd2` provides these functions to provide asynchronous feedback to the user without interfering
-with the command line. This means the feedback is provided to the user when they are still entering
-text at the prompt. To use this functionality, the application must be running in a terminal that
-supports [VT100](https://en.wikipedia.org/wiki/VT100) control characters and `readline`. Linux, Mac,
-and Windows 10 and greater all support these.
+`cmd2` provides a function to deliver asynchronous feedback to the user without interfering with the
+command line. This allows feedback to be provided while the user is still entering text at the
+prompt.
-- [cmd2.Cmd.async_alert][]
-- [cmd2.Cmd.async_update_prompt][]
-- [cmd2.Cmd.async_refresh_prompt][]
-- [cmd2.Cmd.need_prompt_refresh][]
+- [cmd2.Cmd.add_alert][]
-`cmd2` also provides a function to change the title of the terminal window. This feature requires
-the application be running in a terminal that supports VT100 control characters. Linux, Mac, and
-Windows 10 and greater all support these.
+### Asynchronous Feedback Mechanisms
+
+Alerts can interact with the CLI in two ways:
+
+1. **Message Printing**: It can print a message directly above the current prompt line.
+1. **Prompt Updates**: It can dynamically replace the text of the active prompt to reflect changing
+ state.
+
+!!! note
+
+ To ensure the user interface remains accurate, a prompt update is ignored if the alert
+ was created before the current prompt was rendered. This prevents older alerts from overwriting a newer
+ prompt, though the alert's message will still be printed.
+
+### Terminal Window Management
+
+`cmd2` also provides a function to change the title of the terminal window.
- [cmd2.Cmd.set_window_title][]
The easiest way to understand these functions is to see the
[async_printing.py](https://github.com/python-cmd2/cmd2/blob/main/examples/async_printing.py)
example for a demonstration.
+
+## Bottom Toolbar
+
+`cmd2` supports an optional, persistent bottom toolbar that is always visible at the bottom of the
+terminal window while the application is idle and waiting for input.
+
+### Enabling the Toolbar
+
+To enable the toolbar, set `bottom_toolbar=True` in the [cmd2.Cmd.__init__][] constructor:
+
+```py
+class App(cmd2.Cmd):
+ def __init__(self):
+ super().__init__(bottom_toolbar=True)
+```
+
+### Customizing Toolbar Content
+
+You can customize the content of the toolbar by overriding the [cmd2.Cmd.get_bottom_toolbar][]
+method. This method should return either a string or a list of `(style, text)` tuples for formatted
+text.
+
+```py
+ def get_bottom_toolbar(self) -> list[str | tuple[str, str]] | None:
+ return [
+ ('ansigreen', 'My Application Name'),
+ ('', ' - '),
+ ('ansiyellow', 'Current Status: Idle'),
+ ]
+```
+
+### Refreshing the Toolbar
+
+Since the toolbar is rendered by `prompt-toolkit` as part of the prompt, it is naturally redrawn
+whenever the prompt is refreshed. If you want the toolbar to update automatically (for example, to
+display a clock), you can use a background thread to call `app.invalidate()` periodically.
+
+See the
+[getting_started.py](https://github.com/python-cmd2/cmd2/blob/main/examples/getting_started.py)
+example for a demonstration of this technique.
diff --git a/docs/features/settings.md b/docs/features/settings.md
index 02ee3399a..84cc01616 100644
--- a/docs/features/settings.md
+++ b/docs/features/settings.md
@@ -37,11 +37,6 @@ This setting can be one of three values:
stripped.
- `Always` - ANSI escape sequences are always passed through to the output
-### always_show_hint
-
-If `True`, display tab completion hint even when completion suggestions print. The default value of
-this setting is `False`.
-
### debug
The default value of this setting is `False`, which causes the `cmd2.Cmd.pexcept` method to only
@@ -68,14 +63,14 @@ If `True` the output is sent to `stdout` (which is often the screen but may be
[redirected](./redirection.md#output-redirection-and-pipes)). The feedback output will be mixed in
with and indistinguishable from output generated with `cmd2.Cmd.poutput`.
-### max_completion_items
+### max_completion_table_items
-Maximum number of CompletionItems to display during tab completion. A CompletionItem is a special
-kind of tab completion hint which displays both a value and description and uses one line for each
-hint. Tab complete the `set` command for an example.
+The maximum number of items to display in a completion table. A completion table is a special kind
+of completion hint which displays details about items being completed. Tab complete the `set`
+command for an example.
-If the number of tab completion hints exceeds `max_completion_items`, then they will be displayed in
-the typical columnized format and will not include the description text of the CompletionItem.
+If the number of completion suggestions exceeds `max_completion_table_items`, then no table will
+appear.
### quiet
diff --git a/docs/features/startup_commands.md b/docs/features/startup_commands.md
index 87daf0bc9..7bf65f4dc 100644
--- a/docs/features/startup_commands.md
+++ b/docs/features/startup_commands.md
@@ -16,7 +16,7 @@ program. `cmd2` interprets each argument as a separate command, so you should en
in quotation marks if it is more than a one-word command. You can use either single or double quotes
for this purpose.
- $ python examples/transcript_example.py "say hello" "say Gracie" quit
+ $ python examples/cmd_as_argument.py "say hello" "say Gracie" quit
hello
Gracie
diff --git a/docs/features/transcripts.md b/docs/features/transcripts.md
deleted file mode 100644
index 4e3f2bca5..000000000
--- a/docs/features/transcripts.md
+++ /dev/null
@@ -1,182 +0,0 @@
-# Transcripts
-
-A transcript is both the input and output of a successful session of a `cmd2`-based app which is
-saved to a text file. With no extra work on your part, your app can play back these transcripts as a
-regression test. Transcripts can contain regular expressions, which provide the flexibility to match
-responses from commands that produce dynamic or variable output.
-
-## Creating From History
-
-A transcript can be automatically generated based upon commands previously executed in the _history_
-using `history -t`:
-
-```text
-(Cmd) help
-...
-(Cmd) help history
-...
-(Cmd) history 1:2 -t transcript.txt
-2 commands and outputs saved to transcript file 'transcript.txt'
-```
-
-This is by far the easiest way to generate a transcript.
-
-!!! warning
-
- Make sure you use the **poutput()** method in your `cmd2` application for generating command output. This method of the [cmd2.Cmd][] class ensures that output is properly redirected when redirecting to a file, piping to a shell command, and when generating a transcript.
-
-## Creating From A Script File
-
-A transcript can also be automatically generated from a script file using `run_script -t`:
-
-```text
-(Cmd) run_script scripts/script.txt -t transcript.txt
-2 commands and their outputs saved to transcript file 'transcript.txt'
-(Cmd)
-```
-
-This is a particularly attractive option for automatically regenerating transcripts for regression
-testing as your `cmd2` application changes.
-
-## Creating Manually
-
-Here's a transcript created from `python examples/transcript_example.py`:
-
-```text
-(Cmd) say -r 3 Goodnight, Gracie
-Goodnight, Gracie
-Goodnight, Gracie
-Goodnight, Gracie
-(Cmd) mumble maybe we could go to lunch
-like maybe we ... could go to hmmm lunch
-(Cmd) mumble maybe we could go to lunch
-well maybe we could like go to er lunch right?
-```
-
-This transcript has three commands: they are on the lines that begin with the prompt. The first
-command looks like this:
-
-```text
-(Cmd) say -r 3 Goodnight, Gracie
-```
-
-Following each command is the output generated by that command.
-
-The transcript ignores all lines in the file until it reaches the first line that begins with the
-prompt. You can take advantage of this by using the first lines of the transcript as comments:
-
-```text
-# Lines at the beginning of the transcript that do not
-; start with the prompt i.e. '(Cmd) ' are ignored.
-/* You can use them for comments. */
-
-All six of these lines before the first prompt are treated as comments.
-
-(Cmd) say -r 3 Goodnight, Gracie
-Goodnight, Gracie
-Goodnight, Gracie
-Goodnight, Gracie
-(Cmd) mumble maybe we could go to lunch
-like maybe we ... could go to hmmm lunch
-(Cmd) mumble maybe we could go to lunch
-maybe we could like go to er lunch right?
-```
-
-In this example I've used several different commenting styles, and even bare text. It doesn't matter
-what you put on those beginning lines. Everything before:
-
-```text
-(Cmd) say -r 3 Goodnight, Gracie
-```
-
-will be ignored.
-
-## Regular Expressions
-
-If we used the above transcript as-is, it would likely fail. As you can see, the `mumble` command
-doesn't always return the same thing: it inserts random words into the input.
-
-Regular expressions can be included in the response portion of a transcript, and are surrounded by
-slashes:
-
-```text
-(Cmd) mumble maybe we could go to lunch
-/.*\bmaybe\b.*\bcould\b.*\blunch\b.*/
-(Cmd) mumble maybe we could go to lunch
-/.*\bmaybe\b.*\bcould\b.*\blunch\b.*/
-```
-
-Without creating a tutorial on regular expressions, this one matches anything that has the words
-`maybe`, `could`, and `lunch` in that order. It doesn't ensure that `we` or `go` or `to` appear in
-the output, but it does work if mumble happens to add words to the beginning or the end of the
-output.
-
-Since the output could be multiple lines long, `cmd2` uses multiline regular expression matching,
-and also uses the `DOTALL` flag. These two flags subtly change the behavior of commonly used special
-characters like `.`, `^` and `$`, so you may want to double check the
-[Python regular expression documentation](https://docs.python.org/3/library/re.html).
-
-If your output has slashes in it, you will need to escape those slashes so the stuff between them is
-not interpreted as a regular expression. In this transcript:
-
-```text
-(Cmd) say cd /usr/local/lib/python3.11/site-packages
-/usr/local/lib/python3.11/site-packages
-```
-
-the output contains slashes. The text between the first slash and the second slash, will be
-interpreted as a regular expression, and those two slashes will not be included in the comparison.
-When replayed, this transcript would therefore fail. To fix it, we could either write a regular
-expression to match the path instead of specifying it verbatim, or we can escape the slashes:
-
-```text
-(Cmd) say cd /usr/local/lib/python3.11/site-packages
-\/usr\/local\/lib\/python3.11\/site-packages
-```
-
-!!! warning
-
- Be aware of trailing spaces and newlines. Your commands might output trailing spaces which are impossible to see. Instead of leaving them invisible, you can add a regular expression to match them, so that you can see where they are when you look at the transcript:
-
- ```text
- (Cmd) set editor
- editor: vim/ /
- ```
-
- Some terminal emulators strip trailing space when you copy text from them. This could make the actual data generated by your app different than the text you pasted into the transcript, and it might not be readily obvious why the transcript is not passing. Consider using [redirection](./redirection.md) to the clipboard or to a file to ensure you accurately capture the output of your command.
-
- If you aren't using regular expressions, make sure the newlines at the end of your transcript exactly match the output of your commands. A common cause of a failing transcript is an extra or missing newline.
-
- If you are using regular expressions, be aware that depending on how you write your regex, the newlines after the regex may or may not matter. `\Z` matches _after_ the newline at the end of the string, whereas `$` matches the end of the string _or_ just before a newline.
-
-## Running A Transcript
-
-Once you have created a transcript, it's easy to have your application play it back and check the
-output. From within the `examples/` directory:
-
-```text
-$ python transcript_example.py --test transcript_regex.txt
-.
-----------------------------------------------------------------------
-Ran 1 test in 0.013s
-
-OK
-```
-
-The output will look familiar if you use `unittest`, because that's exactly what happens. Each
-command in the transcript is run, and we `assert` the output matches the expected result from the
-transcript.
-
-!!! note
-
- If you have passed an `allow_cli_args` parameter containing `False` to `cmd2.Cmd.__init__` in order to disable parsing of command line arguments at invocation, then the use of `-t` or `--test` to run transcript testing is automatically disabled. In this case, you can alternatively provide a value for the optional `transcript_files` when constructing the instance of your `cmd2.Cmd` derived class in order to cause a transcript test to run:
-
- ```py
- from cmd2 import Cmd
- class App(Cmd):
- # customized attributes and methods here
-
- if __name__ == '__main__':
- app = App(transcript_files=['exampleSession.txt'])
- app.cmdloop()
- ```
diff --git a/docs/migrating/incompatibilities.md b/docs/migrating/incompatibilities.md
index df9668c02..7c7f5044a 100644
--- a/docs/migrating/incompatibilities.md
+++ b/docs/migrating/incompatibilities.md
@@ -38,8 +38,8 @@ new input is needed; if it is nonempty, its elements will be processed in order,
the prompt.
Since version 0.9.13 `cmd2` has removed support for `Cmd.cmdqueue`. Because `cmd2` supports running
-commands via the main `cmdloop()`, text scripts, Python scripts, transcripts, and history replays,
-the only way to preserve consistent behavior across these methods was to eliminate the command
-queue. Additionally, reasoning about application behavior is much easier without this queue present.
+commands via the main `cmdloop()`, text scripts, Python scripts, and history replays, the only way
+to preserve consistent behavior across these methods was to eliminate the command queue.
+Additionally, reasoning about application behavior is much easier without this queue present.
[cmd]: https://docs.python.org/3/library/cmd
diff --git a/docs/migrating/next_steps.md b/docs/migrating/next_steps.md
index cff4913c5..886f06010 100644
--- a/docs/migrating/next_steps.md
+++ b/docs/migrating/next_steps.md
@@ -9,13 +9,13 @@ leveraging other `cmd2` features. The three ideas here will get you started. Bro
For all but the simplest of commands, it's probably easier to use
[argparse](https://docs.python.org/3/library/argparse.html) to parse user input than to do it
manually yourself for each command. `cmd2` provides a `@with_argparser()` decorator which associates
-an `ArgumentParser` object with one of your commands. Using this method will:
+a `Cmd2ArgumentParser` object with one of your commands. Using this method will:
1. Pass your command a
[Namespace](https://docs.python.org/3/library/argparse.html#argparse.Namespace) containing the
arguments instead of a string of text
2. Properly handle quoted string input from your users
-3. Create a help message for you based on the `ArgumentParser`
+3. Create a help message for you based on the `Cmd2ArgumentParser`
4. Give you a big head start adding [Tab Completion](../features/completion.md) to your application
5. Make it much easier to implement subcommands (i.e. `git` has a bunch of subcommands such as
`git pull`, `git diff`, etc)
diff --git a/docs/migrating/why.md b/docs/migrating/why.md
index c73e8ae61..c0aee99db 100644
--- a/docs/migrating/why.md
+++ b/docs/migrating/why.md
@@ -32,10 +32,12 @@ top-notch interactive command-line experience for their users.
After switching from [cmd][cmd] to `cmd2`, your application will have the following new features and
capabilities, without you having to do anything:
-- More robust [History](../features/history.md). Both [cmd][cmd] and `cmd2` have readline history,
- but `cmd2` also has a robust `history` command which allows you to edit prior commands in a text
- editor of your choosing, re-run multiple commands at a time, save prior commands as a script to be
- executed later, and much more.
+- More robust [History](../features/history.md). Both [cmd][cmd] and `cmd2` provide familiar
+ readline-style history, but `cmd2` utilizes the powerful
+ [prompt-toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit) library for a pure
+ Python, fully cross-platform experience. Additionally, `cmd2` has a robust `history` command which
+ allows you to edit prior commands in a text editor of your choosing, re-run multiple commands at a
+ time, save prior commands as a script to be executed later, and much more.
- Users can redirect output to a file or pipe it to some other operating system command. You did
remember to use `self.stdout` instead of `sys.stdout` in all of your print functions, right? If
you did, then this will work out of the box. If you didn't, you'll have to go back and fix them.
@@ -50,9 +52,6 @@ capabilities, without you having to do anything:
- [Clipboard Integration](../features/clipboard.md) allows you to save command output to the
operating system clipboard.
- A built-in [Timer](../features/misc.md#Timer) can show how long it takes a command to execute
-- A [Transcript](../features/transcripts.md) is a file which contains both the input and output of a
- successful session of a `cmd2`-based app. The transcript can be played back into the app as a unit
- test.
## Next Steps
diff --git a/docs/overview/alternatives.md b/docs/overview/alternatives.md
index 1d7061d4b..ec79594c5 100644
--- a/docs/overview/alternatives.md
+++ b/docs/overview/alternatives.md
@@ -16,28 +16,24 @@ clicks. However, programming a `textual` application is not as straightforward a
Several Python packages exist for building interactive command-line applications approximately
similar in concept to [cmd](https://docs.python.org/3/library/cmd.html) applications. None of them
share `cmd2`'s close ties to [cmd](https://docs.python.org/3/library/cmd.html), but they may be
-worth investigating nonetheless. Two of the most mature and full-featured are:
-
-- [Python Prompt Toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit)
-- [Click](https://click.palletsprojects.com)
-
-[Python Prompt Toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit) is a library for
-building powerful interactive command lines and terminal applications in Python. It provides a lot
-of advanced visual features like syntax highlighting, bottom bars, and the ability to create
-fullscreen apps.
+worth investigating nonetheless.
[Click](https://click.palletsprojects.com) is a Python package for creating beautiful command line
interfaces in a composable way with as little code as necessary. It is more geared towards command
line utilities instead of command line interpreters, but it can be used for either.
-Getting a working command-interpreter application based on either
-[Python Prompt Toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit) or
+Getting a working command-interpreter application based on
[Click](https://click.palletsprojects.com) requires a good deal more effort and boilerplate code
than `cmd2`. `cmd2` focuses on providing an excellent out-of-the-box experience with as many useful
features as possible built in for free with as little work required on the developer's part as
possible. We believe that `cmd2` provides developers the easiest way to write a command-line
interpreter, while allowing a good experience for end users.
+Historically, [Python Prompt Toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit) was
+considered a powerful but more complex alternative to `cmd2`. However, as of version 4.0.0, `cmd2`
+utilizes `prompt-toolkit` internally as its REPL engine. This means you get the power and
+cross-platform compatibility of `prompt-toolkit` with the easy-to-use API of `cmd2`.
+
If you are seeking a visually richer end-user experience and don't mind investing more development
time, we would recommend checking out [Textual](https://github.com/Textualize/textual) as this can
be used to build very sophisticated user interfaces in a terminal that are more akin to feature-rich
diff --git a/docs/overview/installation.md b/docs/overview/installation.md
index d9c2cc9d0..5f8504658 100644
--- a/docs/overview/installation.md
+++ b/docs/overview/installation.md
@@ -88,36 +88,3 @@ If you wish to permanently uninstall `cmd2`, this can also easily be done with
[pip](https://pypi.org/project/pip):
$ pip uninstall cmd2
-
-## readline Considerations
-
-`cmd2` heavily relies on Python's built-in
-[readline](https://docs.python.org/3/library/readline.html) module for its tab completion
-capabilities. Tab completion for `cmd2` applications is only tested against :simple-gnu:
-[GNU Readline](https://tiswww.case.edu/php/chet/readline/rltop.html) or libraries fully compatible
-with it. It does not work properly with the :simple-netbsd: NetBSD
-[Editline](http://thrysoee.dk/editline/) library (`libedit`) which is similar, but not identical to
-GNU Readline. `cmd2` will disable all tab-completion support if an incompatible version of
-`readline` is found.
-
-When installed using `pip`, `uv`, or similar Python packaging tool on either `macOS` or `Windows`,
-`cmd2` will automatically install a compatible version of readline.
-
-Most Linux operating systems come with a compatible version of readline. However, if you are using a
-tool like `uv` to install Python on your system and configure a virtual environment, `uv` installed
-versions of Python come with `libedit`. If you are using `cmd2` on Linux with a version of Python
-installed via `uv`, you will likely need to manually add the `gnureadline` Python module to your
-`uv` virtual environment.
-
-```sh
-uv pip install gnureadline
-```
-
-macOS comes with the [libedit](http://thrysoee.dk/editline/) library which is similar, but not
-identical, to GNU Readline. Tab completion for `cmd2` applications is only tested against GNU
-Readline. In this case you just need to install the `gnureadline` Python package which is statically
-linked against GNU Readline:
-
-```shell
-$ pip install -U gnureadline
-```
diff --git a/docs/overview/integrating.md b/docs/overview/integrating.md
index b119deb86..66408d6c7 100644
--- a/docs/overview/integrating.md
+++ b/docs/overview/integrating.md
@@ -13,16 +13,3 @@ We recommend that you follow the advice given by the Python Packaging User Guide
[install_requires](https://packaging.python.org/discussions/install-requires-vs-requirements/). By
setting an upper bound on the allowed version, you can ensure that your project does not
inadvertently get installed with an incompatible future version of `cmd2`.
-
-## OS Considerations
-
-If you would like to use [Tab Completion](../features/completion.md), then you need a compatible
-version of [readline](https://tiswww.case.edu/php/chet/readline/rltop.html) installed on your
-operating system (OS). `cmd2` forces a sane install of `readline` on both `Windows` and `macOS`, but
-does not do so on `Linux`. If for some reason, you have a version of Python on a Linux OS who's
-built-in `readline` module is based on the
-[Editline Library (libedit)](https://www.thrysoee.dk/editline/) instead of `readline`, you will need
-to manually add a dependency on `gnureadline`. Make sure to include the following dependency in your
-`pyproject.toml` or `setup.py`:
-
- 'gnureadline'
diff --git a/docs/upgrades.md b/docs/upgrades.md
index 7c26afacd..a89e248f2 100644
--- a/docs/upgrades.md
+++ b/docs/upgrades.md
@@ -1,5 +1,55 @@
# cmd2 Major Versions Upgrades
+## Upgrading to cmd2 4.x from 3.x
+
+The biggest change from 3.x to 4.x is the migration from the GNU Readline library to
+[prompt-toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit) for the Read-Eval-Print
+Loop (REPL). This change provides a pure Python replacement for readline that is fully
+cross-platform compatible and offers significant enhancements in terms of features and
+extensibility.
+
+### prompt-toolkit Migration
+
+`cmd2` now utilizes `prompt-toolkit` for all input handling, history navigation, and tab completion.
+This removes the dependency on the GNU Readline library (and the `gnureadline` package on macOS).
+
+#### Key Benefits of prompt-toolkit
+
+- **Cross-platform**: Works identically on Windows, macOS, and Linux without external dependencies.
+- **Asynchronous Output**: Better handling of asynchronous printing to the terminal without
+ interfering with the user's input line.
+- **Enhanced UI**: Support for advanced UI elements like bottom toolbars and floating menus.
+- **Multiline Editing**: Improved support for editing commands that span multiple lines.
+
+#### Breaking Changes and Incompatibilities
+
+While we have strived to maintain compatibility, there are some differences:
+
+- **Key Bindings**: Key bindings are now managed by `prompt-toolkit`. While it defaults to
+ Emacs-style bindings (similar to readline), customization now uses the `prompt-toolkit`
+ [KeyBindings](https://python-prompt-toolkit.readthedocs.io/en/stable/pages/advanced_topics/key_bindings.html)
+ API.
+- **Input Hooks**: Readline-specific input hooks are no longer supported.
+
+### Bottom Toolbar
+
+`cmd2` now supports an optional, persistent bottom toolbar. This can be used to display information
+such as the application name, current state, or even a real-time clock.
+
+- **Enablement**: Set `bottom_toolbar=True` in the [cmd2.Cmd.__init__][] constructor.
+- **Customization**: Override the [cmd2.Cmd.get_bottom_toolbar][] method to return the content you
+ wish to display. The content can be a simple string or a list of `(style, text)` tuples for
+ formatted text with colors.
+
+See the
+[getting_started.py](https://github.com/python-cmd2/cmd2/blob/main/examples/getting_started.py)
+example for a demonstration of how to implement a background thread that refreshes the toolbar
+periodically.
+
+### Deleted Modules
+
+Removed `rl_utils.py` and `terminal_utils.py` since `prompt-toolkit` provides this functionality.
+
## Upgrading to cmd2 3.x from 2.x
For details about all of the changes in the 3.0.0 release, please refer to
diff --git a/examples/README.md b/examples/README.md
index 42102dac7..46ba97f23 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -19,6 +19,8 @@ each:
via the `cmd2.with_argparser` decorator
- [async_call.py](https://github.com/python-cmd2/cmd2/blob/main/examples/async_call.py)
- Shows how to make a call to an async function from a cmd2 command.
+- [async_commands.py](https://github.com/python-cmd2/cmd2/blob/main/examples/async_commands.py)
+ - A simple example demonstrating how to run async commands in a cmd2 app
- [async_printing.py](https://github.com/python-cmd2/cmd2/blob/main/examples/async_printing.py)
- Shows how to asynchronously print alerts, update the prompt in realtime, and change the window
title
@@ -32,15 +34,16 @@ each:
- Example that demonstrates the `CommandSet` features for modularizing commands and demonstrates
all main capabilities including basic CommandSets, dynamic loading an unloading, using
subcommands, etc.
+- [completion_item_choices.py](https://github.com/python-cmd2/cmd2/blob/main/examples/completion_item_choices.py)
+ - Demonstrates using CompletionItem instances as elements in an argparse choices list.
- [custom_parser.py](https://github.com/python-cmd2/cmd2/blob/main/examples/custom_parser.py)
- Demonstrates how to create your own custom `Cmd2ArgumentParser`
- [custom_types.py](https://github.com/python-cmd2/cmd2/blob/main/examples/custom_types.py)
- Some useful custom argument types
- [default_categories.py](https://github.com/python-cmd2/cmd2/blob/main/examples/default_categories.py)
- - Demonstrates usage of `@with_default_category` decorator to group and categorize commands and
- `CommandSet` use
+ - Demonstrates usage of the `DEFAULT_CATEGORY` class variable to group and categorize commands.
- [dynamic_commands.py](https://github.com/python-cmd2/cmd2/blob/main/examples/dynamic_commands.py)
- - Shows how `do_*` commands can be dynamically created programatically at runtime
+ - Shows how `do_*` commands can be dynamically created programmatically at runtime
- [environment.py](https://github.com/python-cmd2/cmd2/blob/main/examples/environment.py)
- Shows how to create custom `cmd2.Settable` parameters which serve as internal environment
variables
@@ -61,7 +64,7 @@ each:
- Shows how to use various `cmd2` application lifecycle hooks
- [migrating.py](https://github.com/python-cmd2/cmd2/blob/main/examples/migrating.py)
- A simple `cmd` application that you can migrate to `cmd2` by changing one line
-- [modular_commands.py](https://github.com/python-cmd2/cmd2/blob/main/examples/modular_commands.py)
+- [modular_commandsets.py](https://github.com/python-cmd2/cmd2/blob/main/examples/modular_commandsets.py)
- Complex example demonstrating a variety of methods to load `CommandSets` using a mix of
command decorators
- [paged_output.py](https://github.com/python-cmd2/cmd2/blob/main/examples/paged_output.py)
@@ -75,8 +78,8 @@ each:
- Shows how cmd2's built-in `run_pyscript` command can provide advanced Python scripting of cmd2
applications
- [read_input.py](https://github.com/python-cmd2/cmd2/blob/main/examples/read_input.py)
- - Demonstrates the various ways to call `cmd2.Cmd.read_input()` for input history and tab
- completion
+ - Demonstrates the various ways to call `cmd2.Cmd.read_input()` and `cmd2.Cmd.read_secret()` for
+ input history, tab completion, and password masking
- [remove_builtin_commands.py](https://github.com/python-cmd2/cmd2/blob/main/examples/remove_builtin_commands.py)
- Shows how to remove any built-in cmd2 commands you do not want to be present in your cmd2
application
@@ -91,7 +94,5 @@ each:
- Shell script that launches two applications using tmux in different windows/tabs
- [tmux_split.sh](https://github.com/python-cmd2/cmd2/blob/main/examples/tmux_split.sh)
- Shell script that launches two applications using tmux in a split pane view
-- [transcript_example.py](https://github.com/python-cmd2/cmd2/blob/main/examples/transcript_example.py)
- - This example is intended to demonstrate `cmd2's` build-in transcript testing capability
- [unicode_commands.py](https://github.com/python-cmd2/cmd2/blob/main/examples/unicode_commands.py)
- Shows that cmd2 supports unicode everywhere, including within command names
diff --git a/examples/argparse_completion.py b/examples/argparse_completion.py
index 8d2c3dca1..aa4b24677 100755
--- a/examples/argparse_completion.py
+++ b/examples/argparse_completion.py
@@ -9,6 +9,7 @@
from rich.text import Text
from cmd2 import (
+ Choices,
Cmd,
Cmd2ArgumentParser,
Cmd2Style,
@@ -19,19 +20,19 @@
)
# Data source for argparse.choices
-food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato']
+food_item_strs = ["Pizza", "Ham", "Ham Sandwich", "Potato"]
class ArgparseCompletion(Cmd):
def __init__(self) -> None:
super().__init__(include_ipy=True)
- self.sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball']
+ self.sport_item_strs = ["Bat", "Basket", "Basketball", "Football", "Space Ball"]
- def choices_provider(self) -> list[str]:
+ def choices_provider(self) -> Choices:
"""A choices provider is useful when the choice list is based on instance data of your application."""
- return self.sport_item_strs
+ return Choices.from_values(self.sport_item_strs)
- def choices_completion_error(self) -> list[str]:
+ def choices_completion_error(self) -> Choices:
"""CompletionErrors can be raised if an error occurs while tab completing.
Example use cases
@@ -39,11 +40,11 @@ def choices_completion_error(self) -> list[str]:
- A previous command line argument that determines the data set being completed is invalid
"""
if self.debug:
- return self.sport_item_strs
+ return Choices.from_values(self.sport_item_strs)
raise CompletionError("debug must be true")
- def choices_completion_item(self) -> list[CompletionItem]:
- """Return CompletionItem instead of strings. These give more context to what's being tab completed."""
+ def choices_completion_tables(self) -> Choices:
+ """Return CompletionItems with completion tables. These give more context to what's being tab completed."""
fancy_item = Text.assemble(
"These things can\ncontain newlines and\n",
Text("styled text!!", style=Style(color=Color.BRIGHT_YELLOW, underline=True)),
@@ -58,28 +59,30 @@ def choices_completion_item(self) -> list[CompletionItem]:
table_item.add_row("Yes, it's true.", "CompletionItems can")
table_item.add_row("even display description", "data in tables!")
- items = {
+ item_dict = {
1: "My item",
2: "Another item",
3: "Yet another item",
4: fancy_item,
5: table_item,
}
- return [CompletionItem(item_id, [description]) for item_id, description in items.items()]
- def choices_arg_tokens(self, arg_tokens: dict[str, list[str]]) -> list[str]:
+ completion_items = [CompletionItem(item_id, table_data=[description]) for item_id, description in item_dict.items()]
+ return Choices(items=completion_items)
+
+ def choices_arg_tokens(self, arg_tokens: dict[str, list[str]]) -> Choices:
"""If a choices or completer function/method takes a value called arg_tokens, then it will be
passed a dictionary that maps the command line tokens up through the one being completed
- to their argparse argument name. All values of the arg_tokens dictionary are lists, even if
+ to their argparse destination name. All values of the arg_tokens dictionary are lists, even if
a particular argument expects only 1 token.
"""
# Check if choices_provider flag has appeared
- values = ['choices_provider', 'flag']
- if 'choices_provider' in arg_tokens:
- values.append('is {}'.format(arg_tokens['choices_provider'][0]))
+ values = ["choices_provider", "flag"]
+ if "choices_provider" in arg_tokens:
+ values.append("is {}".format(arg_tokens["choices_provider"][0]))
else:
- values.append('not supplied')
- return values
+ values.append("not supplied")
+ return Choices.from_values(values)
# Parser for example command
example_parser = Cmd2ArgumentParser(
@@ -88,35 +91,35 @@ def choices_arg_tokens(self, arg_tokens: dict[str, list[str]]) -> list[str]:
# Tab complete from a list using argparse choices. Set metavar if you don't
# want the entire choices list showing in the usage text for this command.
- example_parser.add_argument('--choices', choices=food_item_strs, metavar="CHOICE", help="tab complete using choices")
+ example_parser.add_argument("--choices", choices=food_item_strs, metavar="CHOICE", help="tab complete using choices")
# Tab complete from choices provided by a choices_provider
example_parser.add_argument(
- '--choices_provider', choices_provider=choices_provider, help="tab complete using a choices_provider"
+ "--choices_provider", choices_provider=choices_provider, help="tab complete using a choices_provider"
)
# Tab complete using a completer
- example_parser.add_argument('--completer', completer=Cmd.path_complete, help="tab complete using a completer")
+ example_parser.add_argument("--completer", completer=Cmd.path_complete, help="tab complete using a completer")
# Demonstrate raising a CompletionError while tab completing
example_parser.add_argument(
- '--completion_error',
+ "--completion_error",
choices_provider=choices_completion_error,
help="raise a CompletionError while tab completing if debug is False",
)
- # Demonstrate returning CompletionItems instead of strings
+ # Demonstrate use of completion table
example_parser.add_argument(
- '--completion_item',
- choices_provider=choices_completion_item,
+ "--completion_table",
+ choices_provider=choices_completion_tables,
metavar="ITEM_ID",
- descriptive_headers=["Description"],
- help="demonstrate use of CompletionItems",
+ table_columns=["Description"],
+ help="demonstrate use of completion table",
)
# Demonstrate use of arg_tokens dictionary
example_parser.add_argument(
- '--arg_tokens', choices_provider=choices_arg_tokens, help="demonstrate use of arg_tokens dictionary"
+ "--arg_tokens", choices_provider=choices_arg_tokens, help="demonstrate use of arg_tokens dictionary"
)
@with_argparser(example_parser)
@@ -125,7 +128,7 @@ def do_example(self, _: argparse.Namespace) -> None:
self.poutput("I do nothing")
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
app = ArgparseCompletion()
diff --git a/examples/argparse_example.py b/examples/argparse_example.py
index 564f4be92..40c9c66d9 100755
--- a/examples/argparse_example.py
+++ b/examples/argparse_example.py
@@ -21,9 +21,9 @@
from cmd2.string_utils import stylize
# Command categories
-ARGPARSE_USAGE = 'Argparse Basic Usage'
-ARGPARSE_PRINTING = 'Argparse Printing'
-ARGPARSE_SUBCOMMANDS = 'Argparse Subcommands'
+ARGPARSE_USAGE = "Argparse Basic Usage"
+ARGPARSE_PRINTING = "Argparse Printing"
+ARGPARSE_SUBCOMMANDS = "Argparse Subcommands"
class ArgparsingApp(cmd2.Cmd):
@@ -31,16 +31,16 @@ def __init__(self, color: str) -> None:
"""Cmd2 application for demonstrating the use of argparse for command argument parsing."""
super().__init__(include_ipy=True)
self.intro = stylize(
- 'cmd2 has awesome decorators to make it easy to use Argparse to parse command arguments', style=color
+ "cmd2 has awesome decorators to make it easy to use Argparse to parse command arguments", style=color
)
## ------ Basic examples of using argparse for command argument parsing -----
# do_fsize parser
- fsize_parser = cmd2.Cmd2ArgumentParser(description='Obtain the size of a file')
- fsize_parser.add_argument('-c', '--comma', action='store_true', help='add comma for thousands separator')
- fsize_parser.add_argument('-u', '--unit', choices=['MB', 'KB'], help='unit to display size in')
- fsize_parser.add_argument('file_path', help='path of file', completer=cmd2.Cmd.path_complete)
+ fsize_parser = cmd2.Cmd2ArgumentParser(description="Obtain the size of a file")
+ fsize_parser.add_argument("-c", "--comma", action="store_true", help="add comma for thousands separator")
+ fsize_parser.add_argument("-u", "--unit", choices=["MB", "KB"], help="unit to display size in")
+ fsize_parser.add_argument("file_path", help="path of file", completer=cmd2.Cmd.path_complete)
@cmd2.with_argparser(fsize_parser)
@cmd2.with_category(ARGPARSE_USAGE)
@@ -54,21 +54,21 @@ def do_fsize(self, args: argparse.Namespace) -> None:
self.perror(f"Error retrieving size: {ex}")
return
- if args.unit == 'KB':
+ if args.unit == "KB":
size //= 1024
- elif args.unit == 'MB':
+ elif args.unit == "MB":
size //= 1024 * 1024
else:
- args.unit = 'bytes'
+ args.unit = "bytes"
size = round(size, 2)
- size_str = f'{size:,}' if args.comma else f'{size}'
- self.poutput(f'{size_str} {args.unit}')
+ size_str = f"{size:,}" if args.comma else f"{size}"
+ self.poutput(f"{size_str} {args.unit}")
# do_pow parser
pow_parser = cmd2.Cmd2ArgumentParser()
- pow_parser.add_argument('base', type=int)
- pow_parser.add_argument('exponent', type=int, choices=range(-5, 6))
+ pow_parser.add_argument("base", type=int)
+ pow_parser.add_argument("exponent", type=int, choices=range(-5, 6))
@cmd2.with_argparser(pow_parser)
@cmd2.with_category(ARGPARSE_USAGE)
@@ -77,59 +77,59 @@ def do_pow(self, args: argparse.Namespace) -> None:
:param args: argparse arguments
"""
- self.poutput(f'{args.base} ** {args.exponent} == {args.base**args.exponent}')
+ self.poutput(f"{args.base} ** {args.exponent} == {args.base**args.exponent}")
## ------ Examples displaying how argparse arguments are passed to commands by printing them out -----
argprint_parser = cmd2.Cmd2ArgumentParser()
- argprint_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay')
- argprint_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE')
- argprint_parser.add_argument('-r', '--repeat', type=int, help='output [n] times')
- argprint_parser.add_argument('words', nargs='+', help='words to print')
+ argprint_parser.add_argument("-p", "--piglatin", action="store_true", help="atinLay")
+ argprint_parser.add_argument("-s", "--shout", action="store_true", help="N00B EMULATION MODE")
+ argprint_parser.add_argument("-r", "--repeat", type=int, help="output [n] times")
+ argprint_parser.add_argument("words", nargs="+", help="words to print")
@cmd2.with_argparser(argprint_parser)
@cmd2.with_category(ARGPARSE_PRINTING)
def do_print_args(self, args: argparse.Namespace) -> None:
"""Print the arpgarse argument list this command was called with."""
- self.poutput(f'print_args was called with the following\n\targuments: {args!r}')
+ self.poutput(f"print_args was called with the following\n\targuments: {args!r}")
unknownprint_parser = cmd2.Cmd2ArgumentParser()
- unknownprint_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay')
- unknownprint_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE')
- unknownprint_parser.add_argument('-r', '--repeat', type=int, help='output [n] times')
+ unknownprint_parser.add_argument("-p", "--piglatin", action="store_true", help="atinLay")
+ unknownprint_parser.add_argument("-s", "--shout", action="store_true", help="N00B EMULATION MODE")
+ unknownprint_parser.add_argument("-r", "--repeat", type=int, help="output [n] times")
@cmd2.with_argparser(unknownprint_parser, with_unknown_args=True)
@cmd2.with_category(ARGPARSE_PRINTING)
def do_print_unknown(self, args: argparse.Namespace, unknown: list[str]) -> None:
"""Print the arpgarse argument list this command was called with, including unknown arguments."""
- self.poutput(f'print_unknown was called with the following arguments\n\tknown: {args!r}\n\tunknown: {unknown}')
+ self.poutput(f"print_unknown was called with the following arguments\n\tknown: {args!r}\n\tunknown: {unknown}")
## ------ Examples demonstrating how to use argparse subcommands -----
# create the top-level parser for the base command
calculate_parser = cmd2.Cmd2ArgumentParser(description="Perform simple mathematical calculations.")
- calculate_subparsers = calculate_parser.add_subparsers(title='operation', help='Available operations', required=True)
+ calculate_subparsers = calculate_parser.add_subparsers(title="operation", help="Available operations", required=True)
# create the parser for the "add" subcommand
add_description = "Add two numbers"
add_parser = cmd2.Cmd2ArgumentParser("add", description=add_description)
- add_parser.add_argument('num1', type=int, help='The first number')
- add_parser.add_argument('num2', type=int, help='The second number')
+ add_parser.add_argument("num1", type=int, help="The first number")
+ add_parser.add_argument("num2", type=int, help="The second number")
# create the parser for the "add" subcommand
subtract_description = "Subtract two numbers"
subtract_parser = cmd2.Cmd2ArgumentParser("subtract", description=subtract_description)
- subtract_parser.add_argument('num1', type=int, help='The first number')
- subtract_parser.add_argument('num2', type=int, help='The second number')
+ subtract_parser.add_argument("num1", type=int, help="The first number")
+ subtract_parser.add_argument("num2", type=int, help="The second number")
# subcommand functions for the calculate command
- @cmd2.as_subcommand_to('calculate', 'add', add_parser, help=add_description.lower())
+ @cmd2.as_subcommand_to("calculate", "add", add_parser, help=add_description.lower())
def add(self, args: argparse.Namespace) -> None:
"""add subcommand of calculate command."""
result = args.num1 + args.num2
self.poutput(f"{args.num1} + {args.num2} = {result}")
- @cmd2.as_subcommand_to('calculate', 'subtract', subtract_parser, help=subtract_description.lower())
+ @cmd2.as_subcommand_to("calculate", "subtract", subtract_parser, help=subtract_description.lower())
def subtract(self, args: argparse.Namespace) -> None:
"""subtract subcommand of calculate command."""
result = args.num1 - args.num2
@@ -139,24 +139,23 @@ def subtract(self, args: argparse.Namespace) -> None:
@cmd2.with_category(ARGPARSE_SUBCOMMANDS)
def do_calculate(self, args: argparse.Namespace) -> None:
"""Calculate a simple mathematical operation on two integers."""
- handler = args.cmd2_handler.get()
- handler(args)
+ args.cmd2_subcmd_handler(args)
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
from cmd2.colors import Color
# You can do your custom Argparse parsing here to meet your application's needs
- parser = cmd2.Cmd2ArgumentParser(description='Process the arguments however you like.')
+ parser = cmd2.Cmd2ArgumentParser(description="Process the arguments however you like.")
# Add an argument which we will pass to the app to change some behavior
parser.add_argument(
- '-c',
- '--color',
+ "-c",
+ "--color",
choices=[Color.RED, Color.ORANGE1, Color.YELLOW, Color.GREEN, Color.BLUE, Color.PURPLE, Color.VIOLET, Color.WHITE],
- help='Color of intro text',
+ help="Color of intro text",
)
# Parse the arguments
diff --git a/examples/async_call.py b/examples/async_call.py
index f802858b0..a3dc8bae8 100755
--- a/examples/async_call.py
+++ b/examples/async_call.py
@@ -12,9 +12,7 @@
def run_async(coro) -> concurrent.futures.Future:
- """
- Await a coroutine from a synchronous function/method.
- """
+ """Await a coroutine from a synchronous function/method."""
global _event_loop # noqa: PLW0603
@@ -24,7 +22,7 @@ def run_async(coro) -> concurrent.futures.Future:
_event_loop = asyncio.new_event_loop()
thread = threading.Thread(
target=_event_loop.run_forever,
- name='Async Runner',
+ name="Async Runner",
daemon=True,
)
thread.start()
@@ -33,48 +31,46 @@ def run_async(coro) -> concurrent.futures.Future:
async def async_wait(duration: float) -> float:
- """
- Example async function that is called from a synchronous cmd2 command
- """
+ """Example async function that is called from a synchronous cmd2 command"""
await asyncio.sleep(duration)
return duration
class AsyncCallExample(cmd2.Cmd):
- """
- A simple cmd2 application.
+ """A simple cmd2 application.
+
Demonstrates how to run an async function from a cmd2 command.
"""
def do_async_wait(self, _: str) -> None:
- """
- Waits asynchronously. Example cmd2 command that calls an async function.
+ """Waits asynchronously.
+
+ Example cmd2 command that calls an async function.
"""
waitable = run_async(async_wait(0.1))
- self.poutput('Begin waiting...')
+ self.poutput("Begin waiting...")
# Wait for coroutine to complete and get its return value:
res = waitable.result()
- self.poutput(f'Done waiting: {res}')
+ self.poutput(f"Done waiting: {res}")
return
def do_hello_world(self, _: str) -> None:
+ """Prints a simple greeting.
+
+ Just a typical (synchronous) cmd2 command
"""
- Prints a simple greeting. Just a typical (synchronous) cmd2 command
- """
- self.poutput('Hello World')
+ self.poutput("Hello World")
-async def main() -> int:
- """
- Having this async ensures presence of the top level event loop.
- """
+def main() -> int:
+ """Main entry point for the example."""
app = AsyncCallExample()
app.set_window_title("Call to an Async Function Test")
return app.cmdloop()
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
- sys.exit(asyncio.run(main(), debug=True))
+ sys.exit(main())
diff --git a/examples/async_commands.py b/examples/async_commands.py
new file mode 100755
index 000000000..5e18214de
--- /dev/null
+++ b/examples/async_commands.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python
+"""A simple example demonstrating how to run async commands in a cmd2 app.
+
+It also demonstrates how to configure keybindings to run a handler method on
+key-combo press and how to display colored output above the prompt.
+"""
+
+import asyncio
+import functools
+import random
+import shutil
+import threading
+from collections.abc import Callable
+from typing import (
+ Any,
+)
+
+from prompt_toolkit import ANSI, print_formatted_text
+from prompt_toolkit.key_binding import KeyBindings
+from rich.text import Text
+
+import cmd2
+
+# Global event loop and lock
+_event_loop: asyncio.AbstractEventLoop | None = None
+_event_lock = threading.Lock()
+
+
+def _get_event_loop() -> asyncio.AbstractEventLoop:
+ """Get or create the background event loop."""
+ global _event_loop # noqa: PLW0603
+
+ if _event_loop is None:
+ with _event_lock:
+ if _event_loop is None:
+ _event_loop = asyncio.new_event_loop()
+ thread = threading.Thread(
+ target=_event_loop.run_forever,
+ name="Async Runner",
+ daemon=True,
+ )
+ thread.start()
+ return _event_loop
+
+
+def with_async_loop(func: Callable[..., Any], cancel_on_interrupt: bool = True) -> Callable[..., Any]:
+ """Decorate an async ``do_*`` command method to give it access to the event loop.
+
+
+ This decorator wraps a do_* command method. When the command is executed,
+ it submits the coroutine returned by the method to a background asyncio loop
+ and waits for the result synchronously (blocking the cmd2 loop, as expected
+ for a synchronous command).
+
+ :param func: do_* method to wrap
+ :param cancel_on_interrupt: if True, cancel any running async task on an interrupt;
+ if False, leave any async task running
+ """
+
+ @functools.wraps(func)
+ def wrapper(self: cmd2.Cmd, *args: Any, **kwargs: Any) -> Any:
+ loop = _get_event_loop()
+ coro = func(self, *args, **kwargs)
+ future = asyncio.run_coroutine_threadsafe(coro, loop)
+ try:
+ return future.result()
+ except KeyboardInterrupt:
+ if cancel_on_interrupt:
+ future.cancel()
+ raise
+
+ return wrapper
+
+
+class AsyncCommandsApp(cmd2.Cmd):
+ """Example cmd2 application with async commands."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.intro = 'Welcome to the Async Commands example. Type "help" to see available commands.'
+
+ if self.main_session.key_bindings is None:
+ self.main_session.key_bindings = KeyBindings()
+
+ # Add a custom key binding for +T that calls a method so it has access to self
+ @self.main_session.key_bindings.add("c-t")
+ def _(_event: Any) -> None:
+ self.handle_control_t(_event)
+
+ @with_async_loop
+ async def do_my_async(self, _: cmd2.Statement) -> None:
+ """An example async command that simulates work."""
+ self.poutput("Starting async work...")
+ # simulate some async I/O
+ await asyncio.sleep(1.0)
+ self.poutput("Async work complete!")
+
+ @with_async_loop
+ async def do_fetch(self, _: cmd2.Statement) -> None:
+ """Simulate fetching data asynchronously."""
+ self.poutput("Fetching data...")
+ data = await self._fake_fetch()
+ self.poutput(f"Received: {data}")
+
+ async def _fake_fetch(self) -> str:
+ await asyncio.sleep(0.5)
+ return "Some Data"
+
+ def do_sync_command(self, _: cmd2.Statement) -> None:
+ """A normal synchronous command."""
+ self.poutput("This is a normal synchronous command.")
+
+ def handle_control_t(self, _event) -> None:
+ """Handler method for +T key press.
+
+ Prints 'fnord' above the prompt in a random color and random position.
+ """
+ word = "fnord"
+
+ # Generate a random RGB color tuple
+ r = random.randint(0, 255)
+ g = random.randint(0, 255)
+ b = random.randint(0, 255)
+
+ # Get terminal width to calculate padding for right-alignment
+ cols, _ = shutil.get_terminal_size()
+ extra_width = cols - len(word) - 1
+ padding_size = random.randint(0, extra_width)
+ padding = " " * padding_size
+
+ # Use rich to generate the the overall text to print out
+ text = Text()
+ text.append(padding)
+ text.append(word, style=f"rgb({r},{g},{b})")
+
+ print_formatted_text(ANSI(cmd2.rich_utils.rich_text_to_string(text)))
+
+
+if __name__ == "__main__":
+ import sys
+
+ app = AsyncCommandsApp()
+ sys.exit(app.cmdloop())
diff --git a/examples/async_printing.py b/examples/async_printing.py
index f1eac85d4..13c58b126 100755
--- a/examples/async_printing.py
+++ b/examples/async_printing.py
@@ -30,17 +30,17 @@
class AlerterApp(cmd2.Cmd):
"""An app that shows off async_alert() and async_update_prompt()."""
- def __init__(self, *args, **kwargs) -> None:
+ def __init__(self) -> None:
"""Initializer."""
- super().__init__(*args, **kwargs)
+ super().__init__()
self.prompt = "(APR)> "
# The thread that will asynchronously alert the user of events
self._stop_event = threading.Event()
- self._alerter_thread = threading.Thread()
+ self._add_alert_thread = threading.Thread()
self._alert_count = 0
- self._next_alert_time = 0
+ self._next_alert_time = 0.0
# Create some hooks to handle the starting and stopping of our thread
self.register_preloop_hook(self._preloop_hook)
@@ -48,38 +48,30 @@ def __init__(self, *args, **kwargs) -> None:
def _preloop_hook(self) -> None:
"""Start the alerter thread."""
- # This runs after cmdloop() acquires self.terminal_lock, which will be locked until the prompt appears.
- # Therefore this is the best place to start the alerter thread since there is no risk of it alerting
- # before the prompt is displayed. You can also start it via a command if its not something that should
- # be running during the entire application. See do_start_alerts().
self._stop_event.clear()
-
- self._alerter_thread = threading.Thread(name='alerter', target=self._alerter_thread_func)
- self._alerter_thread.start()
+ self._add_alert_thread = threading.Thread(name="alerter", target=self._add_alerts_func)
+ self._add_alert_thread.start()
def _postloop_hook(self) -> None:
"""Stops the alerter thread."""
- # After this function returns, cmdloop() releases self.terminal_lock which could make the alerter
- # thread think the prompt is on screen. Therefore this is the best place to stop the alerter thread.
- # You can also stop it via a command. See do_stop_alerts().
self._stop_event.set()
- if self._alerter_thread.is_alive():
- self._alerter_thread.join()
+ if self._add_alert_thread.is_alive():
+ self._add_alert_thread.join()
- def do_start_alerts(self, _) -> None:
+ def do_start_alerts(self, _: cmd2.Statement) -> None:
"""Starts the alerter thread."""
- if self._alerter_thread.is_alive():
+ if self._add_alert_thread.is_alive():
print("The alert thread is already started")
else:
self._stop_event.clear()
- self._alerter_thread = threading.Thread(name='alerter', target=self._alerter_thread_func)
- self._alerter_thread.start()
+ self._add_alert_thread = threading.Thread(name="alerter", target=self._add_alerts_func)
+ self._add_alert_thread.start()
- def do_stop_alerts(self, _) -> None:
+ def do_stop_alerts(self, _: cmd2.Statement) -> None:
"""Stops the alerter thread."""
self._stop_event.set()
- if self._alerter_thread.is_alive():
- self._alerter_thread.join()
+ if self._add_alert_thread.is_alive():
+ self._add_alert_thread.join()
else:
print("The alert thread is already stopped")
@@ -111,11 +103,11 @@ def _get_alerts(self) -> list[str]:
return alerts
- def _generate_alert_str(self) -> str:
+ def _build_alert_str(self) -> str:
"""Combines alerts into one string that can be printed to the terminal
:return: the alert string.
"""
- alert_str = ''
+ alert_str = ""
alerts = self._get_alerts()
longest_alert = max(ALERTS, key=len)
@@ -123,21 +115,21 @@ def _generate_alert_str(self) -> str:
for i, cur_alert in enumerate(alerts):
# Use padding to center the alert
- padding = ' ' * int((num_asterisks - len(cur_alert)) / 2)
+ padding = " " * int((num_asterisks - len(cur_alert)) / 2)
if i > 0:
- alert_str += '\n'
- alert_str += '*' * num_asterisks + '\n'
- alert_str += padding + cur_alert + padding + '\n'
- alert_str += '*' * num_asterisks + '\n'
+ alert_str += "\n"
+ alert_str += "*" * num_asterisks + "\n"
+ alert_str += padding + cur_alert + padding + "\n"
+ alert_str += "*" * num_asterisks + "\n"
return alert_str
- def _generate_colored_prompt(self) -> str:
- """Randomly generates a colored prompt
+ def _build_colored_prompt(self) -> str:
+ """Randomly builds a colored prompt
:return: the new prompt.
"""
- rand_num = random.randint(1, 20)
+ rand_num = random.randint(1, 6)
status_color = Color.DEFAULT
@@ -154,42 +146,32 @@ def _generate_colored_prompt(self) -> str:
return stylize(self.visible_prompt, style=status_color)
- def _alerter_thread_func(self) -> None:
+ def _add_alerts_func(self) -> None:
"""Prints alerts and updates the prompt any time the prompt is showing."""
self._alert_count = 0
self._next_alert_time = 0
while not self._stop_event.is_set():
- # Always acquire terminal_lock before printing alerts or updating the prompt.
- # To keep the app responsive, do not block on this call.
- if self.terminal_lock.acquire(blocking=False):
- # Get any alerts that need to be printed
- alert_str = self._generate_alert_str()
-
- # Generate a new prompt
- new_prompt = self._generate_colored_prompt()
-
- # Check if we have alerts to print
- if alert_str:
- # new_prompt is an optional parameter to async_alert()
- self.async_alert(alert_str, new_prompt)
- new_title = f"Alerts Printed: {self._alert_count}"
- self.set_window_title(new_title)
+ # Get any alerts that need to be printed
+ alert_str = self._build_alert_str()
- # Otherwise check if the prompt needs to be updated or refreshed
- elif self.prompt != new_prompt:
- self.async_update_prompt(new_prompt)
+ # Build a new prompt
+ new_prompt = self._build_colored_prompt()
- elif self.need_prompt_refresh():
- self.async_refresh_prompt()
+ # Check if we have alerts to print
+ if alert_str:
+ self.add_alert(msg=alert_str, prompt=new_prompt)
+ new_title = f"Alerts Printed: {self._alert_count}"
+ self.set_window_title(new_title)
- # Don't forget to release the lock
- self.terminal_lock.release()
+ # Otherwise check if the prompt needs to be updated or refreshed
+ elif self.prompt != new_prompt:
+ self.add_alert(prompt=new_prompt)
self._stop_event.wait(0.5)
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
app = AlerterApp()
diff --git a/examples/basic_completion.py b/examples/basic_completion.py
index fd3a5c639..96cb663cf 100755
--- a/examples/basic_completion.py
+++ b/examples/basic_completion.py
@@ -1,88 +1,45 @@
#!/usr/bin/env python
"""A simple example demonstrating how to enable tab completion by assigning a completer function to do_* commands.
+
This also demonstrates capabilities of the following completer features included with cmd2:
- CompletionError exceptions
- delimiter_complete()
-- flag_based_complete() (see note below)
-- index_based_complete() (see note below).
-flag_based_complete() and index_based_complete() are basic methods and should only be used if you are not
-familiar with argparse. The recommended approach for tab completing positional tokens and flags is to use
-argparse-based completion. For an example integrating tab completion with argparse, see argparse_completion.py
+The recommended approach for tab completing is to use argparse-based completion.
+For an example integrating tab completion with argparse, see argparse_completion.py.
"""
import functools
+from typing import NoReturn
import cmd2
-# List of strings used with completion functions
-food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato']
-sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball']
-
# This data is used to demonstrate delimiter_complete
file_strs = [
- '/home/user/file.db',
- '/home/user/file space.db',
- '/home/user/another.db',
- '/home/other user/maps.db',
- '/home/other user/tests.db',
+ "/home/user/file.db",
+ "/home/user/file space.db",
+ "/home/user/another.db",
+ "/home/other user/maps.db",
+ "/home/other user/tests.db",
]
class BasicCompletion(cmd2.Cmd):
- def __init__(self, *args, **kwargs) -> None:
- super().__init__(*args, **kwargs)
-
- def do_flag_based(self, statement: cmd2.Statement) -> None:
- """Tab completes arguments based on a preceding flag using flag_based_complete
- -f, --food [completes food items]
- -s, --sport [completes sports]
- -p, --path [completes local file system paths].
- """
- self.poutput(f"Args: {statement.args}")
-
- def complete_flag_based(self, text, line, begidx, endidx) -> list[str]:
- """Completion function for do_flag_based."""
- flag_dict = {
- # Tab complete food items after -f and --food flags in command line
- '-f': food_item_strs,
- '--food': food_item_strs,
- # Tab complete sport items after -s and --sport flags in command line
- '-s': sport_item_strs,
- '--sport': sport_item_strs,
- # Tab complete using path_complete function after -p and --path flags in command line
- '-p': self.path_complete,
- '--path': self.path_complete,
- }
-
- return self.flag_based_complete(text, line, begidx, endidx, flag_dict=flag_dict)
-
- def do_index_based(self, statement: cmd2.Statement) -> None:
- """Tab completes first 3 arguments using index_based_complete."""
- self.poutput(f"Args: {statement.args}")
-
- def complete_index_based(self, text, line, begidx, endidx) -> list[str]:
- """Completion function for do_index_based."""
- index_dict = {
- 1: food_item_strs, # Tab complete food items at index 1 in command line
- 2: sport_item_strs, # Tab complete sport items at index 2 in command line
- 3: self.path_complete, # Tab complete using path_complete function at index 3 in command line
- }
-
- return self.index_based_complete(text, line, begidx, endidx, index_dict=index_dict)
+ def __init__(self) -> None:
+ super().__init__(auto_suggest=False, include_py=True)
def do_delimiter_complete(self, statement: cmd2.Statement) -> None:
"""Tab completes files from a list using delimiter_complete."""
self.poutput(f"Args: {statement.args}")
# Use a partialmethod to set arguments to delimiter_complete
- complete_delimiter_complete = functools.partialmethod(cmd2.Cmd.delimiter_complete, match_against=file_strs, delimiter='/')
+ complete_delimiter_complete = functools.partialmethod(cmd2.Cmd.delimiter_complete, match_against=file_strs, delimiter="/")
def do_raise_error(self, statement: cmd2.Statement) -> None:
"""Demonstrates effect of raising CompletionError."""
self.poutput(f"Args: {statement.args}")
- def complete_raise_error(self, _text, _line, _begidx, _endidx) -> list[str]:
+ def complete_raise_error(self, _text: str, _line: str, _begidx: int, _endidx: int) -> NoReturn:
"""CompletionErrors can be raised if an error occurs while tab completing.
Example use cases
@@ -92,7 +49,7 @@ def complete_raise_error(self, _text, _line, _begidx, _endidx) -> list[str]:
raise cmd2.CompletionError("This is how a CompletionError behaves")
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
app = BasicCompletion()
diff --git a/examples/cmd_as_argument.py b/examples/cmd_as_argument.py
index b9db4acd5..e3552b877 100755
--- a/examples/cmd_as_argument.py
+++ b/examples/cmd_as_argument.py
@@ -1,13 +1,10 @@
#!/usr/bin/env python
"""A sample application for cmd2.
-This example is very similar to transcript_example.py, but had additional
-code in main() that shows how to accept a command from
+This example has additional code in main() that shows how to accept a command from
the command line at invocation:
$ python cmd_as_argument.py speak -p hello there
-
-
"""
import argparse
@@ -19,28 +16,26 @@
class CmdLineApp(cmd2.Cmd):
"""Example cmd2 application."""
- # Setting this true makes it run a shell command if a cmd2/cmd command doesn't exist
- # default_to_shell = True # noqa: ERA001
- MUMBLES = ('like', '...', 'um', 'er', 'hmmm', 'ahh')
- MUMBLE_FIRST = ('so', 'like', 'well')
- MUMBLE_LAST = ('right?',)
+ MUMBLES = ("like", "...", "um", "er", "hmmm", "ahh")
+ MUMBLE_FIRST = ("so", "like", "well")
+ MUMBLE_LAST = ("right?",)
def __init__(self) -> None:
shortcuts = dict(cmd2.DEFAULT_SHORTCUTS)
- shortcuts.update({'&': 'speak'})
+ shortcuts.update({"&": "speak"})
# Set include_ipy to True to enable the "ipy" command which runs an interactive IPython shell
- super().__init__(allow_cli_args=False, include_ipy=True, multiline_commands=['orate'], shortcuts=shortcuts)
+ super().__init__(allow_cli_args=False, include_ipy=True, multiline_commands=["orate"], shortcuts=shortcuts)
self.self_in_py = True
self.maxrepeats = 3
# Make maxrepeats settable at runtime
- self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command', self))
+ self.add_settable(cmd2.Settable("maxrepeats", int, "max repetitions for speak command", self))
speak_parser = cmd2.Cmd2ArgumentParser()
- speak_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay')
- speak_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE')
- speak_parser.add_argument('-r', '--repeat', type=int, help='output [n] times')
- speak_parser.add_argument('words', nargs='+', help='words to say')
+ speak_parser.add_argument("-p", "--piglatin", action="store_true", help="atinLay")
+ speak_parser.add_argument("-s", "--shout", action="store_true", help="N00B EMULATION MODE")
+ speak_parser.add_argument("-r", "--repeat", type=int, help="output [n] times")
+ speak_parser.add_argument("words", nargs="+", help="words to say")
@cmd2.with_argparser(speak_parser)
def do_speak(self, args) -> None:
@@ -48,21 +43,21 @@ def do_speak(self, args) -> None:
words = []
for word in args.words:
if args.piglatin:
- word = f'{word[1:]}{word[0]}ay'
+ word = f"{word[1:]}{word[0]}ay"
if args.shout:
word = word.upper()
words.append(word)
repetitions = args.repeat or 1
for _ in range(min(repetitions, self.maxrepeats)):
# .poutput handles newlines, and accommodates output redirection too
- self.poutput(' '.join(words))
+ self.poutput(" ".join(words))
do_say = do_speak # now "say" is a synonym for "speak"
do_orate = do_speak # another synonym, but this one takes multi-line input
mumble_parser = cmd2.Cmd2ArgumentParser()
- mumble_parser.add_argument('-r', '--repeat', type=int, help='how many times to repeat')
- mumble_parser.add_argument('words', nargs='+', help='words to say')
+ mumble_parser.add_argument("-r", "--repeat", type=int, help="how many times to repeat")
+ mumble_parser.add_argument("words", nargs="+", help="words to say")
@cmd2.with_argparser(mumble_parser)
def do_mumble(self, args) -> None:
@@ -78,16 +73,16 @@ def do_mumble(self, args) -> None:
output.append(word)
if random.random() < 0.25:
output.append(random.choice(self.MUMBLE_LAST))
- self.poutput(' '.join(output))
+ self.poutput(" ".join(output))
def main(argv=None):
"""Run when invoked from the operating system shell."""
- parser = cmd2.Cmd2ArgumentParser(description='Commands as arguments')
- command_help = 'optional command to run, if no command given, enter an interactive shell'
- parser.add_argument('command', nargs='?', help=command_help)
- arg_help = 'optional arguments for command'
- parser.add_argument('command_args', nargs=argparse.REMAINDER, help=arg_help)
+ parser = cmd2.Cmd2ArgumentParser(description="Commands as arguments")
+ command_help = "optional command to run, if no command given, enter an interactive shell"
+ parser.add_argument("command", nargs="?", help=command_help)
+ arg_help = "optional arguments for command"
+ parser.add_argument("command_args", nargs=argparse.REMAINDER, help=arg_help)
args = parser.parse_args(argv)
@@ -96,7 +91,7 @@ def main(argv=None):
sys_exit_code = 0
if args.command:
# we have a command, run it and then exit
- c.onecmd_plus_hooks('{} {}'.format(args.command, ' '.join(args.command_args)))
+ c.onecmd_plus_hooks("{} {}".format(args.command, " ".join(args.command_args)))
else:
# we have no command, drop into interactive mode
sys_exit_code = c.cmdloop()
@@ -104,7 +99,7 @@ def main(argv=None):
return sys_exit_code
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
sys.exit(main())
diff --git a/examples/color.py b/examples/color.py
index e6e2cf26b..3440f3fce 100755
--- a/examples/color.py
+++ b/examples/color.py
@@ -19,11 +19,11 @@ class CmdLineApp(cmd2.Cmd):
def __init__(self) -> None:
# Set include_ipy to True to enable the "ipy" command which runs an interactive IPython shell
super().__init__(include_ipy=True)
- self.intro = 'Run the taste_the_rainbow command to see all of the colors available to you in cmd2.'
+ self.intro = "Run the taste_the_rainbow command to see all of the colors available to you in cmd2."
rainbow_parser = cmd2.Cmd2ArgumentParser()
- rainbow_parser.add_argument('-b', '--background', action='store_true', help='show background colors as well')
- rainbow_parser.add_argument('-p', '--paged', action='store_true', help='display output using a pager')
+ rainbow_parser.add_argument("-b", "--background", action="store_true", help="show background colors as well")
+ rainbow_parser.add_argument("-p", "--paged", action="store_true", help="display output using a pager")
@cmd2.with_argparser(rainbow_parser)
def do_taste_the_rainbow(self, args: argparse.Namespace) -> None:
@@ -44,7 +44,7 @@ def create_style(color: Color) -> Style:
self.poutput(output)
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
c = CmdLineApp()
diff --git a/examples/command_sets.py b/examples/command_sets.py
index ed51c6f4b..f8dacf270 100755
--- a/examples/command_sets.py
+++ b/examples/command_sets.py
@@ -6,7 +6,7 @@
most commands trivial because the intent is to focus on the CommandSet feature set.
The `AutoLoadCommandSet` is a basic command set which is loaded automatically at application startup and stays loaded until
-application exit. Ths is the simplest case of simply modularizing command definitions to different classes and/or files.
+application exit. This is the simplest case of simply modularizing command definitions to different classes and/or files.
The `LoadableFruits` and `LoadableVegetables` CommandSets are dynamically loadable and un-loadable at runtime using the `load`
and `unload` commands. This demonstrates the ability to load and unload CommandSets based on application state. Each of these
@@ -20,7 +20,6 @@
CommandSet,
with_argparser,
with_category,
- with_default_category,
)
COMMANDSET_BASIC = "Basic CommandSet"
@@ -29,67 +28,70 @@
COMMANDSET_SUBCOMMAND = "Subcommands with CommandSet"
-@with_default_category(COMMANDSET_BASIC)
-class AutoLoadCommandSet(CommandSet):
+class AutoLoadCommandSet(CommandSet[cmd2.Cmd]):
+ DEFAULT_CATEGORY = COMMANDSET_BASIC
+
def __init__(self) -> None:
"""CommandSet class for auto-loading commands at startup."""
super().__init__()
def do_hello(self, _: cmd2.Statement) -> None:
"""Print hello."""
- self._cmd.poutput('Hello')
+ self._cmd.poutput("Hello")
def do_world(self, _: cmd2.Statement) -> None:
"""Print World."""
- self._cmd.poutput('World')
+ self._cmd.poutput("World")
+
+class LoadableFruits(CommandSet[cmd2.Cmd]):
+ DEFAULT_CATEGORY = COMMANDSET_DYNAMIC
-@with_default_category(COMMANDSET_DYNAMIC)
-class LoadableFruits(CommandSet):
def __init__(self) -> None:
"""CommandSet class for dynamically loading commands related to fruits."""
super().__init__()
def do_apple(self, _: cmd2.Statement) -> None:
"""Print Apple."""
- self._cmd.poutput('Apple')
+ self._cmd.poutput("Apple")
def do_banana(self, _: cmd2.Statement) -> None:
"""Print Banana."""
- self._cmd.poutput('Banana')
+ self._cmd.poutput("Banana")
banana_description = "Cut a banana"
banana_parser = cmd2.Cmd2ArgumentParser(description=banana_description)
- banana_parser.add_argument('direction', choices=['discs', 'lengthwise'])
+ banana_parser.add_argument("direction", choices=["discs", "lengthwise"])
- @cmd2.as_subcommand_to('cut', 'banana', banana_parser, help=banana_description.lower())
+ @cmd2.as_subcommand_to("cut", "banana", banana_parser, help=banana_description.lower())
def cut_banana(self, ns: argparse.Namespace) -> None:
"""Cut banana."""
- self._cmd.poutput('cutting banana: ' + ns.direction)
+ self._cmd.poutput("cutting banana: " + ns.direction)
+
+class LoadableVegetables(CommandSet[cmd2.Cmd]):
+ DEFAULT_CATEGORY = COMMANDSET_DYNAMIC
-@with_default_category(COMMANDSET_DYNAMIC)
-class LoadableVegetables(CommandSet):
def __init__(self) -> None:
"""CommandSet class for dynamically loading commands related to vegetables."""
super().__init__()
def do_arugula(self, _: cmd2.Statement) -> None:
"Print Arguula."
- self._cmd.poutput('Arugula')
+ self._cmd.poutput("Arugula")
def do_bokchoy(self, _: cmd2.Statement) -> None:
"""Print Bok Choy."""
- self._cmd.poutput('Bok Choy')
+ self._cmd.poutput("Bok Choy")
bokchoy_description = "Cut some bokchoy"
bokchoy_parser = cmd2.Cmd2ArgumentParser(description=bokchoy_description)
- bokchoy_parser.add_argument('style', choices=['quartered', 'diced'])
+ bokchoy_parser.add_argument("style", choices=["quartered", "diced"])
- @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser, help=bokchoy_description.lower())
+ @cmd2.as_subcommand_to("cut", "bokchoy", bokchoy_parser, help=bokchoy_description.lower())
def cut_bokchoy(self, ns: argparse.Namespace) -> None:
"""Cut bokchoy."""
- self._cmd.poutput('Bok Choy: ' + ns.style)
+ self._cmd.poutput("Bok Choy: " + ns.style)
class CommandSetApp(cmd2.Cmd):
@@ -102,61 +104,61 @@ def __init__(self) -> None:
self.register_command_set(AutoLoadCommandSet())
- # Store the dyanmic CommandSet classes for ease of loading and unloading
+ # Store the dynamic CommandSet classes for ease of loading and unloading
self._fruits = LoadableFruits()
self._vegetables = LoadableVegetables()
- self.intro = 'The CommandSet feature allows defining commands in multiple files and the dynamic load/unload at runtime'
+ self.intro = "The CommandSet feature allows defining commands in multiple files and the dynamic load/unload at runtime"
load_parser = cmd2.Cmd2ArgumentParser()
- load_parser.add_argument('cmds', choices=['fruits', 'vegetables'])
+ load_parser.add_argument("cmds", choices=["fruits", "vegetables"])
@with_argparser(load_parser)
@with_category(COMMANDSET_LOAD_UNLOAD)
def do_load(self, ns: argparse.Namespace) -> None:
"""Load a CommandSet at runtime."""
- if ns.cmds == 'fruits':
+ if ns.cmds == "fruits":
try:
self.register_command_set(self._fruits)
- self.poutput('Fruits loaded')
+ self.poutput("Fruits loaded")
except ValueError:
- self.poutput('Fruits already loaded')
+ self.poutput("Fruits already loaded")
- if ns.cmds == 'vegetables':
+ if ns.cmds == "vegetables":
try:
self.register_command_set(self._vegetables)
- self.poutput('Vegetables loaded')
+ self.poutput("Vegetables loaded")
except ValueError:
- self.poutput('Vegetables already loaded')
+ self.poutput("Vegetables already loaded")
@with_argparser(load_parser)
@with_category(COMMANDSET_LOAD_UNLOAD)
def do_unload(self, ns: argparse.Namespace) -> None:
"""Unload a CommandSet at runtime."""
- if ns.cmds == 'fruits':
+ if ns.cmds == "fruits":
self.unregister_command_set(self._fruits)
- self.poutput('Fruits unloaded')
+ self.poutput("Fruits unloaded")
- if ns.cmds == 'vegetables':
+ if ns.cmds == "vegetables":
self.unregister_command_set(self._vegetables)
- self.poutput('Vegetables unloaded')
+ self.poutput("Vegetables unloaded")
cut_parser = cmd2.Cmd2ArgumentParser()
- cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut')
+ cut_subparsers = cut_parser.add_subparsers(title="item", help="item to cut")
@with_argparser(cut_parser)
@with_category(COMMANDSET_SUBCOMMAND)
def do_cut(self, ns: argparse.Namespace) -> None:
- """Intended to be used with dyanmically loaded subcommands specifically."""
- handler = ns.cmd2_handler.get()
+ """Intended to be used with dynamically loaded subcommands specifically."""
+ handler = ns.cmd2_subcmd_handler
if handler is not None:
handler(ns)
else:
# No subcommand was provided, so call help
- self.poutput('This command does nothing without sub-parsers registered')
- self.do_help('cut')
+ self.poutput("This command does nothing without sub-parsers registered")
+ self.do_help("cut")
-if __name__ == '__main__':
+if __name__ == "__main__":
app = CommandSetApp()
app.cmdloop()
diff --git a/examples/completion_item_choices.py b/examples/completion_item_choices.py
new file mode 100755
index 000000000..a69116b9c
--- /dev/null
+++ b/examples/completion_item_choices.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python
+"""
+Demonstrates using CompletionItem instances as elements in an argparse choices list.
+
+Technical Note:
+ Using 'choices' is best for fixed datasets that do not change during the
+ application's lifecycle. For dynamic data (e.g., results from a database or
+ file system), use a 'choices_provider' instead.
+
+Key strengths of this approach:
+ 1. Command handlers receive fully-typed domain objects directly in the
+ argparse.Namespace, eliminating manual lookups from string keys.
+ 2. Choices carry tab-completion UI enhancements (display_meta, table_data)
+ that are not supported by standard argparse string choices.
+ 3. Provides a single source of truth for completion UI, input validation,
+ and object mapping.
+
+This demo showcases two distinct approaches:
+ 1. Simple: Using CompletionItems with basic types (ints) to add UI metadata
+ (display_meta) while letting argparse handle standard type conversion.
+ 2. Advanced: Using a custom 'text' alias and a type converter to map a friendly
+ string (e.g., 'alice') directly to a complex object (Account).
+"""
+
+import argparse
+import sys
+from typing import (
+ ClassVar,
+ cast,
+)
+
+from cmd2 import (
+ Cmd,
+ Cmd2ArgumentParser,
+ CompletionItem,
+ with_argparser,
+)
+
+# -----------------------------------------------------------------------------
+# Simple Example: Basic types with UI metadata
+# -----------------------------------------------------------------------------
+# Integers with metadata. No 'text' override or custom type converter needed.
+# argparse will handle 'type=int' and validate it against the CompletionItem.value.
+id_choices = [
+ CompletionItem(101, display_meta="Alice's Account"),
+ CompletionItem(202, display_meta="Bob's Account"),
+]
+
+
+# -----------------------------------------------------------------------------
+# Advanced Example: Mapping friendly aliases to objects
+# -----------------------------------------------------------------------------
+class Account:
+ """A complex object that we want to select by a friendly name."""
+
+ def __init__(self, account_id: int, owner: str):
+ self.account_id = account_id
+ self.owner = owner
+
+ def __eq__(self, other: object) -> bool:
+ if isinstance(other, Account):
+ return self.account_id == other.account_id
+ return False
+
+ def __hash__(self) -> int:
+ return hash(self.account_id)
+
+ def __repr__(self) -> str:
+ return f"Account(id={self.account_id}, owner='{self.owner}')"
+
+
+# Map friendly 'text' aliases to the actual object 'value'.
+# The user types 'alice' or 'bob' (tab-completion), but the parsed value will be the Account object.
+accounts = [
+ Account(101, "Alice"),
+ Account(202, "Bob"),
+]
+account_choices = [
+ CompletionItem(
+ acc,
+ text=acc.owner.lower(),
+ display_meta=f"ID: {acc.account_id}",
+ )
+ for acc in accounts
+]
+
+
+def account_lookup(name: str) -> Account:
+ """Type converter that looks up an Account by its friendly name."""
+ for item in account_choices:
+ if item.text == name:
+ return cast(Account, item.value)
+ raise argparse.ArgumentTypeError(f"invalid account: {name}")
+
+
+# -----------------------------------------------------------------------------
+# Demo Application
+# -----------------------------------------------------------------------------
+class ChoicesDemo(Cmd):
+ """Demo cmd2 application."""
+
+ DEFAULT_CATEGORY: ClassVar[str] = "Demo Commands"
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.intro = (
+ "Welcome to the CompletionItem Choices Demo!\n"
+ "Try 'simple' followed by [TAB] to see basic metadata.\n"
+ "Try 'advanced' followed by [TAB] to see custom string mapping."
+ )
+
+ # Simple Command: argparse handles the int conversion, CompletionItem handles the UI
+ simple_parser = Cmd2ArgumentParser()
+ simple_parser.add_argument(
+ "account_id",
+ type=int,
+ choices=id_choices,
+ help="Select an account ID (tab-complete to see metadata)",
+ )
+
+ @with_argparser(simple_parser)
+ def do_simple(self, args: argparse.Namespace) -> None:
+ """Show an account ID selection (Simple Case)."""
+ # argparse converted the input to an int, and validated it against the CompletionItem.value
+ self.poutput(f"Selected Account ID: {args.account_id} (Type: {type(args.account_id).__name__})")
+
+ # Advanced Command: Custom lookup and custom 'text' mapping
+ advanced_parser = Cmd2ArgumentParser()
+ advanced_parser.add_argument(
+ "account",
+ type=account_lookup,
+ choices=account_choices,
+ help="Select an account by owner name (tab-complete to see friendly names)",
+ )
+
+ @with_argparser(advanced_parser)
+ def do_advanced(self, args: argparse.Namespace) -> None:
+ """Show a custom string selection (Advanced Case)."""
+ # args.account is the full Account object
+ self.poutput(f"Selected Account: {args.account!r} (Type: {type(args.account).__name__})")
+
+
+if __name__ == "__main__":
+ app = ChoicesDemo()
+ sys.exit(app.cmdloop())
diff --git a/examples/custom_parser.py b/examples/custom_parser.py
index 70a279e8a..516ce5201 100644
--- a/examples/custom_parser.py
+++ b/examples/custom_parser.py
@@ -25,13 +25,13 @@ def __init__(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
def error(self, message: str) -> NoReturn:
"""Custom override that applies custom formatting to the error message."""
- lines = message.split('\n')
- formatted_message = ''
+ lines = message.split("\n")
+ formatted_message = ""
for linum, line in enumerate(lines):
if linum == 0:
- formatted_message = 'Error: ' + line
+ formatted_message = "Error: " + line
else:
- formatted_message += '\n ' + line
+ formatted_message += "\n " + line
self.print_usage(sys.stderr)
@@ -40,16 +40,16 @@ def error(self, message: str) -> NoReturn:
formatted_message,
style=styles.WARNING,
)
- self.exit(2, f'{formatted_message}\n\n')
+ self.exit(2, f"{formatted_message}\n\n")
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
# Set the default parser type before instantiating app.
set_default_argument_parser_type(CustomParser)
- app = cmd2.Cmd(include_ipy=True, persistent_history_file='cmd2_history.dat')
+ app = cmd2.Cmd(include_ipy=True, persistent_history_file="cmd2_history.dat")
app.self_in_py = True # Enable access to "self" within the py command
app.debug = True # Show traceback if/when an exception occurs
sys.exit(app.cmdloop())
diff --git a/examples/custom_types.py b/examples/custom_types.py
index ea8a4062b..b000e91c0 100644
--- a/examples/custom_types.py
+++ b/examples/custom_types.py
@@ -51,7 +51,7 @@ def integer(value_str: str) -> int:
def hexadecimal(value_str: str) -> int:
- """Parse hexidecimal integer, with optional '0x' prefix."""
+ """Parse hexadecimal integer, with optional '0x' prefix."""
return int(value_str, base=16)
@@ -112,13 +112,13 @@ def __repr__(self) -> str:
def __call__(self, arg: str) -> Iterable[int]:
"""Parse a string into an iterable returning ints."""
- if arg == 'all':
+ if arg == "all":
return range(self.bottom, self.top)
out = []
- for piece in arg.split(','):
- if '-' in piece:
- a, b = [int(x) for x in piece.split('-', 2)]
+ for piece in arg.split(","):
+ if "-" in piece:
+ a, b = [int(x) for x in piece.split("-", 2)]
if a < self.bottom:
raise ValueError(f"Value '{a}' not within {self.range_str}")
if b >= self.top:
@@ -132,19 +132,19 @@ def __call__(self, arg: str) -> Iterable[int]:
return out
-if __name__ == '__main__':
+if __name__ == "__main__":
import argparse
import sys
class CustomTypesExample(cmd2.Cmd):
example_parser = cmd2.Cmd2ArgumentParser()
example_parser.add_argument(
- '--value', '-v', type=integer, help='Integer value, with optional K/M/G/Ki/Mi/Gi/... suffix'
+ "--value", "-v", type=integer, help="Integer value, with optional K/M/G/Ki/Mi/Gi/... suffix"
)
- example_parser.add_argument('--memory-address', '-m', type=hexadecimal, help='Memory address in hex')
- example_parser.add_argument('--year', type=Range(1900, 2000), help='Year between 1900-1999')
+ example_parser.add_argument("--memory-address", "-m", type=hexadecimal, help="Memory address in hex")
+ example_parser.add_argument("--year", type=Range(1900, 2000), help="Year between 1900-1999")
example_parser.add_argument(
- '--index', dest='index_list', type=IntSet(100), help='One or more indexes 0-99. e.g. "1,3,5", "10,30-50", "all"'
+ "--index", dest="index_list", type=IntSet(100), help='One or more indexes 0-99. e.g. "1,3,5", "10,30-50", "all"'
)
@cmd2.with_argparser(example_parser)
diff --git a/examples/default_categories.py b/examples/default_categories.py
index e0f26b991..05f92e28a 100755
--- a/examples/default_categories.py
+++ b/examples/default_categories.py
@@ -1,76 +1,68 @@
#!/usr/bin/env python3
-"""Simple example demonstrating basic CommandSet usage."""
+"""Example demonstrating the DEFAULT_CATEGORY class variable for Cmd and CommandSet.
+
+In cmd2 4.0, command categorization is driven by the DEFAULT_CATEGORY class variable.
+This example shows:
+1. How a Cmd class defines its own default category.
+2. How a CommandSet defines its own default category.
+3. How overriding a framework command moves it to the child class's category.
+4. How to use @with_category to manually override the automatic categorization.
+"""
+
+import argparse
import cmd2
from cmd2 import (
+ Cmd2ArgumentParser,
CommandSet,
- with_default_category,
+ with_argparser,
+ with_category,
)
-@with_default_category('Default Category')
-class MyBaseCommandSet(CommandSet):
- """Defines a default category for all sub-class CommandSets."""
-
-
-class ChildInheritsParentCategories(MyBaseCommandSet):
- """This subclass doesn't declare any categories so all commands here are also categorized under 'Default Category'."""
-
- def do_hello(self, _: cmd2.Statement) -> None:
- self._cmd.poutput('Hello')
-
- def do_world(self, _: cmd2.Statement) -> None:
- self._cmd.poutput('World')
-
-
-@with_default_category('Non-Heritable Category', heritable=False)
-class ChildOverridesParentCategoriesNonHeritable(MyBaseCommandSet):
- """This subclass overrides the 'Default Category' from the parent, but in a non-heritable fashion. Sub-classes of this
- CommandSet will not inherit this category and will, instead, inherit 'Default Category'.
- """
+class MyPlugin(CommandSet[cmd2.Cmd]):
+ """A CommandSet that defines its own category."""
- def do_goodbye(self, _: cmd2.Statement) -> None:
- self._cmd.poutput('Goodbye')
+ DEFAULT_CATEGORY = "Plugin Commands"
+ def do_plugin_action(self, _: cmd2.Statement) -> None:
+ """A command defined in a CommandSet."""
+ self._cmd.poutput("Plugin action executed")
-class GrandchildInheritsGrandparentCategory(ChildOverridesParentCategoriesNonHeritable):
- """This subclass's parent class declared its default category non-heritable. Instead, it inherits the category defined
- by the grandparent class.
- """
- def do_aloha(self, _: cmd2.Statement) -> None:
- self._cmd.poutput('Aloha')
+class CategoryApp(cmd2.Cmd):
+ """An application demonstrating various categorization scenarios."""
+ # This sets the default category for all commands defined in this class
+ DEFAULT_CATEGORY = "Application Commands"
-@with_default_category('Heritable Category')
-class ChildOverridesParentCategories(MyBaseCommandSet):
- """This subclass is decorated with a default category that is heritable. This overrides the parent class's default
- category declaration.
- """
+ # This overrides the category for the cmd2 built-in commands
+ cmd2.Cmd.DEFAULT_CATEGORY = "Cmd2 Shell Commands"
- def do_bonjour(self, _: cmd2.Statement) -> None:
- self._cmd.poutput('Bonjour')
-
-
-class GrandchildInheritsHeritable(ChildOverridesParentCategories):
- """This subclass's parent declares a default category that overrides its parent. As a result, commands in this
- CommandSet will be categorized under 'Heritable Category'.
- """
-
- def do_monde(self, _: cmd2.Statement) -> None:
- self._cmd.poutput('Monde')
+ def __init__(self) -> None:
+ super().__init__()
+ # Register a command set to show how its categories integrate
+ self.register_command_set(MyPlugin())
+ def do_app_command(self, _: cmd2.Statement) -> None:
+ """A standard command defined in the child class."""
+ self.poutput("Application command executed")
-class ExampleApp(cmd2.Cmd):
- """Example to demonstrate heritable default categories."""
+ @with_argparser(Cmd2ArgumentParser(description="Overridden quit command"))
+ def do_quit(self, _: argparse.Namespace) -> bool | None:
+ """Overriding a built-in command without a decorator moves it to our category."""
+ return super().do_quit("")
- def __init__(self) -> None:
- super().__init__()
+ @with_category(cmd2.Cmd.DEFAULT_CATEGORY)
+ @with_argparser(Cmd2ArgumentParser(description="Overridden shortcuts command"))
+ def do_shortcuts(self, _: argparse.Namespace) -> None:
+ """Overriding with @with_category(cmd2.Cmd.DEFAULT_CATEGORY) keeps it cmd2's category."""
+ super().do_shortcuts("")
- def do_something(self, _arg) -> None:
- self.poutput('this is the something command')
+if __name__ == "__main__":
+ import sys
-if __name__ == '__main__':
- app = ExampleApp()
- app.cmdloop()
+ app = CategoryApp()
+ app.poutput("Type 'help' to see how the commands are categorized.\n")
+ sys.exit(app.cmdloop())
diff --git a/examples/dynamic_commands.py b/examples/dynamic_commands.py
index 137f30c7f..14d005c03 100755
--- a/examples/dynamic_commands.py
+++ b/examples/dynamic_commands.py
@@ -9,8 +9,8 @@
HELP_FUNC_PREFIX,
)
-COMMAND_LIST = ['foo', 'bar']
-CATEGORY = 'Dynamic Commands'
+COMMAND_LIST = ["foo", "bar"]
+CATEGORY = "Dynamic Commands"
class CommandsInLoop(cmd2.Cmd):
@@ -43,6 +43,6 @@ def text_help(self, *, text: str) -> None:
self.poutput(f"Simulate sending {text!r} to a server and printing the response")
-if __name__ == '__main__':
+if __name__ == "__main__":
app = CommandsInLoop()
app.cmdloop()
diff --git a/examples/environment.py b/examples/environment.py
index 706f150f4..241425019 100755
--- a/examples/environment.py
+++ b/examples/environment.py
@@ -12,18 +12,18 @@ def __init__(self) -> None:
self.degrees_c = 22
self.sunny = False
self.add_settable(
- cmd2.Settable('degrees_c', int, 'Temperature in Celsius', self, onchange_cb=self._onchange_degrees_c)
+ cmd2.Settable("degrees_c", int, "Temperature in Celsius", self, onchange_cb=self._onchange_degrees_c)
)
- self.add_settable(cmd2.Settable('sunny', bool, 'Is it sunny outside?', self))
+ self.add_settable(cmd2.Settable("sunny", bool, "Is it sunny outside?", self))
def do_sunbathe(self, _arg) -> None:
"""Attempt to sunbathe."""
if self.degrees_c < 20:
result = f"It's {self.degrees_c} C - are you a penguin?"
elif not self.sunny:
- result = 'Too dim.'
+ result = "Too dim."
else:
- result = 'UV is bad for your skin.'
+ result = "UV is bad for your skin."
self.poutput(result)
def _onchange_degrees_c(self, _param_name, _old, new) -> None:
@@ -32,7 +32,7 @@ def _onchange_degrees_c(self, _param_name, _old, new) -> None:
self.sunny = True
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
c = EnvironmentApp()
diff --git a/examples/event_loops.py b/examples/event_loops.py
index aca434207..c2a4cc74a 100755
--- a/examples/event_loops.py
+++ b/examples/event_loops.py
@@ -18,7 +18,7 @@ def __init__(self) -> None:
# ... your class code here ...
-if __name__ == '__main__':
+if __name__ == "__main__":
app = Cmd2EventBased()
app.preloop()
diff --git a/examples/exit_code.py b/examples/exit_code.py
index bfce8c909..389ab56d1 100755
--- a/examples/exit_code.py
+++ b/examples/exit_code.py
@@ -29,10 +29,10 @@ def do_exit(self, arg_list: list[str]) -> bool:
return True
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
app = ReplWithExitCode()
sys_exit_code = app.cmdloop()
- app.poutput(f'{sys.argv[0]!r} exiting with code: {sys_exit_code}')
+ app.poutput(f"{sys.argv[0]!r} exiting with code: {sys_exit_code}")
sys.exit(sys_exit_code)
diff --git a/examples/getting_started.py b/examples/getting_started.py
index 025a4f5c5..5354f1c32 100755
--- a/examples/getting_started.py
+++ b/examples/getting_started.py
@@ -13,10 +13,14 @@
9) Using a custom prompt
10) How to make custom attributes settable at runtime.
11) Shortcuts for commands
+12) Persistent bottom toolbar with realtime status updates
"""
import pathlib
+import threading
+import time
+from prompt_toolkit.formatted_text import FormattedText
from rich.style import Style
import cmd2
@@ -29,45 +33,51 @@
class BasicApp(cmd2.Cmd):
"""Cmd2 application to demonstrate many common features."""
- CUSTOM_CATEGORY = 'My Custom Commands'
+ DEFAULT_CATEGORY = "My Custom Commands"
def __init__(self) -> None:
"""Initialize the cmd2 application."""
# Startup script that defines a couple aliases for running shell commands
- alias_script = pathlib.Path(__file__).absolute().parent / '.cmd2rc'
+ alias_script = pathlib.Path(__file__).absolute().parent / ".cmd2rc"
# Create a shortcut for one of our commands
shortcuts = cmd2.DEFAULT_SHORTCUTS
- shortcuts.update({'&': 'intro'})
+ shortcuts.update({"&": "intro"})
super().__init__(
+ auto_suggest=True,
+ bottom_toolbar=True,
include_ipy=True,
- multiline_commands=['echo'],
- persistent_history_file='cmd2_history.dat',
+ multiline_commands=["echo"],
+ persistent_history_file="cmd2_history.dat",
shortcuts=shortcuts,
startup_script=str(alias_script),
)
+ # Spawn a background thread to refresh the bottom toolbar twice a second.
+ # This is necessary because the toolbar contains a timestamp that we want to keep current.
+ self._stop_refresh = False
+ self._refresh_thread = threading.Thread(target=self._refresh_bottom_toolbar, daemon=True)
+ self._refresh_thread.start()
+
# Prints an intro banner once upon application startup
self.intro = (
stylize(
- 'Welcome to cmd2!',
+ "Welcome to cmd2!",
style=Style(color=Color.GREEN1, bgcolor=Color.GRAY0, bold=True),
)
- + ' Note the full Unicode support: 😇 💩'
+ + " Note the full Unicode support: 😇 💩"
+ + " and the persistent bottom bar with realtime status updates!"
)
# Show this as the prompt when asking for input
- self.prompt = 'myapp> '
+ self.prompt = "myapp> "
# Used as prompt for multiline commands after the first line
- self.continuation_prompt = '... '
+ self.continuation_prompt = "... "
# Allow access to your application in py and ipy via self
self.self_in_py = True
- # Set the default category name
- self.default_category = 'cmd2 Built-in Commands'
-
# Color to output text in with echo command
self.foreground_color = Color.CYAN.value
@@ -75,20 +85,42 @@ def __init__(self) -> None:
fg_colors = [c.value for c in Color]
self.add_settable(
cmd2.Settable(
- 'foreground_color',
+ "foreground_color",
str,
- 'Foreground color to use with echo command',
+ "Foreground color to use with echo command",
self,
choices=fg_colors,
)
)
- @cmd2.with_category(CUSTOM_CATEGORY)
+ def get_rprompt(self) -> str | FormattedText | None:
+ current_working_directory = pathlib.Path.cwd()
+ style = "bg:ansired fg:ansiwhite"
+ text = f"cwd={current_working_directory}"
+ return FormattedText([(style, text)])
+
+ def _refresh_bottom_toolbar(self) -> None:
+ """Background thread target to refresh the bottom toolbar.
+
+ This is a toy example to show how the bottom toolbar can be used to display
+ realtime status updates in an otherwise line-oriented command interpreter.
+ """
+ import contextlib
+
+ from prompt_toolkit.application.current import get_app
+
+ while not self._stop_refresh:
+ with contextlib.suppress(Exception):
+ # get_app() will return the currently running prompt-toolkit application
+ app = get_app()
+ if app:
+ app.invalidate()
+ time.sleep(0.5)
+
def do_intro(self, _: cmd2.Statement) -> None:
"""Display the intro banner."""
self.poutput(self.intro)
- @cmd2.with_category(CUSTOM_CATEGORY)
def do_echo(self, arg: cmd2.Statement) -> None:
"""Multiline command."""
self.poutput(
@@ -99,7 +131,7 @@ def do_echo(self, arg: cmd2.Statement) -> None:
)
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
app = BasicApp()
diff --git a/examples/hello_cmd2.py b/examples/hello_cmd2.py
index a480aa5e4..72705e51e 100755
--- a/examples/hello_cmd2.py
+++ b/examples/hello_cmd2.py
@@ -5,12 +5,15 @@
cmd2,
)
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
# If run as the main application, simply start a bare-bones cmd2 application with only built-in functionality.
- # Enable commands to support interactive Python and IPython shells.
- app = cmd2.Cmd(include_py=True, include_ipy=True, persistent_history_file='cmd2_history.dat')
+ app = cmd2.Cmd(
+ include_ipy=True, # Enable support for interactive Python shell via py command
+ include_py=True, # Enable support for interactive IPython shell via ipy command
+ persistent_history_file="cmd2_history.dat", # Persist history between runs
+ )
app.self_in_py = True # Enable access to "self" within the py command
app.debug = True # Show traceback if/when an exception occurs
sys.exit(app.cmdloop())
diff --git a/examples/help_categories.py b/examples/help_categories.py
index 7a1872509..6cc6b8ed9 100755
--- a/examples/help_categories.py
+++ b/examples/help_categories.py
@@ -13,7 +13,7 @@
def my_decorator(f):
@functools.wraps(f)
def wrapper(*args, **kwds):
- print('Calling decorated function')
+ print("Calling decorated function")
return f(*args, **kwds)
return wrapper
@@ -22,22 +22,22 @@ def wrapper(*args, **kwds):
class HelpCategories(cmd2.Cmd):
"""Example cmd2 application."""
- START_TIMES = ('now', 'later', 'sometime', 'whenever')
+ START_TIMES = ("now", "later", "sometime", "whenever")
# Command categories
- CMD_CAT_CONNECTING = 'Connecting'
- CMD_CAT_APP_MGMT = 'Application Management'
- CMD_CAT_SERVER_INFO = 'Server Information'
+ CMD_CAT_CONNECTING = "Connecting"
+ CMD_CAT_APP_MGMT = "Application Management"
+ CMD_CAT_SERVER_INFO = "Server Information"
+
+ # Show all other commands in "Other" category
+ cmd2.Cmd.DEFAULT_CATEGORY = "Other"
def __init__(self) -> None:
super().__init__()
- # Set the default category for uncategorized commands
- self.default_category = 'Other'
-
def do_connect(self, _) -> None:
"""Connect command."""
- self.poutput('Connect')
+ self.poutput("Connect")
# Tag the above command functions under the category Connecting
cmd2.categorize(do_connect, CMD_CAT_CONNECTING)
@@ -45,64 +45,64 @@ def do_connect(self, _) -> None:
@cmd2.with_category(CMD_CAT_CONNECTING)
def do_which(self, _) -> None:
"""Which command."""
- self.poutput('Which')
+ self.poutput("Which")
def do_list(self, _) -> None:
"""List command."""
- self.poutput('List')
+ self.poutput("List")
def do_deploy(self, _) -> None:
"""Deploy command."""
- self.poutput('Deploy')
+ self.poutput("Deploy")
start_parser = cmd2.Cmd2ArgumentParser(
- description='Start',
- epilog='my_decorator runs even with argparse errors',
+ description="Start",
+ epilog="my_decorator runs even with argparse errors",
)
- start_parser.add_argument('when', choices=START_TIMES, help='Specify when to start')
+ start_parser.add_argument("when", choices=START_TIMES, help="Specify when to start")
@my_decorator
@cmd2.with_argparser(start_parser)
def do_start(self, _) -> None:
"""Start command."""
- self.poutput('Start')
+ self.poutput("Start")
def do_sessions(self, _) -> None:
"""Sessions command."""
- self.poutput('Sessions')
+ self.poutput("Sessions")
def do_redeploy(self, _) -> None:
"""Redeploy command."""
- self.poutput('Redeploy')
+ self.poutput("Redeploy")
restart_parser = cmd2.Cmd2ArgumentParser(
- description='Restart',
- epilog='my_decorator does not run when argparse errors',
+ description="Restart",
+ epilog="my_decorator does not run when argparse errors",
)
- restart_parser.add_argument('when', choices=START_TIMES, help='Specify when to restart')
+ restart_parser.add_argument("when", choices=START_TIMES, help="Specify when to restart")
@cmd2.with_argparser(restart_parser)
@cmd2.with_category(CMD_CAT_APP_MGMT)
@my_decorator
def do_restart(self, _) -> None:
"""Restart command."""
- self.poutput('Restart')
+ self.poutput("Restart")
def do_expire(self, _) -> None:
"""Expire command."""
- self.poutput('Expire')
+ self.poutput("Expire")
def do_undeploy(self, _) -> None:
"""Undeploy command."""
- self.poutput('Undeploy')
+ self.poutput("Undeploy")
def do_stop(self, _) -> None:
"""Stop command."""
- self.poutput('Stop')
+ self.poutput("Stop")
def do_findleakers(self, _) -> None:
"""Find Leakers command."""
- self.poutput('Find Leakers')
+ self.poutput("Find Leakers")
# Tag the above command functions under the category Application Management
cmd2.categorize(
@@ -112,19 +112,19 @@ def do_findleakers(self, _) -> None:
def do_resources(self, _) -> None:
"""Resources command."""
- self.poutput('Resources')
+ self.poutput("Resources")
def do_status(self, _) -> None:
"""Status command."""
- self.poutput('Status')
+ self.poutput("Status")
def do_serverinfo(self, _) -> None:
"""Server Info command."""
- self.poutput('Server Info')
+ self.poutput("Server Info")
def do_thread_dump(self, _) -> None:
"""Thread Dump command."""
- self.poutput('Thread Dump')
+ self.poutput("Thread Dump")
def do_sslconnectorciphers(self, _) -> None:
"""SSL Connector Ciphers command is an example of a command that contains
@@ -134,11 +134,11 @@ def do_sslconnectorciphers(self, _) -> None:
This is after a blank line and won't de displayed in the verbose help
"""
- self.poutput('SSL Connector Ciphers')
+ self.poutput("SSL Connector Ciphers")
def do_vminfo(self, _) -> None:
"""VM Info command."""
- self.poutput('VM Info')
+ self.poutput("VM Info")
# Tag the above command functions under the category Server Information
cmd2.categorize(do_resources, CMD_CAT_SERVER_INFO)
@@ -152,7 +152,7 @@ def do_vminfo(self, _) -> None:
# and show up in the 'Other' group
def do_config(self, _) -> None:
"""Config command."""
- self.poutput('Config')
+ self.poutput("Config")
def do_version(self, _) -> None:
"""Version command."""
@@ -172,7 +172,7 @@ def do_enable_commands(self, _) -> None:
self.poutput("The Application Management commands have been enabled")
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
c = HelpCategories()
diff --git a/examples/hooks.py b/examples/hooks.py
index ccb9a8386..f8c3a6b39 100755
--- a/examples/hooks.py
+++ b/examples/hooks.py
@@ -37,8 +37,6 @@ class CmdLineApp(cmd2.Cmd):
"""
- # Setting this true makes it run a shell command if a cmd2/cmd command doesn't exist
- # default_to_shell = True # noqa: ERA001
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
@@ -61,14 +59,14 @@ def add_whitespace_hook(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.
# ^ - the beginning of the string
# ([^\s\d]+) - one or more non-whitespace non-digit characters, set as capture group 1
# (\d+) - one or more digit characters, set as capture group 2
- command_pattern = re.compile(r'^([^\s\d]+)(\d+)')
+ command_pattern = re.compile(r"^([^\s\d]+)(\d+)")
match = command_pattern.search(command)
if match:
command = match.group(1)
first_arg = match.group(2)
rest_args = data.statement.args
post_command = data.statement.post_command
- data.statement = self.statement_parser.parse(f'{command} {first_arg} {rest_args} {post_command}')
+ data.statement = self.statement_parser.parse(f"{command} {first_arg} {rest_args} {post_command}")
return data
def downcase_hook(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData:
@@ -76,12 +74,12 @@ def downcase_hook(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.Postpa
command = data.statement.command.lower()
args = data.statement.args
post_command = data.statement.post_command
- data.statement = self.statement_parser.parse(f'{command} {args} {post_command}')
+ data.statement = self.statement_parser.parse(f"{command} {args} {post_command}")
return data
def abbrev_hook(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData:
"""Accept unique abbreviated commands."""
- func = self.cmd_func(data.statement.command)
+ func = self.get_command_func(data.statement.command)
if func is None:
# check if the entered command might be an abbreviation
possible_cmds = [cmd for cmd in self.get_all_commands() if cmd.startswith(data.statement.command)]
@@ -93,7 +91,7 @@ def abbrev_hook(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.Postpars
def proof_hook(self, data: cmd2.plugin.PostcommandData) -> cmd2.plugin.PostcommandData:
"""Update the shell prompt with the new raw statement after postparsing hooks are finished."""
if self.debug:
- self.prompt = f'({data.statement.raw})'
+ self.prompt = f"({data.statement.raw})"
return data
@cmd2.with_argument_list
@@ -113,7 +111,7 @@ def do_list(self, arglist: list[str]) -> None:
self.poutput(str(x))
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
c = CmdLineApp()
diff --git a/examples/migrating.py b/examples/migrating.py
index 9c79d488e..2023ef0d1 100755
--- a/examples/migrating.py
+++ b/examples/migrating.py
@@ -9,9 +9,9 @@
class CmdLineApp(cmd.Cmd):
"""Example cmd application."""
- MUMBLES = ('like', '...', 'um', 'er', 'hmmm', 'ahh')
- MUMBLE_FIRST = ('so', 'like', 'well')
- MUMBLE_LAST = ('right?',)
+ MUMBLES = ("like", "...", "um", "er", "hmmm", "ahh")
+ MUMBLE_FIRST = ("so", "like", "well")
+ MUMBLE_LAST = ("right?",)
def do_exit(self, _line) -> bool:
"""Exit the application."""
@@ -28,7 +28,7 @@ def do_speak(self, line) -> None:
def do_mumble(self, line) -> None:
"""Mumbles what you tell me to."""
- words = line.split(' ')
+ words = line.split(" ")
output = []
if random.random() < 0.33:
output.append(random.choice(self.MUMBLE_FIRST))
@@ -38,10 +38,10 @@ def do_mumble(self, line) -> None:
output.append(word)
if random.random() < 0.25:
output.append(random.choice(self.MUMBLE_LAST))
- print(' '.join(output), file=self.stdout)
+ print(" ".join(output), file=self.stdout)
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
c = CmdLineApp()
diff --git a/examples/mixin.py b/examples/mixin.py
index 90b2ce56d..e80813c4f 100755
--- a/examples/mixin.py
+++ b/examples/mixin.py
@@ -62,7 +62,7 @@ def cmd2_mymixin_postloop_hook(self) -> None:
def cmd2_mymixin_postparsing_hook(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData:
"""Method to be called after parsing user input, but before running the command."""
- self.poutput('in postparsing hook')
+ self.poutput("in postparsing hook")
return data
@@ -75,9 +75,9 @@ def __init__(self, *args, **kwargs) -> None:
@empty_decorator
def do_something(self, _arg) -> None:
- self.poutput('this is the something command')
+ self.poutput("this is the something command")
-if __name__ == '__main__':
+if __name__ == "__main__":
app = Example()
app.cmdloop()
diff --git a/examples/modular_commands/commandset_basic.py b/examples/modular_commands/commandset_basic.py
index 8ef0a9d06..8462e9ddb 100644
--- a/examples/modular_commands/commandset_basic.py
+++ b/examples/modular_commands/commandset_basic.py
@@ -1,79 +1,39 @@
"""A simple example demonstrating a loadable command set."""
from cmd2 import (
+ Cmd,
CommandSet,
CompletionError,
+ Completions,
Statement,
with_category,
- with_default_category,
)
-@with_default_category('Basic Completion')
-class BasicCompletionCommandSet(CommandSet):
- # List of strings used with completion functions
- food_item_strs = ('Pizza', 'Ham', 'Ham Sandwich', 'Potato')
- sport_item_strs = ('Bat', 'Basket', 'Basketball', 'Football', 'Space Ball')
+class BasicCompletionCommandSet(CommandSet[Cmd]):
+ DEFAULT_CATEGORY = "Basic Completion"
# This data is used to demonstrate delimiter_complete
file_strs = (
- '/home/user/file.db',
- '/home/user/file space.db',
- '/home/user/another.db',
- '/home/other user/maps.db',
- '/home/other user/tests.db',
+ "/home/user/file.db",
+ "/home/user/file space.db",
+ "/home/user/another.db",
+ "/home/other user/maps.db",
+ "/home/other user/tests.db",
)
- def do_flag_based(self, statement: Statement) -> None:
- """Tab completes arguments based on a preceding flag using flag_based_complete
- -f, --food [completes food items]
- -s, --sport [completes sports]
- -p, --path [completes local file system paths].
- """
- self._cmd.poutput(f"Args: {statement.args}")
-
- def complete_flag_based(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
- """Completion function for do_flag_based."""
- flag_dict = {
- # Tab complete food items after -f and --food flags in command line
- '-f': self.food_item_strs,
- '--food': self.food_item_strs,
- # Tab complete sport items after -s and --sport flags in command line
- '-s': self.sport_item_strs,
- '--sport': self.sport_item_strs,
- # Tab complete using path_complete function after -p and --path flags in command line
- '-p': self._cmd.path_complete,
- '--path': self._cmd.path_complete,
- }
-
- return self._cmd.flag_based_complete(text, line, begidx, endidx, flag_dict=flag_dict)
-
- def do_index_based(self, statement: Statement) -> None:
- """Tab completes first 3 arguments using index_based_complete."""
- self._cmd.poutput(f"Args: {statement.args}")
-
- def complete_index_based(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
- """Completion function for do_index_based."""
- index_dict = {
- 1: self.food_item_strs, # Tab complete food items at index 1 in command line
- 2: self.sport_item_strs, # Tab complete sport items at index 2 in command line
- 3: self._cmd.path_complete, # Tab complete using path_complete function at index 3 in command line
- }
-
- return self._cmd.index_based_complete(text, line, begidx, endidx, index_dict=index_dict)
-
def do_delimiter_complete(self, statement: Statement) -> None:
"""Tab completes files from a list using delimiter_complete."""
self._cmd.poutput(f"Args: {statement.args}")
- def complete_delimiter_complete(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
- return self._cmd.delimiter_complete(text, line, begidx, endidx, match_against=self.file_strs, delimiter='/')
+ def complete_delimiter_complete(self, text: str, line: str, begidx: int, endidx: int) -> Completions:
+ return self._cmd.delimiter_complete(text, line, begidx, endidx, match_against=self.file_strs, delimiter="/")
def do_raise_error(self, statement: Statement) -> None:
"""Demonstrates effect of raising CompletionError."""
self._cmd.poutput(f"Args: {statement.args}")
- def complete_raise_error(self, _text: str, _line: str, _begidx: int, _endidx: int) -> list[str]:
+ def complete_raise_error(self, _text: str, _line: str, _begidx: int, _endidx: int) -> Completions:
"""CompletionErrors can be raised if an error occurs while tab completing.
Example use cases
@@ -82,6 +42,6 @@ def complete_raise_error(self, _text: str, _line: str, _begidx: int, _endidx: in
"""
raise CompletionError("This is how a CompletionError behaves")
- @with_category('Not Basic Completion')
+ @with_category("Not Basic Completion")
def do_custom_category(self, _statement: Statement) -> None:
- self._cmd.poutput('Demonstrates a command that bypasses the default category')
+ self._cmd.poutput("Demonstrates a command that bypasses the default category")
diff --git a/examples/modular_commands/commandset_complex.py b/examples/modular_commands/commandset_complex.py
index d1e157b98..c18d60e9d 100644
--- a/examples/modular_commands/commandset_complex.py
+++ b/examples/modular_commands/commandset_complex.py
@@ -5,42 +5,44 @@
import cmd2
-@cmd2.with_default_category('Fruits')
class CommandSetA(cmd2.CommandSet):
+ DEFAULT_CATEGORY = "Fruits"
+
def do_apple(self, _statement: cmd2.Statement) -> None:
- self._cmd.poutput('Apple!')
+ """Apple Command."""
+ self._cmd.poutput("Apple!")
def do_banana(self, _statement: cmd2.Statement) -> None:
"""Banana Command."""
- self._cmd.poutput('Banana!!')
+ self._cmd.poutput("Banana!!")
cranberry_parser = cmd2.Cmd2ArgumentParser()
- cranberry_parser.add_argument('arg1', choices=['lemonade', 'juice', 'sauce'])
+ cranberry_parser.add_argument("arg1", choices=["lemonade", "juice", "sauce"])
@cmd2.with_argparser(cranberry_parser, with_unknown_args=True)
def do_cranberry(self, ns: argparse.Namespace, unknown: list[str]) -> None:
- self._cmd.poutput(f'Cranberry {ns.arg1}!!')
+ self._cmd.poutput(f"Cranberry {ns.arg1}!!")
if unknown and len(unknown):
- self._cmd.poutput('Unknown: ' + ', '.join(['{}'] * len(unknown)).format(*unknown))
- self._cmd.last_result = {'arg1': ns.arg1, 'unknown': unknown}
+ self._cmd.poutput("Unknown: " + ", ".join(["{}"] * len(unknown)).format(*unknown))
+ self._cmd.last_result = {"arg1": ns.arg1, "unknown": unknown}
def help_cranberry(self) -> None:
- self._cmd.stdout.write('This command does diddly squat...\n')
+ self._cmd.stdout.write("This command does diddly squat...\n")
@cmd2.with_argument_list
- @cmd2.with_category('Also Alone')
+ @cmd2.with_category("Also Alone")
def do_durian(self, args: list[str]) -> None:
"""Durian Command."""
- self._cmd.poutput(f'{len(args)} Arguments: ')
- self._cmd.poutput(', '.join(['{}'] * len(args)).format(*args))
+ self._cmd.poutput(f"{len(args)} Arguments: ")
+ self._cmd.poutput(", ".join(["{}"] * len(args)).format(*args))
def complete_durian(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
- return self._cmd.basic_complete(text, line, begidx, endidx, ['stinks', 'smells', 'disgusting'])
+ return self._cmd.basic_complete(text, line, begidx, endidx, ["stinks", "smells", "disgusting"])
elderberry_parser = cmd2.Cmd2ArgumentParser()
- elderberry_parser.add_argument('arg1')
+ elderberry_parser.add_argument("arg1")
- @cmd2.with_category('Alone')
+ @cmd2.with_category("Alone")
@cmd2.with_argparser(elderberry_parser)
def do_elderberry(self, ns: argparse.Namespace) -> None:
- self._cmd.poutput(f'Elderberry {ns.arg1}!!')
+ self._cmd.poutput(f"Elderberry {ns.arg1}!!")
diff --git a/examples/modular_commands/commandset_custominit.py b/examples/modular_commands/commandset_custominit.py
index fcd8bfa41..8d1918f5b 100644
--- a/examples/modular_commands/commandset_custominit.py
+++ b/examples/modular_commands/commandset_custominit.py
@@ -1,14 +1,15 @@
"""A simple example demonstrating a loadable command set."""
from cmd2 import (
+ Cmd,
CommandSet,
Statement,
- with_default_category,
)
-@with_default_category('Custom Init')
-class CustomInitCommandSet(CommandSet):
+class CustomInitCommandSet(CommandSet[Cmd]):
+ DEFAULT_CATEGORY = "Custom Init"
+
def __init__(self, arg1, arg2) -> None:
super().__init__()
@@ -16,7 +17,9 @@ def __init__(self, arg1, arg2) -> None:
self._arg2 = arg2
def do_show_arg1(self, _: Statement) -> None:
- self._cmd.poutput('Arg1: ' + self._arg1)
+ """Show Arg 1."""
+ self._cmd.poutput("Arg1: " + self._arg1)
def do_show_arg2(self, _: Statement) -> None:
- self._cmd.poutput('Arg2: ' + self._arg2)
+ """Show Arg 2."""
+ self._cmd.poutput("Arg2: " + self._arg2)
diff --git a/examples/modular_commands.py b/examples/modular_commandsets.py
similarity index 85%
rename from examples/modular_commands.py
rename to examples/modular_commandsets.py
index 79cc366b1..3d9900d61 100755
--- a/examples/modular_commands.py
+++ b/examples/modular_commandsets.py
@@ -29,7 +29,7 @@ class WithCommandSets(Cmd):
def __init__(self, command_sets: Iterable[CommandSet] | None = None) -> None:
"""Cmd2 application to demonstrate a variety of methods for loading CommandSets."""
super().__init__(command_sets=command_sets)
- self.sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball']
+ self.sport_item_strs = ["Bat", "Basket", "Basketball", "Football", "Space Ball"]
def choices_provider(self) -> list[str]:
"""A choices provider is useful when the choice list is based on instance data of your application."""
@@ -43,16 +43,16 @@ def choices_provider(self) -> list[str]:
# Tab complete from a list using argparse choices. Set metavar if you don't
# want the entire choices list showing in the usage text for this command.
example_parser.add_argument(
- '--choices', choices=['some', 'choices', 'here'], metavar="CHOICE", help="tab complete using choices"
+ "--choices", choices=["some", "choices", "here"], metavar="CHOICE", help="tab complete using choices"
)
# Tab complete from choices provided by a choices provider
example_parser.add_argument(
- '--choices_provider', choices_provider=choices_provider, help="tab complete using a choices_provider"
+ "--choices_provider", choices_provider=choices_provider, help="tab complete using a choices_provider"
)
# Tab complete using a completer
- example_parser.add_argument('--completer', completer=Cmd.path_complete, help="tab complete using a completer")
+ example_parser.add_argument("--completer", completer=Cmd.path_complete, help="tab complete using a completer")
@with_argparser(example_parser)
def do_example(self, _: argparse.Namespace) -> None:
@@ -60,10 +60,10 @@ def do_example(self, _: argparse.Namespace) -> None:
self.poutput("I do nothing")
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
print("Starting")
- my_sets = [BasicCompletionCommandSet(), CommandSetA(), CustomInitCommandSet('First argument', 'Second argument')]
+ my_sets = [BasicCompletionCommandSet(), CommandSetA(), CustomInitCommandSet("First argument", "Second argument")]
app = WithCommandSets(command_sets=my_sets)
sys.exit(app.cmdloop())
diff --git a/examples/paged_output.py b/examples/paged_output.py
index 935bdd2e9..d10740fe6 100755
--- a/examples/paged_output.py
+++ b/examples/paged_output.py
@@ -20,7 +20,7 @@ def page_file(self, file_path: str, chop: bool = False) -> None:
text = f.read()
self.ppaged(text, chop=chop)
except OSError as ex:
- self.pexcept(f'Error reading {filename!r}: {ex}')
+ self.pexcept(f"Error reading {filename!r}: {ex}")
@cmd2.with_argument_list
def do_page_wrap(self, args: list[str]) -> None:
@@ -29,7 +29,7 @@ def do_page_wrap(self, args: list[str]) -> None:
Usage: page_wrap
"""
if not args:
- self.perror('page_wrap requires a path to a file as an argument')
+ self.perror("page_wrap requires a path to a file as an argument")
return
self.page_file(args[0], chop=False)
@@ -44,14 +44,14 @@ def do_page_truncate(self, args: list[str]) -> None:
Usage: page_chop
"""
if not args:
- self.perror('page_truncate requires a path to a file as an argument')
+ self.perror("page_truncate requires a path to a file as an argument")
return
self.page_file(args[0], chop=True)
complete_page_truncate = cmd2.Cmd.path_complete
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
app = PagedOutput()
diff --git a/examples/persistent_history.py b/examples/persistent_history.py
index d2ae8ceff..e1bc607cf 100755
--- a/examples/persistent_history.py
+++ b/examples/persistent_history.py
@@ -17,15 +17,15 @@ def __init__(self, hist_file) -> None:
:param hist_file: file to load history from at start and write it to at end
"""
super().__init__(persistent_history_file=hist_file, persistent_history_length=500, allow_cli_args=False)
- self.prompt = 'ph> '
+ self.prompt = "ph> "
# ... your class code here ...
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
- history_file = '~/.persistent_history.cmd2'
+ history_file = "~/.persistent_history.cmd2"
if len(sys.argv) > 1:
history_file = sys.argv[1]
diff --git a/examples/pretty_print.py b/examples/pretty_print.py
index bf3ce9c9c..f10dc185c 100755
--- a/examples/pretty_print.py
+++ b/examples/pretty_print.py
@@ -1,7 +1,5 @@
#!/usr/bin/env python3
-"""A simple example demonstrating how to pretty print JSON data in a cmd2 app using rich."""
-
-from rich.json import JSON
+"""A simple example demonstrating how to pretty print data."""
import cmd2
@@ -9,36 +7,22 @@
"name": "John Doe",
"age": 30,
"address": {"street": "123 Main St", "city": "Anytown", "state": "CA"},
- "hobbies": ["reading", "hiking", "coding"],
+ "hobbies": ["reading", "hiking", "coding", "cooking", "running", "painting", "music", "photography", "cycling"],
+ "member": True,
+ "vip": False,
+ "phone": None,
}
class Cmd2App(cmd2.Cmd):
def __init__(self) -> None:
super().__init__()
- self.data = EXAMPLE_DATA
-
- def do_normal(self, _) -> None:
- """Display the data using the normal poutput method."""
- self.poutput(self.data)
-
- def do_pretty(self, _) -> None:
- """Display the JSON data in a pretty way using rich."""
- json_renderable = JSON.from_data(
- self.data,
- indent=2,
- highlight=True,
- skip_keys=False,
- ensure_ascii=False,
- check_circular=True,
- allow_nan=True,
- default=None,
- sort_keys=False,
- )
- self.poutput(json_renderable)
+ def do_pretty(self, _: cmd2.Statement) -> None:
+ """Print an object using ppretty()."""
+ self.ppretty(EXAMPLE_DATA)
-if __name__ == '__main__':
+if __name__ == "__main__":
app = Cmd2App()
app.cmdloop()
diff --git a/examples/python_scripting.py b/examples/python_scripting.py
index 0e5c6fc61..044736ad4 100755
--- a/examples/python_scripting.py
+++ b/examples/python_scripting.py
@@ -37,12 +37,12 @@ def __init__(self) -> None:
# Set include_ipy to True to enable the "ipy" command which runs an interactive IPython shell
super().__init__(include_ipy=True)
self._set_prompt()
- self.intro = 'Happy 𝛑 Day. Note the full Unicode support: 😇 💩'
+ self.intro = "Happy 𝛑 Day. Note the full Unicode support: 😇 💩"
def _set_prompt(self) -> None:
"""Set prompt so it displays the current working directory."""
self.cwd = os.getcwd()
- self.prompt = stylize(f'{self.cwd} $ ', style=Color.CYAN)
+ self.prompt = stylize(f"{self.cwd} $ ", style=Color.CYAN)
def postcmd(self, stop: bool, _line: str) -> bool:
"""Hook method executed just after a command dispatch is finished.
@@ -64,8 +64,8 @@ def do_cd(self, arglist: list[str]) -> None:
# Expect 1 argument, the directory to change to
if not arglist or len(arglist) != 1:
self.perror("cd requires exactly 1 argument:")
- self.do_help('cd')
- self.last_result = 'Bad arguments'
+ self.do_help("cd")
+ self.last_result = "Bad arguments"
return
# Convert relative paths to absolute paths
@@ -75,16 +75,16 @@ def do_cd(self, arglist: list[str]) -> None:
err = None
data = None
if not os.path.isdir(path):
- err = f'{path} is not a directory'
+ err = f"{path} is not a directory"
elif not os.access(path, os.R_OK):
- err = f'You do not have read access to {path}'
+ err = f"You do not have read access to {path}"
else:
try:
os.chdir(path)
except Exception as ex: # noqa: BLE001
- err = f'{ex}'
+ err = f"{ex}"
else:
- self.poutput(f'Successfully changed directory to {path}')
+ self.poutput(f"Successfully changed directory to {path}")
data = path
if err:
@@ -97,7 +97,7 @@ def complete_cd(self, text: str, line: str, begidx: int, endidx: int) -> list[st
return self.path_complete(text, line, begidx, endidx, path_filter=os.path.isdir)
dir_parser = cmd2.Cmd2ArgumentParser()
- dir_parser.add_argument('-l', '--long', action='store_true', help="display in long format with one item per line")
+ dir_parser.add_argument("-l", "--long", action="store_true", help="display in long format with one item per line")
@cmd2.with_argparser(dir_parser, with_unknown_args=True)
def do_dir(self, _args: argparse.Namespace, unknown: list[str]) -> None:
@@ -105,21 +105,21 @@ def do_dir(self, _args: argparse.Namespace, unknown: list[str]) -> None:
# No arguments for this command
if unknown:
self.perror("dir does not take any positional arguments:")
- self.do_help('dir')
- self.last_result = 'Bad arguments'
+ self.do_help("dir")
+ self.last_result = "Bad arguments"
return
# Get the contents as a list
contents = os.listdir(self.cwd)
for f in contents:
- self.poutput(f'{f}')
- self.poutput('')
+ self.poutput(f"{f}")
+ self.poutput("")
self.last_result = contents
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
c = CmdLineApp()
diff --git a/examples/read_input.py b/examples/read_input.py
index 408617705..24dcad205 100755
--- a/examples/read_input.py
+++ b/examples/read_input.py
@@ -1,5 +1,9 @@
#!/usr/bin/env python
-"""A simple example demonstrating the various ways to call cmd2.Cmd.read_input() for input history and tab completion."""
+"""A simple example demonstrating the various ways to call cmd2.Cmd.read_input() and cmd2.Cmd.read_secret().
+
+These methods can be used to read input from stdin with optional history, tab completion, or password masking.
+It also demonstrates how to use the cmd2.Cmd.select method.
+"""
import contextlib
@@ -12,7 +16,7 @@ class ReadInputApp(cmd2.Cmd):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.prompt = "\n" + self.prompt
- self.custom_history = ['history 1', 'history 2']
+ self.custom_history = ["history 1", "history 2"]
@cmd2.with_category(EXAMPLE_COMMANDS)
def do_basic(self, _) -> None:
@@ -32,13 +36,6 @@ def do_basic_with_history(self, _) -> None:
else:
self.custom_history.append(input_str)
- @cmd2.with_category(EXAMPLE_COMMANDS)
- def do_commands(self, _) -> None:
- """Call read_input the same way cmd2 prompt does to read commands."""
- self.poutput("Tab completing and up-arrow history configured for commands")
- with contextlib.suppress(EOFError):
- self.read_input("> ", completion_mode=cmd2.CompletionMode.COMMANDS)
-
@cmd2.with_category(EXAMPLE_COMMANDS)
def do_custom_choices(self, _) -> None:
"""Call read_input to use custom history and choices."""
@@ -47,17 +44,16 @@ def do_custom_choices(self, _) -> None:
input_str = self.read_input(
"> ",
history=self.custom_history,
- completion_mode=cmd2.CompletionMode.CUSTOM,
- choices=['choice_1', 'choice_2', 'choice_3'],
+ choices=["choice_1", "choice_2", "choice_3"],
)
except EOFError:
pass
else:
self.custom_history.append(input_str)
- def choices_provider(self) -> list[str]:
+ def choices_provider(self) -> cmd2.Choices:
"""Example choices provider function."""
- return ["from_provider_1", "from_provider_2", "from_provider_3"]
+ return cmd2.Choices.from_values(["from_provider_1", "from_provider_2", "from_provider_3"])
@cmd2.with_category(EXAMPLE_COMMANDS)
def do_custom_choices_provider(self, _) -> None:
@@ -67,7 +63,6 @@ def do_custom_choices_provider(self, _) -> None:
input_str = self.read_input(
"> ",
history=self.custom_history,
- completion_mode=cmd2.CompletionMode.CUSTOM,
choices_provider=ReadInputApp.choices_provider,
)
except EOFError:
@@ -80,9 +75,7 @@ def do_custom_completer(self, _) -> None:
"""Call read_input to use custom history and completer function."""
self.poutput("Tab completing paths and using custom history")
try:
- input_str = self.read_input(
- "> ", history=self.custom_history, completion_mode=cmd2.CompletionMode.CUSTOM, completer=cmd2.Cmd.path_complete
- )
+ input_str = self.read_input("> ", history=self.custom_history, completer=cmd2.Cmd.path_complete)
self.custom_history.append(input_str)
except EOFError:
pass
@@ -90,25 +83,46 @@ def do_custom_completer(self, _) -> None:
@cmd2.with_category(EXAMPLE_COMMANDS)
def do_custom_parser(self, _) -> None:
"""Call read_input to use a custom history and an argument parser."""
- parser = cmd2.Cmd2ArgumentParser(prog='', description="An example parser")
- parser.add_argument('-o', '--option', help="an optional arg")
- parser.add_argument('arg_1', help="a choice for this arg", metavar='arg_1', choices=['my_choice', 'your_choice'])
- parser.add_argument('arg_2', help="path of something", completer=cmd2.Cmd.path_complete)
+ parser = cmd2.Cmd2ArgumentParser(prog="", description="An example parser")
+ parser.add_argument("-o", "--option", help="an optional arg")
+ parser.add_argument("arg_1", help="a choice for this arg", metavar="arg_1", choices=["my_choice", "your_choice"])
+ parser.add_argument("arg_2", help="path of something", completer=cmd2.Cmd.path_complete)
self.poutput("Tab completing with argument parser and using custom history")
self.poutput(parser.format_usage())
try:
- input_str = self.read_input(
- "> ", history=self.custom_history, completion_mode=cmd2.CompletionMode.CUSTOM, parser=parser
- )
+ input_str = self.read_input("> ", history=self.custom_history, parser=parser)
except EOFError:
pass
else:
self.custom_history.append(input_str)
+ @cmd2.with_category(EXAMPLE_COMMANDS)
+ def do_read_password(self, _) -> None:
+ """Call read_secret to read a password without displaying it while being typed.
+
+ WARNING: Password will be displayed for verification after it is typed.
+ """
+ self.poutput("The input will not be displayed on the screen")
+ try:
+ password = self.read_secret("Password: ")
+ self.poutput(f"You entered: {password}")
+ except EOFError:
+ pass
+
+ def do_eat(self, arg):
+ """Example of using the select method for reading multiple choice input.
+
+ Usage: eat wheatties
+ """
+ sauce = self.select("sweet salty", "Sauce? ")
+ result = "{food} with {sauce} sauce, yum!"
+ result = result.format(food=arg, sauce=sauce)
+ self.stdout.write(result + "\n")
+
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
app = ReadInputApp()
diff --git a/examples/remove_builtin_commands.py b/examples/remove_builtin_commands.py
index eb226c7a8..c4fe0a1c3 100755
--- a/examples/remove_builtin_commands.py
+++ b/examples/remove_builtin_commands.py
@@ -18,13 +18,13 @@ def __init__(self) -> None:
super().__init__()
# To hide commands from displaying in the help menu, add them to the hidden_commands list
- self.hidden_commands.append('history')
+ self.hidden_commands.append("history")
# To remove built-in commands entirely, delete their "do_*" function from the cmd2.Cmd class
del cmd2.Cmd.do_edit
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
app = RemoveBuiltinCommands()
diff --git a/examples/remove_settable.py b/examples/remove_settable.py
index c2c338890..c11eff7d8 100755
--- a/examples/remove_settable.py
+++ b/examples/remove_settable.py
@@ -7,10 +7,10 @@
class MyApp(cmd2.Cmd):
def __init__(self) -> None:
super().__init__()
- self.remove_settable('debug')
+ self.remove_settable("debug")
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
c = MyApp()
diff --git a/examples/rich_tables.py b/examples/rich_tables.py
index e2c891064..a729b2a34 100755
--- a/examples/rich_tables.py
+++ b/examples/rich_tables.py
@@ -20,7 +20,7 @@
import cmd2
from cmd2.colors import Color
-CITY_HEADERS = ['Flag', 'City', 'Country', '2025 Population']
+CITY_HEADERS = ["Flag", "City", "Country", "2025 Population"]
CITY_DATA = [
["🇯🇵", "Tokyo (東京)", "Japan", 37_036_200],
["🇮🇳", "Delhi", "India", 34_665_600],
@@ -37,13 +37,13 @@
CITY_CAPTION = "Data from https://worldpopulationreview.com/"
COUNTRY_HEADERS = [
- 'Flag',
- 'Country',
- '2025 Population',
- 'Area (M km^2)',
- 'Population Density (/km^2)',
- 'GDP (million US$)',
- 'GDP per capita (US$)',
+ "Flag",
+ "Country",
+ "2025 Population",
+ "Area (M km^2)",
+ "Population Density (/km^2)",
+ "GDP (million US$)",
+ "GDP per capita (US$)",
]
COUNTRY_DATA = [
["🇮🇳", "India", 1_463_870_000, 3.3, 492, 4_187_017, 2_878],
@@ -64,19 +64,15 @@
class TableApp(cmd2.Cmd):
"""Cmd2 application to demonstrate displaying tabular data using rich."""
- TABLE_CATEGORY = 'Table Commands'
+ DEFAULT_CATEGORY = "Table Commands"
def __init__(self) -> None:
"""Initialize the cmd2 application."""
super().__init__()
# Prints an intro banner once upon application startup
- self.intro = 'Are you curious which countries and cities on Earth have the largest populations?'
+ self.intro = "Are you curious which countries and cities on Earth have the largest populations?"
- # Set the default category name
- self.default_category = 'cmd2 Built-in Commands'
-
- @cmd2.with_category(TABLE_CATEGORY)
def do_cities(self, _: cmd2.Statement) -> None:
"""Display the cities with the largest population."""
table = Table(title=CITY_TITLE, caption=CITY_CAPTION)
@@ -91,7 +87,6 @@ def do_cities(self, _: cmd2.Statement) -> None:
self.poutput(table)
- @cmd2.with_category(TABLE_CATEGORY)
def do_countries(self, _: cmd2.Statement) -> None:
"""Display the countries with the largest population."""
table = Table(title=COUNTRY_TITLE, caption=COUNTRY_CAPTION)
@@ -110,9 +105,9 @@ def do_countries(self, _: cmd2.Statement) -> None:
case percap if "per capita" in percap:
header_style = Color.BRIGHT_GREEN
style = Color.GREEN
- case flag if 'Flag' in flag:
+ case flag if "Flag" in flag:
justify = "center"
- case country if 'Country' in country:
+ case country if "Country" in country:
justify = "left"
table.add_column(header, justify=justify, header_style=header_style, style=style)
@@ -125,6 +120,6 @@ def do_countries(self, _: cmd2.Statement) -> None:
self.poutput(table)
-if __name__ == '__main__':
+if __name__ == "__main__":
app = TableApp()
app.cmdloop()
diff --git a/examples/scripts/conditional.py b/examples/scripts/conditional.py
index 99c442de7..ca47465f6 100644
--- a/examples/scripts/conditional.py
+++ b/examples/scripts/conditional.py
@@ -13,36 +13,36 @@
if len(sys.argv) > 1:
directory = sys.argv[1]
- print(f'Using specified directory: {directory!r}')
+ print(f"Using specified directory: {directory!r}")
else:
- directory = 'foobar'
- print(f'Using default directory: {directory!r}')
+ directory = "foobar"
+ print(f"Using default directory: {directory!r}")
# Keep track of where we stared
original_dir = os.getcwd()
# Try to change to the specified directory
-result = app(f'cd {directory}')
+result = app(f"cd {directory}")
# Conditionally do something based on the results of the last command
if result:
print(f"STDOUT: {result.stdout}\n")
print(f"STDERR: {result.stderr}\n")
- print(f'\nContents of directory {directory!r}:')
- result = app('dir -l')
+ print(f"\nContents of directory {directory!r}:")
+ result = app("dir -l")
print(f"STDOUT: {result.stdout}\n")
print(f"STDERR: {result.stderr}\n")
- print(f'{result.data}\n')
+ print(f"{result.data}\n")
# Change back to where we were
- print(f'Changing back to original directory: {original_dir!r}')
- app(f'cd {original_dir}')
+ print(f"Changing back to original directory: {original_dir!r}")
+ app(f"cd {original_dir}")
else:
# cd command failed, print a warning
- print(f'Failed to change directory to {directory!r}')
+ print(f"Failed to change directory to {directory!r}")
print(f"STDOUT: {result.stdout}\n")
print(f"STDERR: {result.stderr}\n")
diff --git a/examples/scripts/save_help_text.py b/examples/scripts/save_help_text.py
index 41636b085..be792d2c9 100644
--- a/examples/scripts/save_help_text.py
+++ b/examples/scripts/save_help_text.py
@@ -2,29 +2,36 @@
This is meant to be run within a cmd2 session using run_pyscript.
"""
-import argparse
import os
import sys
from typing import TextIO
+from cmd2 import Cmd2ArgumentParser
+
ASTERISKS = "********************************************************"
-def get_sub_commands(parser: argparse.ArgumentParser) -> list[str]:
- """Get a list of subcommands for an ArgumentParser."""
+def get_sub_commands(parser: Cmd2ArgumentParser) -> list[str]:
+ """Get a list of subcommands for a Cmd2ArgumentParser."""
+ try:
+ subparsers_action = parser.get_subparsers_action()
+ except ValueError:
+ # No subcommands
+ return []
+
+ # Prevent redundant traversal of parser aliases
+ checked_parsers: set[Cmd2ArgumentParser] = set()
+
sub_cmds = []
+ for subcmd, subcmd_parser in subparsers_action.choices.items():
+ if subcmd_parser in checked_parsers:
+ continue
+ checked_parsers.add(subcmd_parser)
- # Check if this is parser has subcommands
- if parser is not None and parser._subparsers is not None:
- # Find the _SubParsersAction for the subcommands of this parser
- for action in parser._subparsers._actions:
- if isinstance(action, argparse._SubParsersAction):
- for sub_cmd, sub_cmd_parser in action.choices.items():
- sub_cmds.append(sub_cmd)
+ sub_cmds.append(subcmd)
- # Look for nested subcommands
- sub_cmds.extend(f'{sub_cmd} {nested_sub_cmd}' for nested_sub_cmd in get_sub_commands(sub_cmd_parser))
- break
+ # Look for nested subcommands
+ sub_cmds.extend(f"{subcmd} {nested_subcmd}" for nested_subcmd in get_sub_commands(subcmd_parser))
sub_cmds.sort()
return sub_cmds
@@ -32,23 +39,24 @@ def get_sub_commands(parser: argparse.ArgumentParser) -> list[str]:
def add_help_to_file(item: str, outfile: TextIO, is_command: bool) -> None:
"""Write help text for commands and topics to the output file
+
:param item: what is having its help text saved
:param outfile: file being written to
:param is_command: tells if the item is a command and not just a help topic.
"""
label = "COMMAND" if is_command else "TOPIC"
- header = f'{ASTERISKS}\n{label}: {item}\n{ASTERISKS}\n'
+ header = f"{ASTERISKS}\n{label}: {item}\n{ASTERISKS}\n"
outfile.write(header)
- result = app(f'help {item}')
+ result = app(f"help {item}")
outfile.write(result.stdout)
def main() -> None:
"""Main function of this script."""
# Make sure we have access to self
- if 'self' not in globals():
+ if "self" not in globals():
print("Re-run this script from a cmd2 application where self_in_py is True")
return
@@ -57,41 +65,43 @@ def main() -> None:
print(f"Usage: {os.path.basename(sys.argv[0])} ")
return
- # Open the output file
outfile_path = os.path.expanduser(sys.argv[1])
try:
- with open(outfile_path, 'w') as outfile:
- pass
- except OSError as e:
- print(f"Error opening {outfile_path} because: {e}")
- return
+ with open(outfile_path, "w") as outfile:
+ # Write the help summary
+ header = f"{ASTERISKS}\nSUMMARY\n{ASTERISKS}\n"
+ outfile.write(header)
- # Write the help summary
- header = f'{ASTERISKS}\nSUMMARY\n{ASTERISKS}\n'
- outfile.write(header)
+ result = app("help -v")
+ outfile.write(result.stdout)
- result = app('help -v')
- outfile.write(result.stdout)
+ # Get a list of all commands and help topics and then filter out duplicates
+ all_commands = set(self.get_all_commands())
+ all_topics = set(self.get_help_topics())
+ to_save = sorted(all_commands | all_topics)
+
+ for item in to_save:
+ is_command = item in all_commands
+ add_help_to_file(item, outfile, is_command)
+
+ if not is_command:
+ continue
- # Get a list of all commands and help topics and then filter out duplicates
- all_commands = set(self.get_all_commands())
- all_topics = set(self.get_help_topics())
- to_save = list(all_commands | all_topics)
- to_save.sort()
+ cmd_func = self.get_command_func(item)
+ parser = self.command_parsers.get(cmd_func)
+ if parser is None:
+ continue
- for item in to_save:
- is_command = item in all_commands
- add_help_to_file(item, outfile, is_command)
+ # Add any subcommands
+ for subcmd in get_sub_commands(parser):
+ full_cmd = f"{item} {subcmd}"
+ add_help_to_file(full_cmd, outfile, is_command)
- if is_command:
- # Add any subcommands
- for subcmd in get_sub_commands(getattr(self.cmd_func(item), 'argparser', None)):
- full_cmd = f'{item} {subcmd}'
- add_help_to_file(full_cmd, outfile, is_command)
+ print(f"Output written to {outfile_path}")
- outfile.close()
- print(f"Output written to {outfile_path}")
+ except OSError as ex:
+ print(f"Error handling {outfile_path} because: {ex}")
-# Run main function
-main()
+if __name__ == "__main__":
+ main()
diff --git a/examples/transcript_example.py b/examples/transcript_example.py
deleted file mode 100755
index 06b06c2d7..000000000
--- a/examples/transcript_example.py
+++ /dev/null
@@ -1,84 +0,0 @@
-#!/usr/bin/env python
-"""A sample application for cmd2.
-
-Thanks to cmd2's built-in transcript testing capability, it also serves as a
-test suite for transcript_example.py when used with the transcript_regex.txt transcript.
-
-Running `python transcript_example.py -t transcript_regex.txt` will run all the commands in
-the transcript against transcript_example.py, verifying that the output produced matches
-the transcript.
-"""
-
-import random
-
-import cmd2
-
-
-class CmdLineApp(cmd2.Cmd):
- """Example cmd2 application."""
-
- # Setting this true makes it run a shell command if a cmd2/cmd command doesn't exist
- # default_to_shell = True # noqa: ERA001
- MUMBLES = ('like', '...', 'um', 'er', 'hmmm', 'ahh')
- MUMBLE_FIRST = ('so', 'like', 'well')
- MUMBLE_LAST = ('right?',)
-
- def __init__(self) -> None:
- shortcuts = cmd2.DEFAULT_SHORTCUTS
- shortcuts.update({'&': 'speak'})
- super().__init__(multiline_commands=['orate'], shortcuts=shortcuts)
-
- # Make maxrepeats settable at runtime
- self.maxrepeats = 3
- self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command', self))
-
- speak_parser = cmd2.Cmd2ArgumentParser()
- speak_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay')
- speak_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE')
- speak_parser.add_argument('-r', '--repeat', type=int, help='output [n] times')
- speak_parser.add_argument('words', nargs='+', help='words to say')
-
- @cmd2.with_argparser(speak_parser)
- def do_speak(self, args) -> None:
- """Repeats what you tell me to."""
- words = []
- for word in args.words:
- if args.piglatin:
- word = f'{word[1:]}{word[0]}ay'
- if args.shout:
- word = word.upper()
- words.append(word)
- repetitions = args.repeat or 1
- for _ in range(min(repetitions, self.maxrepeats)):
- # .poutput handles newlines, and accommodates output redirection too
- self.poutput(' '.join(words))
-
- do_say = do_speak # now "say" is a synonym for "speak"
- do_orate = do_speak # another synonym, but this one takes multi-line input
-
- mumble_parser = cmd2.Cmd2ArgumentParser()
- mumble_parser.add_argument('-r', '--repeat', type=int, help='how many times to repeat')
- mumble_parser.add_argument('words', nargs='+', help='words to say')
-
- @cmd2.with_argparser(mumble_parser)
- def do_mumble(self, args) -> None:
- """Mumbles what you tell me to."""
- repetitions = args.repeat or 1
- for _ in range(min(repetitions, self.maxrepeats)):
- output = []
- if random.random() < 0.33:
- output.append(random.choice(self.MUMBLE_FIRST))
- for word in args.words:
- if random.random() < 0.40:
- output.append(random.choice(self.MUMBLES))
- output.append(word)
- if random.random() < 0.25:
- output.append(random.choice(self.MUMBLE_LAST))
- self.poutput(' '.join(output))
-
-
-if __name__ == '__main__':
- import sys
-
- c = CmdLineApp()
- sys.exit(c.cmdloop())
diff --git a/examples/transcripts/exampleSession.txt b/examples/transcripts/exampleSession.txt
deleted file mode 100644
index 84ff1e3f6..000000000
--- a/examples/transcripts/exampleSession.txt
+++ /dev/null
@@ -1,14 +0,0 @@
-# Run this transcript with "python transcript_example.py -t exampleSession.txt"
-# Anything between two forward slashes, /, is interpreted as a regular expression (regex).
-# The regex for editor will match whatever program you use.
-# regexes on prompts just make the trailing space obvious
-(Cmd) set
-allow_style: '/(Terminal|Always|Never)/'
-debug: False
-echo: False
-editor: /.*?/
-feedback_to_output: False
-max_completion_items: 50
-maxrepeats: 3
-quiet: False
-timing: False
diff --git a/examples/transcripts/pirate.transcript b/examples/transcripts/pirate.transcript
deleted file mode 100644
index 570f0cd7b..000000000
--- a/examples/transcripts/pirate.transcript
+++ /dev/null
@@ -1,10 +0,0 @@
-arrr> loot
-Now we gots 1 doubloons
-arrr> loot
-Now we gots 2 doubloons
-arrr> loot
-Now we gots 3 doubloons
-arrr> drink 3
-Now we gots 0 doubloons
-arrr> yo --ho 3 rum
-yo ho ho ho and a bottle of rum
diff --git a/examples/transcripts/quit.txt b/examples/transcripts/quit.txt
deleted file mode 100644
index 6dcf8c666..000000000
--- a/examples/transcripts/quit.txt
+++ /dev/null
@@ -1 +0,0 @@
-(Cmd) quit
diff --git a/examples/transcripts/transcript_regex.txt b/examples/transcripts/transcript_regex.txt
deleted file mode 100644
index 1eef14276..000000000
--- a/examples/transcripts/transcript_regex.txt
+++ /dev/null
@@ -1,15 +0,0 @@
-# Run this transcript with "python transcript_example.py -t transcript_regex.txt"
-# Anything between two forward slashes, /, is interpreted as a regular expression (regex).
-# The regex for editor will match whatever program you use.
-# regexes on prompts just make the trailing space obvious
-(Cmd) set
-allow_style: '/(Terminal|Always|Never)/'
-always_show_hint: False
-debug: False
-echo: False
-editor: /.*?/
-feedback_to_output: False
-max_completion_items: 50
-maxrepeats: 3
-quiet: False
-timing: False
diff --git a/examples/unicode_commands.py b/examples/unicode_commands.py
index 3321e636f..ee8cc0dcb 100755
--- a/examples/unicode_commands.py
+++ b/examples/unicode_commands.py
@@ -11,7 +11,7 @@ class UnicodeApp(cmd2.Cmd):
def __init__(self) -> None:
super().__init__()
- self.intro = 'Welcome the Unicode example app. Note the full Unicode support: 😇 💩'
+ self.intro = "Welcome the Unicode example app. Note the full Unicode support: 😇 💩"
def do_𝛑print(self, _) -> None: # noqa: PLC2401
"""This command prints 𝛑 to 5 decimal places."""
@@ -22,6 +22,6 @@ def do_你好(self, arg) -> None: # noqa: N802, PLC2401
self.poutput("你好 " + arg)
-if __name__ == '__main__':
+if __name__ == "__main__":
app = UnicodeApp()
app.cmdloop()
diff --git a/mkdocs.yml b/mkdocs.yml
index c2a939b42..ec47edc38 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -74,6 +74,7 @@ plugins:
unwrap_annotated: true
filters:
- "!^_"
+ - "get_bottom_toolbar"
merge_init_into_class: true
docstring_style: sphinx
docstring_section_style: spacy
@@ -154,6 +155,7 @@ nav:
- Features:
- features/index.md
- features/argument_processing.md
+ - features/async_commands.md
- features/builtin_commands.md
- features/clipboard.md
- features/commands.md
@@ -179,7 +181,6 @@ nav:
- features/startup_commands.md
- features/table_creation.md
- features/theme.md
- - features/transcripts.md
- Examples:
- examples/index.md
- examples/getting_started.md
@@ -194,23 +195,22 @@ nav:
- api/index.md
- api/cmd.md
- api/argparse_completer.md
- - api/argparse_custom.md
+ - api/argparse_utils.md
- api/clipboard.md
- api/colors.md
- - api/command_definition.md
+ - api/command_set.md
+ - api/completion.md
- api/constants.md
- api/decorators.md
- api/exceptions.md
- api/history.md
- api/parsing.md
- api/plugin.md
+ - api/pt_utils.md
- api/py_bridge.md
- api/rich_utils.md
- - api/rl_utils.md
- api/string_utils.md
- api/styles.md
- - api/terminal_utils.md
- - api/transcript.md
- api/utils.md
- Version Upgrades:
- upgrades.md
diff --git a/pyproject.toml b/pyproject.toml
index 950708aa9..606a031c3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,5 +1,5 @@
[build-system]
-requires = ["build>=1.2.2", "setuptools>=80.7.1", "setuptools-scm>=9.2"]
+requires = ["build>=1.3.0", "setuptools>=80.8.0", "setuptools-scm>=9.2.1"]
build-backend = "setuptools.build_meta"
[project]
@@ -30,22 +30,21 @@ classifiers = [
]
dependencies = [
"backports.strenum; python_version == '3.10'",
- "gnureadline>=8; platform_system == 'Darwin'",
+ "prompt-toolkit>=3.0.52",
"pyperclip>=1.8.2",
- "pyreadline3>=3.4; platform_system == 'Windows'",
- "rich>=14.3.0",
- "rich-argparse>=1.7.1",
+ "rich>=15.0.0",
+ "rich-argparse>=1.7.2",
"typing-extensions; python_version == '3.10'",
]
[dependency-groups]
-build = ["build>=1.2.2", "setuptools>=80.7.1", "setuptools-scm>=9.2"]
+build = ["build>=1.3.0", "setuptools>=80.8.0", "setuptools-scm>=9.2.1"]
dev = [
"codecov>=2.1",
"ipython>=8.23",
"mkdocstrings[python]>=1",
"mypy>=1.13",
- "pre-commit>=3",
+ "prek>=0.3.5",
"pytest>=8.1.1",
"pytest-cov>=5",
"pytest-mock>=3.14.1",
@@ -55,11 +54,11 @@ dev = [
]
docs = [
"mkdocstrings[python]>=1",
- "setuptools>=80.7.1",
+ "setuptools>=80.8.0",
"setuptools_scm>=8",
"zensical>=0.0.17",
]
-quality = ["pre-commit>=3"]
+quality = ["prek>=0.3.5"]
test = [
"codecov>=2.1",
"coverage>=7.11",
@@ -67,7 +66,7 @@ test = [
"pytest-cov>=5",
"pytest-mock>=3.14.1",
]
-validate = ["mypy>=1.13", "ruff>=0.14.10", "types-setuptools>=80.7.1"]
+validate = ["mypy>=1.13", "ruff>=0.14.10", "types-setuptools>=80.8.0"]
[tool.mypy]
disallow_incomplete_defs = true
diff --git a/ruff.toml b/ruff.toml
index 7d5962b79..e63651609 100644
--- a/ruff.toml
+++ b/ruff.toml
@@ -138,9 +138,6 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
mccabe.max-complexity = 49
[lint.per-file-ignores]
-# Do not call setattr with constant attribute value
-"cmd2/argparse_custom.py" = ["B010"]
-
# Ignore various warnings in examples/ directory
"examples/*.py" = [
"ANN", # Ignore all type annotation rules in examples folder
@@ -154,7 +151,7 @@ mccabe.max-complexity = 49
# Ignore starting a process with a partial executable path (i.e. git)
"scripts/validate_tag.py" = ["S607"]
-# Ingore various rulesets in test directories
+# Ignore various rulesets in test directories
"{tests}/*.py" = [
"ANN", # Ignore all type annotation rules in test folders
"ARG", # Ignore all unused argument warnings in test folders
@@ -168,7 +165,7 @@ mccabe.max-complexity = 49
[format]
# Like Black, use double quotes for strings.
-quote-style = "preserve"
+quote-style = "double"
# Like Black, indent with spaces, rather than tabs.
indent-style = "space"
diff --git a/scripts/validate_tag.py b/scripts/validate_tag.py
index 4c8325645..ff5d0ecf1 100755
--- a/scripts/validate_tag.py
+++ b/scripts/validate_tag.py
@@ -4,7 +4,7 @@
import re
import subprocess
-SEMVER_SIMPLE = re.compile(r'(\d+)\.(\d+)\.(\d+)((a|b|rc)(\d+))?')
+SEMVER_SIMPLE = re.compile(r"(\d+)\.(\d+)\.(\d+)((a|b|rc)(\d+))?")
SEMVER_PATTERN = re.compile(
r"""
^ # Start of the string
@@ -31,12 +31,12 @@ def get_current_tag() -> str:
try:
# Gets the name of the latest tag reachable from the current commit
result = subprocess.run(
- ['git', 'describe', '--exact-match', '--tags', '--abbrev=0'], capture_output=True, text=True, check=True
+ ["git", "describe", "--exact-match", "--tags", "--abbrev=0"], capture_output=True, text=True, check=True
)
return result.stdout.strip()
except subprocess.CalledProcessError:
print("Could not find a reachable tag.")
- return ''
+ return ""
def is_semantic_version(tag_name: str) -> bool:
@@ -73,12 +73,12 @@ def is_semantic_version(tag_name: str) -> bool:
return bool(semver_pattern.match(tag_name))
-if __name__ == '__main__':
+if __name__ == "__main__":
import sys
git_tag = get_current_tag()
if not git_tag:
- print('Git tag does not exist for current commit.')
+ print("Git tag does not exist for current commit.")
sys.exit(-1)
if not is_semantic_version(git_tag):
diff --git a/tests/conftest.py b/tests/conftest.py
index fa31b42b9..8cbce3036 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,6 +1,5 @@
"""Cmd2 unit/functional testing"""
-import argparse
import sys
from collections.abc import Callable
from contextlib import redirect_stderr
@@ -11,18 +10,16 @@
TypeVar,
cast,
)
-from unittest import mock
import pytest
import cmd2
from cmd2 import rich_utils as ru
-from cmd2.rl_utils import readline
from cmd2.utils import StdSim
# For type hinting decorators
-P = ParamSpec('P')
-T = TypeVar('T')
+P = ParamSpec("P")
+T = TypeVar("T")
def verify_help_text(cmd2_app: cmd2.Cmd, help_output: str | list[str], verbose_strings: list[str] | None = None) -> None:
@@ -32,7 +29,7 @@ def verify_help_text(cmd2_app: cmd2.Cmd, help_output: str | list[str], verbose_s
:param help_output: output of help, either as a string or list of strings
:param verbose_strings: optional list of verbose strings to search for
"""
- help_text = help_output if isinstance(help_output, str) else ''.join(help_output)
+ help_text = help_output if isinstance(help_output, str) else "".join(help_output)
commands = cmd2_app.get_visible_commands()
for command in commands:
assert command in help_text
@@ -58,7 +55,7 @@ def normalize(block: str) -> list[str]:
from each line.
"""
assert isinstance(block, str)
- block = block.strip('\n')
+ block = block.strip("\n")
return [line.rstrip() for line in block.splitlines()]
@@ -117,54 +114,7 @@ def cmd_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
# These are odd file names for testing quoting of them
-odd_file_names = ['nothingweird', 'has spaces', '"is_double_quoted"', "'is_single_quoted'"]
-
-
-def complete_tester(text: str, line: str, begidx: int, endidx: int, app: cmd2.Cmd) -> str | None:
- """This is a convenience function to test cmd2.complete() since
- in a unit test environment there is no actual console readline
- is monitoring. Therefore we use mock to provide readline data
- to complete().
-
- :param text: the string prefix we are attempting to match
- :param line: the current input line with leading whitespace removed
- :param begidx: the beginning index of the prefix text
- :param endidx: the ending index of the prefix text
- :param app: the cmd2 app that will run completions
- :return: The first matched string or None if there are no matches
- Matches are stored in app.completion_matches
- These matches also have been sorted by complete()
- """
-
- def get_line() -> str:
- return line
-
- def get_begidx() -> int:
- return begidx
-
- def get_endidx() -> int:
- return endidx
-
- # Run the readline tab completion function with readline mocks in place
- with (
- mock.patch.object(readline, 'get_line_buffer', get_line),
- mock.patch.object(readline, 'get_begidx', get_begidx),
- mock.patch.object(readline, 'get_endidx', get_endidx),
- ):
- return app.complete(text, 0)
-
-
-def find_subcommand(action: argparse.ArgumentParser, subcmd_names: list[str]) -> argparse.ArgumentParser:
- if not subcmd_names:
- return action
- cur_subcmd = subcmd_names.pop(0)
- for sub_action in action._actions:
- if isinstance(sub_action, argparse._SubParsersAction):
- for choice_name, choice in sub_action.choices.items():
- if choice_name == cur_subcmd:
- return find_subcommand(choice, subcmd_names)
- break
- raise ValueError(f"Could not find subcommand '{subcmd_names}'")
+odd_file_names = ["nothingweird", "has spaces", '"is_double_quoted"', "'is_single_quoted'"]
if TYPE_CHECKING:
@@ -189,7 +139,7 @@ def __init__(self, *args, **kwargs):
# code placed here runs before cmd2 initializes
super().__init__(*args, **kwargs)
if not isinstance(self, cmd2.Cmd):
- raise TypeError('The ExternalTestMixin class is intended to be used in multiple inheritance with cmd2.Cmd')
+ raise TypeError("The ExternalTestMixin class is intended to be used in multiple inheritance with cmd2.Cmd")
# code placed here runs after cmd2 initializes
self._pybridge = cmd2.py_bridge.PyBridge(self)
diff --git a/tests/pyscript/echo.py b/tests/pyscript/echo.py
index c5999355a..4eb19d86e 100644
--- a/tests/pyscript/echo.py
+++ b/tests/pyscript/echo.py
@@ -2,7 +2,7 @@
app.cmd_echo = False
# echo defaults to current setting which is False, so this help text should not be echoed to pytest's stdout
-app('help alias')
+app("help alias")
# pytest's stdout should have this help text written to it
-app('help edit', echo=True)
+app("help edit", echo=True)
diff --git a/tests/pyscript/environment.py b/tests/pyscript/environment.py
index 758c85002..adced4ae0 100644
--- a/tests/pyscript/environment.py
+++ b/tests/pyscript/environment.py
@@ -4,7 +4,7 @@
app.cmd_echo = True
-if __name__ != '__main__':
+if __name__ != "__main__":
print(f"Error: __name__ is: {__name__}")
quit()
diff --git a/tests/pyscript/help.py b/tests/pyscript/help.py
index 480c6cd70..7cce3da3e 100644
--- a/tests/pyscript/help.py
+++ b/tests/pyscript/help.py
@@ -1,5 +1,5 @@
app.cmd_echo = True
-app('help')
+app("help")
# Exercise py_quit() in unit test
quit()
diff --git a/tests/pyscript/raises_exception.py b/tests/pyscript/raises_exception.py
index 9883a2b87..cfd466a77 100644
--- a/tests/pyscript/raises_exception.py
+++ b/tests/pyscript/raises_exception.py
@@ -1,3 +1,3 @@
"""Example demonstrating what happens when a Python script raises an exception"""
-x = 1 + 'blue'
+x = 1 + "blue"
diff --git a/tests/pyscript/recursive.py b/tests/pyscript/recursive.py
index f71234b8e..05cf49c81 100644
--- a/tests/pyscript/recursive.py
+++ b/tests/pyscript/recursive.py
@@ -5,4 +5,4 @@
app.cmd_echo = True
my_dir = os.path.dirname(os.path.realpath(sys.argv[0]))
-app('run_pyscript {}'.format(os.path.join(my_dir, 'stop.py')))
+app("run_pyscript {}".format(os.path.join(my_dir, "stop.py")))
diff --git a/tests/pyscript/self_in_py.py b/tests/pyscript/self_in_py.py
index ee26293f6..67a1b7dc3 100644
--- a/tests/pyscript/self_in_py.py
+++ b/tests/pyscript/self_in_py.py
@@ -1,5 +1,5 @@
# Tests self_in_py in pyscripts
-if 'self' in globals():
+if "self" in globals():
print("I see self")
else:
print("I do not see self")
diff --git a/tests/pyscript/stop.py b/tests/pyscript/stop.py
index 31b587bd2..a7d7eb69b 100644
--- a/tests/pyscript/stop.py
+++ b/tests/pyscript/stop.py
@@ -1,8 +1,8 @@
app.cmd_echo = True
-app('help')
+app("help")
# This will set stop to True in the PyBridge
-app('quit')
+app("quit")
# Exercise py_quit() in unit test
quit()
diff --git a/tests/scripts/postcmds.txt b/tests/scripts/postcmds.txt
index 30f470550..d48172c0d 100644
--- a/tests/scripts/postcmds.txt
+++ b/tests/scripts/postcmds.txt
@@ -1 +1 @@
-set allow_style Never
+set allow_style Terminal
diff --git a/tests/test_argparse.py b/tests/test_argparse.py
index c2cfb7778..8e0c44459 100644
--- a/tests/test_argparse.py
+++ b/tests/test_argparse.py
@@ -24,10 +24,10 @@ def namespace_provider(self) -> argparse.Namespace:
@staticmethod
def _say_parser_builder() -> cmd2.Cmd2ArgumentParser:
say_parser = cmd2.Cmd2ArgumentParser()
- say_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay')
- say_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE')
- say_parser.add_argument('-r', '--repeat', type=int, help='output [n] times')
- say_parser.add_argument('words', nargs='+', help='words to say')
+ say_parser.add_argument("-p", "--piglatin", action="store_true", help="atinLay")
+ say_parser.add_argument("-s", "--shout", action="store_true", help="N00B EMULATION MODE")
+ say_parser.add_argument("-r", "--repeat", type=int, help="output [n] times")
+ say_parser.add_argument("words", nargs="+", help="words to say")
return say_parser
@cmd2.with_argparser(_say_parser_builder)
@@ -41,53 +41,53 @@ def do_say(self, args, *, keyword_arg: str | None = None) -> None:
for word in args.words:
modified_word = word
if word is None:
- modified_word = ''
+ modified_word = ""
if args.piglatin:
- modified_word = f'{word[1:]}{word[0]}ay'
+ modified_word = f"{word[1:]}{word[0]}ay"
if args.shout:
modified_word = word.upper()
words.append(modified_word)
repetitions = args.repeat or 1
for _ in range(min(repetitions, self.maxrepeats)):
- self.stdout.write(' '.join(words))
- self.stdout.write('\n')
+ self.stdout.write(" ".join(words))
+ self.stdout.write("\n")
if keyword_arg is not None:
print(keyword_arg)
- tag_parser = cmd2.Cmd2ArgumentParser(description='create a html tag')
- tag_parser.add_argument('tag', help='tag')
- tag_parser.add_argument('content', nargs='+', help='content to surround with tag')
+ tag_parser = cmd2.Cmd2ArgumentParser(description="create a html tag")
+ tag_parser.add_argument("tag", help="tag")
+ tag_parser.add_argument("content", nargs="+", help="content to surround with tag")
@cmd2.with_argparser(tag_parser, preserve_quotes=True)
def do_tag(self, args) -> None:
- self.stdout.write('<{0}>{1}{0}>'.format(args.tag, ' '.join(args.content)))
- self.stdout.write('\n')
+ self.stdout.write("<{0}>{1}{0}>".format(args.tag, " ".join(args.content)))
+ self.stdout.write("\n")
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser(), ns_provider=namespace_provider)
def do_test_argparse_ns(self, args) -> None:
- self.stdout.write(f'{args.custom_stuff}')
+ self.stdout.write(f"{args.custom_stuff}")
@cmd2.with_argument_list
def do_arglist(self, arglist, *, keyword_arg: str | None = None) -> None:
if isinstance(arglist, list):
- self.stdout.write('True')
+ self.stdout.write("True")
else:
- self.stdout.write('False')
+ self.stdout.write("False")
if keyword_arg is not None:
print(keyword_arg)
@cmd2.with_argument_list(preserve_quotes=True)
def do_preservelist(self, arglist) -> None:
- self.stdout.write(f'{arglist}')
+ self.stdout.write(f"{arglist}")
@classmethod
def _speak_parser_builder(cls) -> cmd2.Cmd2ArgumentParser:
known_parser = cmd2.Cmd2ArgumentParser()
- known_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay')
- known_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE')
- known_parser.add_argument('-r', '--repeat', type=int, help='output [n] times')
+ known_parser.add_argument("-p", "--piglatin", action="store_true", help="atinLay")
+ known_parser.add_argument("-s", "--shout", action="store_true", help="N00B EMULATION MODE")
+ known_parser.add_argument("-r", "--repeat", type=int, help="output [n] times")
return known_parser
@cmd2.with_argparser(_speak_parser_builder, with_unknown_args=True)
@@ -97,27 +97,27 @@ def do_speak(self, args, extra, *, keyword_arg: str | None = None) -> None:
for word in extra:
modified_word = word
if word is None:
- modified_word = ''
+ modified_word = ""
if args.piglatin:
- modified_word = f'{word[1:]}{word[0]}ay'
+ modified_word = f"{word[1:]}{word[0]}ay"
if args.shout:
modified_word = word.upper()
words.append(modified_word)
repetitions = args.repeat or 1
for _ in range(min(repetitions, self.maxrepeats)):
- self.stdout.write(' '.join(words))
- self.stdout.write('\n')
+ self.stdout.write(" ".join(words))
+ self.stdout.write("\n")
if keyword_arg is not None:
print(keyword_arg)
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser(), preserve_quotes=True, with_unknown_args=True)
def do_test_argparse_with_list_quotes(self, args, extra) -> None:
- self.stdout.write('{}'.format(' '.join(extra)))
+ self.stdout.write("{}".format(" ".join(extra)))
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser(), ns_provider=namespace_provider, with_unknown_args=True)
def do_test_argparse_with_list_ns(self, args, extra) -> None:
- self.stdout.write(f'{args.custom_stuff}')
+ self.stdout.write(f"{args.custom_stuff}")
@pytest.fixture
@@ -131,25 +131,25 @@ def test_invalid_syntax(argparse_app) -> None:
def test_argparse_basic_command(argparse_app) -> None:
- out, _err = run_cmd(argparse_app, 'say hello')
- assert out == ['hello']
+ out, _err = run_cmd(argparse_app, "say hello")
+ assert out == ["hello"]
def test_argparse_remove_quotes(argparse_app) -> None:
out, _err = run_cmd(argparse_app, 'say "hello there"')
- assert out == ['hello there']
+ assert out == ["hello there"]
def test_argparse_with_no_args(argparse_app) -> None:
"""Make sure we receive TypeError when calling argparse-based function with no args"""
with pytest.raises(TypeError) as excinfo:
argparse_app.do_say()
- assert 'Expected arguments' in str(excinfo.value)
+ assert "Expected arguments" in str(excinfo.value)
def test_argparser_kwargs(argparse_app, capsys) -> None:
"""Test with_argparser wrapper passes through kwargs to command function"""
- argparse_app.do_say('word', keyword_arg="foo")
+ argparse_app.do_say("word", keyword_arg="foo")
out, _err = capsys.readouterr()
assert out == "foo\n"
@@ -160,18 +160,18 @@ def test_argparse_preserve_quotes(argparse_app) -> None:
def test_argparse_custom_namespace(argparse_app) -> None:
- out, _err = run_cmd(argparse_app, 'test_argparse_ns')
- assert out[0] == 'custom'
+ out, _err = run_cmd(argparse_app, "test_argparse_ns")
+ assert out[0] == "custom"
def test_argparse_with_list(argparse_app) -> None:
- out, _err = run_cmd(argparse_app, 'speak -s hello world!')
- assert out == ['HELLO WORLD!']
+ out, _err = run_cmd(argparse_app, "speak -s hello world!")
+ assert out == ["HELLO WORLD!"]
def test_argparse_with_list_remove_quotes(argparse_app) -> None:
out, _err = run_cmd(argparse_app, 'speak -s hello "world!"')
- assert out == ['HELLO WORLD!']
+ assert out == ["HELLO WORLD!"]
def test_argparse_with_list_preserve_quotes(argparse_app) -> None:
@@ -180,62 +180,62 @@ def test_argparse_with_list_preserve_quotes(argparse_app) -> None:
def test_argparse_with_list_custom_namespace(argparse_app) -> None:
- out, _err = run_cmd(argparse_app, 'test_argparse_with_list_ns')
- assert out[0] == 'custom'
+ out, _err = run_cmd(argparse_app, "test_argparse_with_list_ns")
+ assert out[0] == "custom"
def test_argparse_with_list_and_empty_doc(argparse_app) -> None:
- out, _err = run_cmd(argparse_app, 'speak -s hello world!')
- assert out == ['HELLO WORLD!']
+ out, _err = run_cmd(argparse_app, "speak -s hello world!")
+ assert out == ["HELLO WORLD!"]
def test_argparser_correct_args_with_quotes_and_midline_options(argparse_app) -> None:
out, _err = run_cmd(argparse_app, "speak 'This is a' -s test of the emergency broadcast system!")
- assert out == ['THIS IS A TEST OF THE EMERGENCY BROADCAST SYSTEM!']
+ assert out == ["THIS IS A TEST OF THE EMERGENCY BROADCAST SYSTEM!"]
def test_argparser_and_unknown_args_kwargs(argparse_app, capsys) -> None:
"""Test with_argparser wrapper passing through kwargs to command function"""
- argparse_app.do_speak('', keyword_arg="foo")
+ argparse_app.do_speak("", keyword_arg="foo")
out, _err = capsys.readouterr()
assert out == "foo\n"
def test_argparse_quoted_arguments_multiple(argparse_app) -> None:
out, _err = run_cmd(argparse_app, 'say "hello there" "rick & morty"')
- assert out == ['hello there rick & morty']
+ assert out == ["hello there rick & morty"]
def test_argparse_help_docstring(argparse_app) -> None:
- out, _err = run_cmd(argparse_app, 'help say')
- assert out[0].startswith('Usage: say')
- assert out[1] == ''
- assert out[2] == 'Repeat what you tell me to.'
+ out, _err = run_cmd(argparse_app, "help say")
+ assert out[0].startswith("Usage: say")
+ assert out[1] == ""
+ assert out[2] == "Repeat what you tell me to."
for line in out:
- assert not line.startswith(':')
+ assert not line.startswith(":")
def test_argparse_help_description(argparse_app) -> None:
- out, _err = run_cmd(argparse_app, 'help tag')
- assert out[0].startswith('Usage: tag')
- assert out[1] == ''
- assert out[2] == 'create a html tag'
+ out, _err = run_cmd(argparse_app, "help tag")
+ assert out[0].startswith("Usage: tag")
+ assert out[1] == ""
+ assert out[2] == "create a html tag"
def test_argparse_prog(argparse_app) -> None:
- out, _err = run_cmd(argparse_app, 'help tag')
- progname = out[0].split(' ')[1]
- assert progname == 'tag'
+ out, _err = run_cmd(argparse_app, "help tag")
+ progname = out[0].split(" ")[1]
+ assert progname == "tag"
def test_arglist(argparse_app) -> None:
out, _err = run_cmd(argparse_app, 'arglist "we should" get these')
- assert out[0] == 'True'
+ assert out[0] == "True"
def test_arglist_kwargs(argparse_app, capsys) -> None:
"""Test with_argument_list wrapper passes through kwargs to command function"""
- argparse_app.do_arglist('arg', keyword_arg="foo")
+ argparse_app.do_arglist("arg", keyword_arg="foo")
out, _err = capsys.readouterr()
assert out == "foo\n"
@@ -247,13 +247,59 @@ def test_preservelist(argparse_app) -> None:
def test_invalid_parser_builder(argparse_app):
parser_builder = None
- with pytest.raises(TypeError):
- argparse_app._build_parser(argparse_app, parser_builder, "fake_prog")
+ with pytest.raises(TypeError, match="Invalid type for parser_builder"):
+ argparse_app._build_parser(argparse_app, parser_builder)
+
+
+def test_invalid_parser_return_type(argparse_app):
+ def bad_builder():
+ return argparse.ArgumentParser()
+
+ with pytest.raises(TypeError, match="must be a Cmd2ArgumentParser or a subclass of it"):
+ argparse_app._build_parser(argparse_app, bad_builder)
+
+
+def test_invalid_parser_return_type_staticmethod(argparse_app):
+ def bad_builder():
+ return argparse.ArgumentParser()
+
+ sm = staticmethod(bad_builder)
+
+ with pytest.raises(TypeError, match="must be a Cmd2ArgumentParser or a subclass of it"):
+ argparse_app._build_parser(argparse_app, sm)
+
+
+def test_invalid_parser_return_type_classmethod(argparse_app):
+ def bad_builder(cls):
+ return argparse.ArgumentParser()
+
+ cm = classmethod(bad_builder)
+
+ with pytest.raises(TypeError, match="must be a Cmd2ArgumentParser or a subclass of it"):
+ argparse_app._build_parser(argparse_app, cm)
+
+
+def test_invalid_parser_return_type_nameless_object(argparse_app):
+ # A class that is callable but has no __name__ attribute
+ class NamelessBuilder:
+ def __call__(self):
+ return argparse.ArgumentParser()
+
+ builder = NamelessBuilder()
+
+ # Verify __name__ is actually missing
+ assert not hasattr(builder, "__name__")
+
+ # The error message should now contain the string representation of the object
+ expected_msg = f"The parser returned by '{builder}' must be a Cmd2ArgumentParser"
+
+ with pytest.raises(TypeError, match=expected_msg):
+ argparse_app._build_parser(argparse_app, builder)
def _build_has_subcmd_parser() -> cmd2.Cmd2ArgumentParser:
has_subcmds_parser = cmd2.Cmd2ArgumentParser(description="Tests as_subcmd_to decorator")
- has_subcmds_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND', required=True)
+ has_subcmds_parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND", required=True)
return has_subcmds_parser
@@ -267,33 +313,31 @@ def base_foo(self, args) -> None:
def base_bar(self, args) -> None:
"""Bar subcommand of base command"""
- self.poutput(f'(({args.z}))')
+ self.poutput(f"(({args.z}))")
def base_helpless(self, args) -> None:
"""Helpless subcommand of base command"""
- self.poutput(f'(({args.z}))')
+ self.poutput(f"(({args.z}))")
# create the top-level parser for the base command
base_parser = cmd2.Cmd2ArgumentParser()
- base_subparsers = base_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND', required=True)
+ base_subparsers = base_parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND", required=True)
# create the parser for the "foo" subcommand
- parser_foo = base_subparsers.add_parser('foo', help='foo help')
- parser_foo.add_argument('-x', type=int, default=1, help='integer')
- parser_foo.add_argument('y', type=float, help='float')
+ parser_foo = base_subparsers.add_parser("foo", help="foo help")
+ parser_foo.add_argument("-x", type=int, default=1, help="integer")
+ parser_foo.add_argument("y", type=float, help="float")
parser_foo.set_defaults(func=base_foo)
# create the parser for the "bar" subcommand
- parser_bar = base_subparsers.add_parser('bar', help='bar help', aliases=['bar_1', 'bar_2'])
- parser_bar.add_argument('z', help='string')
+ parser_bar = base_subparsers.add_parser("bar", help="bar help", aliases=["bar_1", "bar_2"])
+ parser_bar.add_argument("z", help="string")
parser_bar.set_defaults(func=base_bar)
# create the parser for the "helpless" subcommand
- # This subcommand has aliases and no help text. It exists to prevent changes to set_parser_prog() which
- # use an approach which relies on action._choices_actions list. See comment in that function for more
- # details.
- parser_helpless = base_subparsers.add_parser('helpless', aliases=['helpless_1', 'helpless_2'])
- parser_helpless.add_argument('z', help='string')
+ # This subcommand has aliases and no help text.
+ parser_helpless = base_subparsers.add_parser("helpless", aliases=["helpless_1", "helpless_2"])
+ parser_helpless.add_argument("z", help="string")
parser_helpless.set_defaults(func=base_helpless)
@cmd2.with_argparser(base_parser)
@@ -306,12 +350,11 @@ def do_base(self, args) -> None:
# Add subcommands using as_subcommand_to decorator
@cmd2.with_argparser(_build_has_subcmd_parser)
def do_test_subcmd_decorator(self, args: argparse.Namespace) -> None:
- handler = args.cmd2_handler.get()
- handler(args)
+ args.cmd2_subcmd_handler(args)
subcmd_parser = cmd2.Cmd2ArgumentParser(description="A subcommand")
- @cmd2.as_subcommand_to('test_subcmd_decorator', 'subcmd', subcmd_parser, help=subcmd_parser.description.lower())
+ @cmd2.as_subcommand_to("test_subcmd_decorator", "subcmd", subcmd_parser, help=subcmd_parser.description.lower())
def subcmd_func(self, args: argparse.Namespace) -> None:
# Make sure printing the Namespace works. The way we originally added cmd2_handler to it resulted in a RecursionError.
self.poutput(args)
@@ -319,7 +362,7 @@ def subcmd_func(self, args: argparse.Namespace) -> None:
helpless_subcmd_parser = cmd2.Cmd2ArgumentParser(add_help=False, description="A subcommand with no help")
@cmd2.as_subcommand_to(
- 'test_subcmd_decorator', 'helpless_subcmd', helpless_subcmd_parser, help=helpless_subcmd_parser.description.lower()
+ "test_subcmd_decorator", "helpless_subcmd", helpless_subcmd_parser, help=helpless_subcmd_parser.description.lower()
)
def helpless_subcmd_func(self, args: argparse.Namespace) -> None:
# Make sure vars(Namespace) works. The way we originally added cmd2_handler to it resulted in a RecursionError.
@@ -332,109 +375,96 @@ def subcommand_app():
def test_subcommand_foo(subcommand_app) -> None:
- out, _err = run_cmd(subcommand_app, 'base foo -x2 5.0')
- assert out == ['10.0']
+ out, _err = run_cmd(subcommand_app, "base foo -x2 5.0")
+ assert out == ["10.0"]
def test_subcommand_bar(subcommand_app) -> None:
- out, _err = run_cmd(subcommand_app, 'base bar baz')
- assert out == ['((baz))']
+ out, _err = run_cmd(subcommand_app, "base bar baz")
+ assert out == ["((baz))"]
def test_subcommand_invalid(subcommand_app) -> None:
- _out, err = run_cmd(subcommand_app, 'base baz')
- assert err[0].startswith('Usage: base')
+ _out, err = run_cmd(subcommand_app, "base baz")
+ assert err[0].startswith("Usage: base")
assert err[1].startswith("Error: argument SUBCOMMAND: invalid choice: 'baz'")
def test_subcommand_base_help(subcommand_app) -> None:
- out, _err = run_cmd(subcommand_app, 'help base')
- assert out[0].startswith('Usage: base')
- assert out[1] == ''
- assert out[2] == 'Base command help'
+ out, _err = run_cmd(subcommand_app, "help base")
+ assert out[0].startswith("Usage: base")
+ assert out[1] == ""
+ assert out[2] == "Base command help"
def test_subcommand_help(subcommand_app) -> None:
# foo has no aliases
- out, _err = run_cmd(subcommand_app, 'help base foo')
- assert out[0].startswith('Usage: base foo')
- assert out[1] == ''
- assert out[2] == 'Positional Arguments:'
+ out, _err = run_cmd(subcommand_app, "help base foo")
+ assert out[0].startswith("Usage: base foo")
+ assert out[1] == ""
+ assert out[2] == "Positional Arguments:"
# bar has aliases (usage should never show alias name)
- out, _err = run_cmd(subcommand_app, 'help base bar')
- assert out[0].startswith('Usage: base bar')
- assert out[1] == ''
- assert out[2] == 'Positional Arguments:'
+ out, _err = run_cmd(subcommand_app, "help base bar")
+ assert out[0].startswith("Usage: base bar")
+ assert out[1] == ""
+ assert out[2] == "Positional Arguments:"
- out, _err = run_cmd(subcommand_app, 'help base bar_1')
- assert out[0].startswith('Usage: base bar')
- assert out[1] == ''
- assert out[2] == 'Positional Arguments:'
+ out, _err = run_cmd(subcommand_app, "help base bar_1")
+ assert out[0].startswith("Usage: base bar")
+ assert out[1] == ""
+ assert out[2] == "Positional Arguments:"
- out, _err = run_cmd(subcommand_app, 'help base bar_2')
- assert out[0].startswith('Usage: base bar')
- assert out[1] == ''
- assert out[2] == 'Positional Arguments:'
+ out, _err = run_cmd(subcommand_app, "help base bar_2")
+ assert out[0].startswith("Usage: base bar")
+ assert out[1] == ""
+ assert out[2] == "Positional Arguments:"
# helpless has aliases and no help text (usage should never show alias name)
- out, _err = run_cmd(subcommand_app, 'help base helpless')
- assert out[0].startswith('Usage: base helpless')
- assert out[1] == ''
- assert out[2] == 'Positional Arguments:'
+ out, _err = run_cmd(subcommand_app, "help base helpless")
+ assert out[0].startswith("Usage: base helpless")
+ assert out[1] == ""
+ assert out[2] == "Positional Arguments:"
- out, _err = run_cmd(subcommand_app, 'help base helpless_1')
- assert out[0].startswith('Usage: base helpless')
- assert out[1] == ''
- assert out[2] == 'Positional Arguments:'
+ out, _err = run_cmd(subcommand_app, "help base helpless_1")
+ assert out[0].startswith("Usage: base helpless")
+ assert out[1] == ""
+ assert out[2] == "Positional Arguments:"
- out, _err = run_cmd(subcommand_app, 'help base helpless_2')
- assert out[0].startswith('Usage: base helpless')
- assert out[1] == ''
- assert out[2] == 'Positional Arguments:'
+ out, _err = run_cmd(subcommand_app, "help base helpless_2")
+ assert out[0].startswith("Usage: base helpless")
+ assert out[1] == ""
+ assert out[2] == "Positional Arguments:"
def test_subcommand_invalid_help(subcommand_app) -> None:
- out, _err = run_cmd(subcommand_app, 'help base baz')
- assert out[0].startswith('Usage: base')
-
-
-def test_add_another_subcommand(subcommand_app) -> None:
- """This tests makes sure set_parser_prog() sets _prog_prefix on every _SubParsersAction so that all future calls
- to add_parser() write the correct prog value to the parser being added.
- """
- base_parser = subcommand_app._command_parsers.get(subcommand_app.do_base)
- for sub_action in base_parser._actions:
- if isinstance(sub_action, argparse._SubParsersAction):
- new_parser = sub_action.add_parser('new_sub', help='stuff')
- break
-
- assert new_parser.prog == "base new_sub"
+ out, _err = run_cmd(subcommand_app, "help base baz")
+ assert out[0].startswith("Usage: base")
def test_subcmd_decorator(subcommand_app) -> None:
# Test subcommand that has help option
- out, err = run_cmd(subcommand_app, 'test_subcmd_decorator subcmd')
- assert out[0].startswith('Namespace(')
+ out, err = run_cmd(subcommand_app, "test_subcmd_decorator subcmd")
+ assert out[0].startswith("Namespace(")
- out, err = run_cmd(subcommand_app, 'help test_subcmd_decorator subcmd')
- assert out[0] == 'Usage: test_subcmd_decorator subcmd [-h]'
+ out, err = run_cmd(subcommand_app, "help test_subcmd_decorator subcmd")
+ assert out[0] == "Usage: test_subcmd_decorator subcmd [-h]"
- out, err = run_cmd(subcommand_app, 'test_subcmd_decorator subcmd -h')
- assert out[0] == 'Usage: test_subcmd_decorator subcmd [-h]'
+ out, err = run_cmd(subcommand_app, "test_subcmd_decorator subcmd -h")
+ assert out[0] == "Usage: test_subcmd_decorator subcmd [-h]"
# Test subcommand that has no help option
- out, err = run_cmd(subcommand_app, 'test_subcmd_decorator helpless_subcmd')
+ out, err = run_cmd(subcommand_app, "test_subcmd_decorator helpless_subcmd")
assert "'subcommand': 'helpless_subcmd'" in out[1]
- out, err = run_cmd(subcommand_app, 'help test_subcmd_decorator helpless_subcmd')
- assert out[0] == 'Usage: test_subcmd_decorator helpless_subcmd'
+ out, err = run_cmd(subcommand_app, "help test_subcmd_decorator helpless_subcmd")
+ assert out[0] == "Usage: test_subcmd_decorator helpless_subcmd"
assert not err
- out, err = run_cmd(subcommand_app, 'test_subcmd_decorator helpless_subcmd -h')
+ out, err = run_cmd(subcommand_app, "test_subcmd_decorator helpless_subcmd -h")
assert not out
- assert err[0] == 'Usage: test_subcmd_decorator [-h] SUBCOMMAND ...'
- assert err[1] == 'Error: unrecognized arguments: -h'
+ assert err[0] == "Usage: test_subcmd_decorator [-h] SUBCOMMAND ..."
+ assert err[1] == "Error: unrecognized arguments: -h"
def test_unittest_mock() -> None:
@@ -446,16 +476,16 @@ def test_unittest_mock() -> None:
CommandSetRegistrationError,
)
- with mock.patch.object(ArgparseApp, 'namespace_provider'), pytest.raises(CommandSetRegistrationError):
+ with mock.patch.object(ArgparseApp, "namespace_provider"), pytest.raises(CommandSetRegistrationError):
ArgparseApp()
- with mock.patch.object(ArgparseApp, 'namespace_provider', spec=True):
+ with mock.patch.object(ArgparseApp, "namespace_provider", spec=True):
ArgparseApp()
- with mock.patch.object(ArgparseApp, 'namespace_provider', spec_set=True):
+ with mock.patch.object(ArgparseApp, "namespace_provider", spec_set=True):
ArgparseApp()
- with mock.patch.object(ArgparseApp, 'namespace_provider', autospec=True):
+ with mock.patch.object(ArgparseApp, "namespace_provider", autospec=True):
ArgparseApp()
@@ -464,19 +494,19 @@ def test_pytest_mock_invalid(mocker) -> None:
CommandSetRegistrationError,
)
- mocker.patch.object(ArgparseApp, 'namespace_provider')
+ mocker.patch.object(ArgparseApp, "namespace_provider")
with pytest.raises(CommandSetRegistrationError):
ArgparseApp()
@pytest.mark.parametrize(
- 'spec_param',
+ "spec_param",
[
- {'spec': True},
- {'spec_set': True},
- {'autospec': True},
+ {"spec": True},
+ {"spec_set": True},
+ {"autospec": True},
],
)
def test_pytest_mock_valid(mocker, spec_param) -> None:
- mocker.patch.object(ArgparseApp, 'namespace_provider', **spec_param)
+ mocker.patch.object(ArgparseApp, "namespace_provider", **spec_param)
ArgparseApp()
diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py
index a58d94fe6..b15ada148 100644
--- a/tests/test_argparse_completer.py
+++ b/tests/test_argparse_completer.py
@@ -1,7 +1,6 @@
"""Unit/functional testing for argparse completer in cmd2"""
import argparse
-import numbers
from typing import cast
import pytest
@@ -10,32 +9,32 @@
import cmd2
import cmd2.string_utils as su
from cmd2 import (
+ Choices,
Cmd2ArgumentParser,
CompletionError,
CompletionItem,
+ Completions,
argparse_completer,
- argparse_custom,
+ argparse_utils,
with_argparser,
)
from cmd2 import rich_utils as ru
from .conftest import (
- complete_tester,
- normalize,
run_cmd,
with_ansi_style,
)
# Data and functions for testing standalone choice_provider and completer
-standalone_choices = ['standalone', 'provider']
-standalone_completions = ['standalone', 'completer']
+standalone_choices = ["standalone", "provider"]
+standalone_completions = ["standalone", "completer"]
-def standalone_choice_provider(cli: cmd2.Cmd) -> list[str]:
- return standalone_choices
+def standalone_choice_provider(cli: cmd2.Cmd) -> Choices:
+ return Choices.from_values(standalone_choices)
-def standalone_completer(cli: cmd2.Cmd, text: str, line: str, begidx: int, endidx: int) -> list[str]:
+def standalone_completer(cli: cmd2.Cmd, text: str, line: str, begidx: int, endidx: int) -> Completions:
return cli.basic_complete(text, line, begidx, endidx, standalone_completions)
@@ -49,16 +48,16 @@ def __init__(self, *args, **kwargs) -> None:
# Begin code related to help and command name completion
############################################################################################################
# Top level parser for music command
- music_parser = Cmd2ArgumentParser(description='Manage music')
+ music_parser = Cmd2ArgumentParser(description="Manage music")
# Add subcommands to music
music_subparsers = music_parser.add_subparsers()
- music_create_parser = music_subparsers.add_parser('create', help='create music')
+ music_create_parser = music_subparsers.add_parser("create", help="create music")
# Add subcommands to music -> create
music_create_subparsers = music_create_parser.add_subparsers()
- music_create_jazz_parser = music_create_subparsers.add_parser('jazz', help='create jazz')
- music_create_rock_parser = music_create_subparsers.add_parser('rock', help='create rocks')
+ music_create_jazz_parser = music_create_subparsers.add_parser("jazz", help="create jazz")
+ music_create_rock_parser = music_create_subparsers.add_parser("rock", help="create rock")
@with_argparser(music_parser)
def do_music(self, args: argparse.Namespace) -> None:
@@ -70,22 +69,23 @@ def do_music(self, args: argparse.Namespace) -> None:
# Uses default flag prefix value (-)
flag_parser = Cmd2ArgumentParser()
- flag_parser.add_argument('-n', '--normal_flag', help='a normal flag', action='store_true')
- flag_parser.add_argument('-a', '--append_flag', help='append flag', action='append')
- flag_parser.add_argument('-o', '--append_const_flag', help='append const flag', action='append_const', const=True)
- flag_parser.add_argument('-c', '--count_flag', help='count flag', action='count')
- flag_parser.add_argument('-s', '--suppressed_flag', help=argparse.SUPPRESS, action='store_true')
- flag_parser.add_argument('-r', '--remainder_flag', nargs=argparse.REMAINDER, help='a remainder flag')
- flag_parser.add_argument('-q', '--required_flag', required=True, help='a required flag', action='store_true')
+ flag_parser.add_argument("-n", "--normal_flag", help="a normal flag", action="store_true")
+ flag_parser.add_argument("-a", "--append_flag", help="append flag", action="append")
+ flag_parser.add_argument("-o", "--append_const_flag", help="append const flag", action="append_const", const=True)
+ flag_parser.add_argument("-c", "--count_flag", help="count flag", action="count")
+ flag_parser.add_argument("-e", "--extend_flag", help="extend flag", action="extend")
+ flag_parser.add_argument("-s", "--suppressed_flag", help=argparse.SUPPRESS, action="store_true")
+ flag_parser.add_argument("-r", "--remainder_flag", nargs=argparse.REMAINDER, help="a remainder flag")
+ flag_parser.add_argument("-q", "--required_flag", required=True, help="a required flag", action="store_true")
@with_argparser(flag_parser)
def do_flag(self, args: argparse.Namespace) -> None:
pass
# Uses non-default flag prefix value (+)
- plus_flag_parser = Cmd2ArgumentParser(prefix_chars='+')
- plus_flag_parser.add_argument('+n', '++normal_flag', help='a normal flag', action='store_true')
- plus_flag_parser.add_argument('+q', '++required_flag', required=True, help='a required flag', action='store_true')
+ plus_flag_parser = Cmd2ArgumentParser(prefix_chars="+")
+ plus_flag_parser.add_argument("+n", "++normal_flag", help="a normal flag", action="store_true")
+ plus_flag_parser.add_argument("+q", "++required_flag", required=True, help="a required flag", action="store_true")
@with_argparser(plus_flag_parser)
def do_plus_flag(self, args: argparse.Namespace) -> None:
@@ -94,7 +94,7 @@ def do_plus_flag(self, args: argparse.Namespace) -> None:
# A parser with a positional and flags. Used to test that remaining flag names are completed when all positionals are done.
pos_and_flag_parser = Cmd2ArgumentParser()
pos_and_flag_parser.add_argument("positional", choices=["a", "choice"])
- pos_and_flag_parser.add_argument("-f", "--flag", action='store_true')
+ pos_and_flag_parser.add_argument("-f", "--flag", action="store_true")
@with_argparser(pos_and_flag_parser)
def do_pos_and_flag(self, args: argparse.Namespace) -> None:
@@ -104,82 +104,118 @@ def do_pos_and_flag(self, args: argparse.Namespace) -> None:
# Begin code related to testing choices and choices_provider parameters
############################################################################################################
STR_METAVAR = "HEADLESS"
- TUPLE_METAVAR = ('arg1', 'others')
- CUSTOM_DESC_HEADERS = ("Custom Headers",)
+ TUPLE_METAVAR = ("arg1", "others")
+ DESCRIPTION_TABLE_COLUMNS = ("Description",)
# tuples (for sake of immutability) used in our tests (there is a mix of sorted and unsorted on purpose)
non_negative_num_choices = (1, 2, 3, 0.5, 22)
num_choices = (-1, 1, -2, 2.5, 0, -12)
- static_choices_list = ('static', 'choices', 'stop', 'here')
- choices_from_provider = ('choices', 'provider', 'probably', 'improved')
+ static_choices_list = ("static", "choices", "stop", "here")
+ choices_from_provider = ("choices", "provider", "probably", "improved")
completion_item_choices = (
- CompletionItem('choice_1', ['Description 1']),
- # Make this the longest description so we can test display width.
- CompletionItem('choice_2', [su.stylize("String with style", style=cmd2.Color.BLUE)]),
- CompletionItem('choice_3', [Text("Text with style", style=cmd2.Color.RED)]),
+ CompletionItem("choice_1", table_data=["Description 1"]),
+ CompletionItem("choice_2", table_data=[su.stylize("String with style", style=cmd2.Color.BLUE)]),
+ CompletionItem("choice_3", table_data=[Text("Text with style", style=cmd2.Color.RED)]),
)
# This tests that CompletionItems created with numerical values are sorted as numbers.
num_completion_items = (
- CompletionItem(5, ["Five"]),
- CompletionItem(1.5, ["One.Five"]),
- CompletionItem(2, ["Five"]),
+ CompletionItem(5, table_data=["Five"]),
+ CompletionItem(1.5, table_data=["One.Five"]),
+ CompletionItem(2, table_data=["Two"]),
)
- def choices_provider(self) -> tuple[str]:
+ def choices_provider(self) -> Choices:
"""Method that provides choices"""
- return self.choices_from_provider
+ return Choices.from_values(self.choices_from_provider)
def completion_item_method(self) -> list[CompletionItem]:
"""Choices method that returns CompletionItems"""
items = []
for i in range(10):
- main_str = f'main_str{i}'
- items.append(CompletionItem(main_str, ['blah blah']))
+ main_str = f"main_str{i}"
+ items.append(CompletionItem(main_str, table_data=["blah blah"]))
return items
choices_parser = Cmd2ArgumentParser()
# Flag args for choices command. Include string and non-string arg types.
- choices_parser.add_argument("-l", "--list", help="a flag populated with a choices list", choices=static_choices_list)
choices_parser.add_argument(
- "-p", "--provider", help="a flag populated with a choices provider", choices_provider=choices_provider
+ "-l",
+ "--list",
+ help="a flag populated with a choices list",
+ choices=static_choices_list,
)
choices_parser.add_argument(
- "--desc_header",
- help='this arg has a descriptive header',
+ "-p",
+ "--provider",
+ help="a flag populated with a choices provider",
+ choices_provider=choices_provider,
+ )
+ choices_parser.add_argument(
+ "--no_metavar",
+ help="this arg has no metavar",
choices_provider=completion_item_method,
- descriptive_headers=CUSTOM_DESC_HEADERS,
+ table_columns=DESCRIPTION_TABLE_COLUMNS,
)
choices_parser.add_argument(
- "--no_header",
- help='this arg has no descriptive header',
+ "--str_metavar",
+ help="this arg has str for a metavar",
choices_provider=completion_item_method,
metavar=STR_METAVAR,
+ table_columns=DESCRIPTION_TABLE_COLUMNS,
)
choices_parser.add_argument(
- '-t',
+ "-t",
"--tuple_metavar",
- help='this arg has tuple for a metavar',
- choices_provider=completion_item_method,
+ help="this arg has tuple for a metavar",
metavar=TUPLE_METAVAR,
nargs=argparse.ONE_OR_MORE,
+ choices_provider=completion_item_method,
+ table_columns=DESCRIPTION_TABLE_COLUMNS,
+ )
+ choices_parser.add_argument(
+ "-n",
+ "--num",
+ type=int,
+ help="a flag with an int type",
+ choices=num_choices,
+ )
+ choices_parser.add_argument(
+ "--completion_items",
+ help="choices are CompletionItems",
+ choices=completion_item_choices,
+ table_columns=DESCRIPTION_TABLE_COLUMNS,
)
- choices_parser.add_argument('-n', '--num', type=int, help='a flag with an int type', choices=num_choices)
- choices_parser.add_argument('--completion_items', help='choices are CompletionItems', choices=completion_item_choices)
choices_parser.add_argument(
- '--num_completion_items', help='choices are numerical CompletionItems', choices=num_completion_items
+ "--num_completion_items",
+ help="choices are numerical CompletionItems",
+ choices=num_completion_items,
+ table_columns=DESCRIPTION_TABLE_COLUMNS,
)
# Positional args for choices command
- choices_parser.add_argument("list_pos", help="a positional populated with a choices list", choices=static_choices_list)
choices_parser.add_argument(
- "method_pos", help="a positional populated with a choices provider", choices_provider=choices_provider
+ "list_pos",
+ help="a positional populated with a choices list",
+ choices=static_choices_list,
)
choices_parser.add_argument(
- 'non_negative_num', type=int, help='a positional with non-negative numerical choices', choices=non_negative_num_choices
+ "method_pos",
+ help="a positional populated with a choices provider",
+ choices_provider=choices_provider,
+ )
+ choices_parser.add_argument(
+ "non_negative_num",
+ type=int,
+ help="a positional with non-negative numerical choices",
+ choices=non_negative_num_choices,
+ )
+ choices_parser.add_argument(
+ "empty_choices",
+ help="a positional with empty choices",
+ choices=[],
)
- choices_parser.add_argument('empty_choices', help='a positional with empty choices', choices=[])
@with_argparser(choices_parser)
def do_choices(self, args: argparse.Namespace) -> None:
@@ -188,17 +224,17 @@ def do_choices(self, args: argparse.Namespace) -> None:
############################################################################################################
# Begin code related to testing completer parameter
############################################################################################################
- completions_for_flag = ('completions', 'flag', 'fairly', 'complete')
- completions_for_pos_1 = ('completions', 'positional_1', 'probably', 'missed', 'spot')
- completions_for_pos_2 = ('completions', 'positional_2', 'probably', 'missed', 'me')
+ completions_for_flag = ("completions", "flag", "fairly", "complete")
+ completions_for_pos_1 = ("completions", "positional_1", "probably", "missed", "spot")
+ completions_for_pos_2 = ("completions", "positional_2", "probably", "missed", "me")
- def flag_completer(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
+ def flag_completer(self, text: str, line: str, begidx: int, endidx: int) -> Completions:
return self.basic_complete(text, line, begidx, endidx, self.completions_for_flag)
- def pos_1_completer(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
+ def pos_1_completer(self, text: str, line: str, begidx: int, endidx: int) -> Completions:
return self.basic_complete(text, line, begidx, endidx, self.completions_for_pos_1)
- def pos_2_completer(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
+ def pos_2_completer(self, text: str, line: str, begidx: int, endidx: int) -> Completions:
return self.basic_complete(text, line, begidx, endidx, self.completions_for_pos_2)
completer_parser = Cmd2ArgumentParser()
@@ -217,12 +253,12 @@ def do_completer(self, args: argparse.Namespace) -> None:
############################################################################################################
# Begin code related to nargs
############################################################################################################
- set_value_choices = ('set', 'value', 'choices')
- one_or_more_choices = ('one', 'or', 'more', 'choices')
- optional_choices = ('a', 'few', 'optional', 'choices')
- range_choices = ('some', 'range', 'choices')
- remainder_choices = ('remainder', 'choices')
- positional_choices = ('the', 'positional', 'choices')
+ set_value_choices = ("set", "value", "choices")
+ one_or_more_choices = ("one", "or", "more", "choices")
+ optional_choices = ("a", "few", "optional", "choices")
+ range_choices = ("some", "range", "choices")
+ remainder_choices = ("remainder", "choices")
+ positional_choices = ("the", "positional", "choices")
nargs_parser = Cmd2ArgumentParser()
@@ -252,12 +288,12 @@ def do_nargs(self, args: argparse.Namespace) -> None:
# Begin code related to testing tab hints
############################################################################################################
hint_parser = Cmd2ArgumentParser()
- hint_parser.add_argument('-f', '--flag', help='a flag arg')
- hint_parser.add_argument('-s', '--suppressed_help', help=argparse.SUPPRESS)
- hint_parser.add_argument('-t', '--suppressed_hint', help='a flag arg', suppress_tab_hint=True)
+ hint_parser.add_argument("-f", "--flag", help="a flag arg")
+ hint_parser.add_argument("-s", "--suppressed_help", help=argparse.SUPPRESS)
+ hint_parser.add_argument("-t", "--suppressed_hint", help="a flag arg", suppress_tab_hint=True)
- hint_parser.add_argument('hint_pos', help='here is a hint\nwith new lines')
- hint_parser.add_argument('no_help_pos')
+ hint_parser.add_argument("hint_pos", help="here is a hint\nwith new lines")
+ hint_parser.add_argument("no_help_pos")
@with_argparser(hint_parser)
def do_hint(self, args: argparse.Namespace) -> None:
@@ -268,15 +304,15 @@ def do_hint(self, args: argparse.Namespace) -> None:
############################################################################################################
def completer_raise_error(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
"""Raises CompletionError"""
- raise CompletionError('completer broke something')
+ raise CompletionError("completer broke something")
- def choice_raise_error(self) -> list[str]:
+ def choice_raise_completion_error(self) -> list[str]:
"""Raises CompletionError"""
- raise CompletionError('choice broke something')
+ raise CompletionError("choice broke something")
comp_error_parser = Cmd2ArgumentParser()
- comp_error_parser.add_argument('completer_pos', help='positional arg', completer=completer_raise_error)
- comp_error_parser.add_argument('--choice', help='flag arg', choices_provider=choice_raise_error)
+ comp_error_parser.add_argument("completer_pos", help="positional arg", completer=completer_raise_error)
+ comp_error_parser.add_argument("--choice", help="flag arg", choices_provider=choice_raise_completion_error)
@with_argparser(comp_error_parser)
def do_raise_completion_error(self, args: argparse.Namespace) -> None:
@@ -285,29 +321,29 @@ def do_raise_completion_error(self, args: argparse.Namespace) -> None:
############################################################################################################
# Begin code related to receiving arg_tokens
############################################################################################################
- def choices_takes_arg_tokens(self, arg_tokens: dict[str, list[str]]) -> list[str]:
+ def choices_takes_arg_tokens(self, arg_tokens: dict[str, list[str]]) -> Choices:
"""Choices function that receives arg_tokens from ArgparseCompleter"""
- return [arg_tokens['parent_arg'][0], arg_tokens['subcommand'][0]]
+ return Choices.from_values([arg_tokens["parent_arg"][0], arg_tokens["subcommand"][0]])
def completer_takes_arg_tokens(
self, text: str, line: str, begidx: int, endidx: int, arg_tokens: dict[str, list[str]]
- ) -> list[str]:
+ ) -> Completions:
"""Completer function that receives arg_tokens from ArgparseCompleter"""
- match_against = [arg_tokens['parent_arg'][0], arg_tokens['subcommand'][0]]
+ match_against = [arg_tokens["parent_arg"][0], arg_tokens["subcommand"][0]]
return self.basic_complete(text, line, begidx, endidx, match_against)
arg_tokens_parser = Cmd2ArgumentParser()
- arg_tokens_parser.add_argument('parent_arg', help='arg from a parent parser')
+ arg_tokens_parser.add_argument("parent_arg", help="arg from a parent parser")
- # Create a subcommand for to exercise receiving parent_tokens and subcommand name in arg_tokens
- arg_tokens_subparser = arg_tokens_parser.add_subparsers(dest='subcommand')
- arg_tokens_subcmd_parser = arg_tokens_subparser.add_parser('subcmd')
+ # Create a subcommand to exercise receiving parent_tokens and subcommand name in arg_tokens
+ arg_tokens_subparser = arg_tokens_parser.add_subparsers(dest="subcommand")
+ arg_tokens_subcmd_parser = arg_tokens_subparser.add_parser("subcmd")
- arg_tokens_subcmd_parser.add_argument('choices_pos', choices_provider=choices_takes_arg_tokens)
- arg_tokens_subcmd_parser.add_argument('completer_pos', completer=completer_takes_arg_tokens)
+ arg_tokens_subcmd_parser.add_argument("choices_pos", choices_provider=choices_takes_arg_tokens)
+ arg_tokens_subcmd_parser.add_argument("completer_pos", completer=completer_takes_arg_tokens)
# Used to override parent_arg in arg_tokens_parser
- arg_tokens_subcmd_parser.add_argument('--parent_arg')
+ arg_tokens_subcmd_parser.add_argument("--parent_arg")
@with_argparser(arg_tokens_parser)
def do_arg_tokens(self, args: argparse.Namespace) -> None:
@@ -319,11 +355,11 @@ def do_arg_tokens(self, args: argparse.Namespace) -> None:
mutex_parser = Cmd2ArgumentParser()
mutex_group = mutex_parser.add_mutually_exclusive_group(required=True)
- mutex_group.add_argument('optional_pos', help='the optional positional', nargs=argparse.OPTIONAL)
- mutex_group.add_argument('-f', '--flag', help='the flag arg')
- mutex_group.add_argument('-o', '--other_flag', help='the other flag arg')
+ mutex_group.add_argument("optional_pos", help="the optional positional", nargs=argparse.OPTIONAL)
+ mutex_group.add_argument("-f", "--flag", help="the flag arg")
+ mutex_group.add_argument("-o", "--other_flag", help="the other flag arg")
- mutex_parser.add_argument('last_arg', help='the last arg')
+ mutex_parser.add_argument("last_arg", help="the last arg")
@with_argparser(mutex_parser)
def do_mutex(self, args: argparse.Namespace) -> None:
@@ -333,354 +369,291 @@ def do_mutex(self, args: argparse.Namespace) -> None:
# Begin code related to standalone functions
############################################################################################################
standalone_parser = Cmd2ArgumentParser()
- standalone_parser.add_argument('--provider', help='standalone provider', choices_provider=standalone_choice_provider)
- standalone_parser.add_argument('--completer', help='standalone completer', completer=standalone_completer)
+ standalone_parser.add_argument("--provider", help="standalone provider", choices_provider=standalone_choice_provider)
+ standalone_parser.add_argument("--completer", help="standalone completer", completer=standalone_completer)
@with_argparser(standalone_parser)
def do_standalone(self, args: argparse.Namespace) -> None:
pass
+ ############################################################################################################
+ # Begin code related to display_meta data
+ ############################################################################################################
+ meta_parser = Cmd2ArgumentParser()
+
+ # Add subcommands to meta
+ meta_subparsers = meta_parser.add_subparsers()
+
+ # Create subcommands with and without help text
+ meta_helpful_parser = meta_subparsers.add_parser("helpful", help="my helpful text")
+ meta_helpless_parser = meta_subparsers.add_parser("helpless")
+
+ # Create flags with and without help text
+ meta_helpful_parser.add_argument("--helpful_flag", help="a helpful flag")
+ meta_helpless_parser.add_argument("--helpless_flag")
+
+ @with_argparser(meta_parser)
+ def do_meta(self, args: argparse.Namespace) -> None:
+ pass
+
@pytest.fixture
-def ac_app():
+def ac_app() -> ArgparseCompleterTester:
return ArgparseCompleterTester()
-@pytest.mark.parametrize('command', ['music', 'music create', 'music create rock', 'music create jazz'])
+@pytest.mark.parametrize("command", ["music", "music create", "music create rock", "music create jazz"])
def test_help(ac_app, command) -> None:
- out1, _err1 = run_cmd(ac_app, f'{command} -h')
- out2, _err2 = run_cmd(ac_app, f'help {command}')
+ out1, _err1 = run_cmd(ac_app, f"{command} -h")
+ out2, _err2 = run_cmd(ac_app, f"help {command}")
assert out1 == out2
def test_bad_subcommand_help(ac_app) -> None:
# These should give the same output because the second one isn't using a
# real subcommand, so help will be called on the music command instead.
- out1, _err1 = run_cmd(ac_app, 'help music')
- out2, _err2 = run_cmd(ac_app, 'help music fake')
+ out1, _err1 = run_cmd(ac_app, "help music")
+ out2, _err2 = run_cmd(ac_app, "help music fake")
assert out1 == out2
@pytest.mark.parametrize(
- ('command', 'text', 'completions'),
+ ("command", "text", "expected"),
[
- ('', 'mus', ['music ']),
- ('music', 'cre', ['create ']),
- ('music', 'creab', []),
- ('music create', '', ['jazz', 'rock']),
- ('music crea', 'jazz', []),
- ('music create', 'foo', []),
- ('fake create', '', []),
- ('music fake', '', []),
+ ("", "mus", ["music"]),
+ ("music", "cre", ["create"]),
+ ("music", "creab", []),
+ ("music create", "", ["jazz", "rock"]),
+ ("music crea", "jazz", []),
+ ("music create", "foo", []),
+ ("fake create", "", []),
+ ("music fake", "", []),
],
)
-def test_complete_help(ac_app, command, text, completions) -> None:
- line = f'help {command} {text}'
+def test_complete_help(ac_app, command, text, expected) -> None:
+ line = f"help {command} {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, ac_app)
- if completions:
- assert first_match is not None
- else:
- assert first_match is None
-
- assert ac_app.completion_matches == sorted(completions, key=ac_app.default_sort_key)
+ completions = ac_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
@pytest.mark.parametrize(
- ('subcommand', 'text', 'completions'),
- [('create', '', ['jazz', 'rock']), ('create', 'ja', ['jazz ']), ('create', 'foo', []), ('creab', 'ja', [])],
+ ("subcommand", "text", "expected"),
+ [
+ ("create", "", ["jazz", "rock"]),
+ ("create", "ja", ["jazz"]),
+ ("create", "foo", []),
+ ("creab", "ja", []),
+ ],
)
-def test_subcommand_completions(ac_app, subcommand, text, completions) -> None:
- line = f'music {subcommand} {text}'
+def test_subcommand_completions(ac_app, subcommand, text, expected) -> None:
+ line = f"music {subcommand} {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, ac_app)
- if completions:
- assert first_match is not None
- else:
- assert first_match is None
-
- assert ac_app.completion_matches == sorted(completions, key=ac_app.default_sort_key)
+ completions = ac_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
@pytest.mark.parametrize(
- ('command_and_args', 'text', 'completion_matches', 'display_matches'),
+ # expected_data is a list of tuples with completion text and display values
+ ("command_and_args", "text", "expected_data"),
[
# Complete all flags (suppressed will not show)
(
- 'flag',
- '-',
- [
- '--append_const_flag',
- '--append_flag',
- '--count_flag',
- '--help',
- '--normal_flag',
- '--remainder_flag',
- '--required_flag',
- '-a',
- '-c',
- '-h',
- '-n',
- '-o',
- '-q',
- '-r',
- ],
+ "flag",
+ "-",
[
- '-q, --required_flag',
- '[-o, --append_const_flag]',
- '[-a, --append_flag]',
- '[-c, --count_flag]',
- '[-h, --help]',
- '[-n, --normal_flag]',
- '[-r, --remainder_flag]',
+ ("-a", "[-a, --append_flag]"),
+ ("-c", "[-c, --count_flag]"),
+ ("-e", "[-e, --extend_flag]"),
+ ("-h", "[-h, --help]"),
+ ("-n", "[-n, --normal_flag]"),
+ ("-o", "[-o, --append_const_flag]"),
+ ("-q", "-q, --required_flag"),
+ ("-r", "[-r, --remainder_flag]"),
],
),
(
- 'flag',
- '--',
+ "flag",
+ "--",
[
- '--append_const_flag',
- '--append_flag',
- '--count_flag',
- '--help',
- '--normal_flag',
- '--remainder_flag',
- '--required_flag',
- ],
- [
- '--required_flag',
- '[--append_const_flag]',
- '[--append_flag]',
- '[--count_flag]',
- '[--help]',
- '[--normal_flag]',
- '[--remainder_flag]',
+ ("--append_const_flag", "[--append_const_flag]"),
+ ("--append_flag", "[--append_flag]"),
+ ("--count_flag", "[--count_flag]"),
+ ("--extend_flag", "[--extend_flag]"),
+ ("--help", "[--help]"),
+ ("--normal_flag", "[--normal_flag]"),
+ ("--remainder_flag", "[--remainder_flag]"),
+ ("--required_flag", "--required_flag"),
],
),
# Complete individual flag
- ('flag', '-n', ['-n '], ['[-n]']),
- ('flag', '--n', ['--normal_flag '], ['[--normal_flag]']),
+ ("flag", "-n", [("-n", "[-n]")]),
+ ("flag", "--n", [("--normal_flag", "[--normal_flag]")]),
# No flags should complete until current flag has its args
- ('flag --append_flag', '-', [], []),
+ ("flag --append_flag", "-", []),
# Complete REMAINDER flag name
- ('flag', '-r', ['-r '], ['[-r]']),
- ('flag', '--rem', ['--remainder_flag '], ['[--remainder_flag]']),
+ ("flag", "-r", [("-r", "[-r]")]),
+ ("flag", "--rem", [("--remainder_flag", "[--remainder_flag]")]),
# No flags after a REMAINDER should complete
- ('flag -r value', '-', [], []),
- ('flag --remainder_flag value', '--', [], []),
+ ("flag -r value", "-", []),
+ ("flag --remainder_flag value", "--", []),
# Suppressed flag should not complete
- ('flag', '-s', [], []),
- ('flag', '--s', [], []),
+ ("flag", "-s", []),
+ ("flag", "--s", []),
# A used flag should not show in completions
(
- 'flag -n',
- '--',
- ['--append_const_flag', '--append_flag', '--count_flag', '--help', '--remainder_flag', '--required_flag'],
+ "flag -n",
+ "--",
[
- '--required_flag',
- '[--append_const_flag]',
- '[--append_flag]',
- '[--count_flag]',
- '[--help]',
- '[--remainder_flag]',
+ ("--append_const_flag", "[--append_const_flag]"),
+ ("--append_flag", "[--append_flag]"),
+ ("--count_flag", "[--count_flag]"),
+ ("--extend_flag", "[--extend_flag]"),
+ ("--help", "[--help]"),
+ ("--remainder_flag", "[--remainder_flag]"),
+ ("--required_flag", "--required_flag"),
],
),
- # Flags with actions set to append, append_const, and count will always show even if they've been used
+ # Flags with actions set to append, append_const, extend, and count will always show even if they've been used
(
- 'flag --append_const_flag -c --append_flag value',
- '--',
- [
- '--append_const_flag',
- '--append_flag',
- '--count_flag',
- '--help',
- '--normal_flag',
- '--remainder_flag',
- '--required_flag',
- ],
+ "flag --append_flag value --append_const_flag --count_flag --extend_flag value",
+ "--",
[
- '--required_flag',
- '[--append_const_flag]',
- '[--append_flag]',
- '[--count_flag]',
- '[--help]',
- '[--normal_flag]',
- '[--remainder_flag]',
+ ("--append_const_flag", "[--append_const_flag]"),
+ ("--append_flag", "[--append_flag]"),
+ ("--count_flag", "[--count_flag]"),
+ ("--extend_flag", "[--extend_flag]"),
+ ("--help", "[--help]"),
+ ("--normal_flag", "[--normal_flag]"),
+ ("--remainder_flag", "[--remainder_flag]"),
+ ("--required_flag", "--required_flag"),
],
),
# Non-default flag prefix character (+)
(
- 'plus_flag',
- '+',
- ['++help', '++normal_flag', '+h', '+n', '+q', '++required_flag'],
- ['+q, ++required_flag', '[+h, ++help]', '[+n, ++normal_flag]'],
+ "plus_flag",
+ "+",
+ [
+ ("+h", "[+h, ++help]"),
+ ("+n", "[+n, ++normal_flag]"),
+ ("+q", "+q, ++required_flag"),
+ ],
),
(
- 'plus_flag',
- '++',
- ['++help', '++normal_flag', '++required_flag'],
- ['++required_flag', '[++help]', '[++normal_flag]'],
+ "plus_flag",
+ "++",
+ [
+ ("++help", "[++help]"),
+ ("++normal_flag", "[++normal_flag]"),
+ ("++required_flag", "++required_flag"),
+ ],
),
# Flag completion should not occur after '--' since that tells argparse all remaining arguments are non-flags
- ('flag --', '--', [], []),
- ('flag --help --', '--', [], []),
- ('plus_flag --', '++', [], []),
- ('plus_flag ++help --', '++', [], []),
+ ("flag --", "--", []),
+ ("flag --help --", "--", []),
+ ("plus_flag --", "++", []),
+ ("plus_flag ++help --", "++", []),
# Test remaining flag names complete after all positionals are complete
- ('pos_and_flag', '', ['a', 'choice'], ['a', 'choice']),
- ('pos_and_flag choice ', '', ['--flag', '--help', '-f', '-h'], ['[-f, --flag]', '[-h, --help]']),
- ('pos_and_flag choice -f ', '', ['--help', '-h'], ['[-h, --help]']),
- ('pos_and_flag choice -f -h ', '', [], []),
+ ("pos_and_flag", "", [("a", "a"), ("choice", "choice")]),
+ ("pos_and_flag choice ", "", [("-f", "[-f, --flag]"), ("-h", "[-h, --help]")]),
+ ("pos_and_flag choice -f ", "", [("-h", "[-h, --help]")]),
+ ("pos_and_flag choice -f -h ", "", []),
],
)
-def test_autcomp_flag_completion(ac_app, command_and_args, text, completion_matches, display_matches) -> None:
- line = f'{command_and_args} {text}'
+def test_autcomp_flag_completion(ac_app, command_and_args, text, expected_data) -> None:
+ line = f"{command_and_args} {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, ac_app)
- if completion_matches:
- assert first_match is not None
- else:
- assert first_match is None
+ expected_completions = Completions(items=[CompletionItem(value=v, display=d) for v, d in expected_data])
+ completions = ac_app.complete(text, line, begidx, endidx)
- assert ac_app.completion_matches == sorted(completion_matches, key=ac_app.default_sort_key)
- assert ac_app.display_matches == sorted(display_matches, key=ac_app.default_sort_key)
+ assert completions.to_strings() == expected_completions.to_strings()
+ assert [item.display for item in completions] == [item.display for item in expected_completions]
@pytest.mark.parametrize(
- ('flag', 'text', 'completions'),
+ ("flag", "text", "expected"),
[
- ('-l', '', ArgparseCompleterTester.static_choices_list),
- ('--list', 's', ['static', 'stop']),
- ('-p', '', ArgparseCompleterTester.choices_from_provider),
- ('--provider', 'pr', ['provider', 'probably']),
- ('-n', '', ArgparseCompleterTester.num_choices),
- ('--num', '1', ['1 ']),
- ('--num', '-', [-1, -2, -12]),
- ('--num', '-1', [-1, -12]),
- ('--num_completion_items', '', ArgparseCompleterTester.num_completion_items),
+ ("-l", "", ArgparseCompleterTester.static_choices_list),
+ ("--list", "s", ["static", "stop"]),
+ ("-p", "", ArgparseCompleterTester.choices_from_provider),
+ ("--provider", "pr", ["provider", "probably"]),
+ ("-n", "", ArgparseCompleterTester.num_choices),
+ ("--num", "1", ["1"]),
+ ("--num", "-", [-1, -2, -12]),
+ ("--num", "-1", [-1, -12]),
+ ("--num_completion_items", "", ArgparseCompleterTester.num_completion_items),
],
)
-def test_autocomp_flag_choices_completion(ac_app, flag, text, completions) -> None:
- line = f'choices {flag} {text}'
+def test_autocomp_flag_choices_completion(ac_app, flag, text, expected) -> None:
+ line = f"choices {flag} {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, ac_app)
- if completions:
- assert first_match is not None
- else:
- assert first_match is None
-
- # Numbers will be sorted in ascending order and then converted to strings by ArgparseCompleter
- if completions and all(isinstance(x, numbers.Number) for x in completions):
- completions = [str(x) for x in sorted(completions)]
- else:
- completions = sorted(completions, key=ac_app.default_sort_key)
-
- assert ac_app.completion_matches == completions
+ completions = ac_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
@pytest.mark.parametrize(
- ('pos', 'text', 'completions'),
+ ("pos", "text", "expected"),
[
- (1, '', ArgparseCompleterTester.static_choices_list),
- (1, 's', ['static', 'stop']),
- (2, '', ArgparseCompleterTester.choices_from_provider),
- (2, 'pr', ['provider', 'probably']),
- (3, '', ArgparseCompleterTester.non_negative_num_choices),
- (3, '2', [2, 22]),
- (4, '', []),
+ (1, "", ArgparseCompleterTester.static_choices_list),
+ (1, "s", ["static", "stop"]),
+ (2, "", ArgparseCompleterTester.choices_from_provider),
+ (2, "pr", ["provider", "probably"]),
+ (3, "", ArgparseCompleterTester.non_negative_num_choices),
+ (3, "2", [2, 22]),
+ (4, "", []),
],
)
-def test_autocomp_positional_choices_completion(ac_app, pos, text, completions) -> None:
- # Generate line were preceding positionals are already filled
- line = 'choices {} {}'.format('foo ' * (pos - 1), text)
- endidx = len(line)
- begidx = endidx - len(text)
-
- first_match = complete_tester(text, line, begidx, endidx, ac_app)
- if completions:
- assert first_match is not None
- else:
- assert first_match is None
-
- # Numbers will be sorted in ascending order and then converted to strings by ArgparseCompleter
- if completions and all(isinstance(x, numbers.Number) for x in completions):
- completions = [str(x) for x in sorted(completions)]
- else:
- completions = sorted(completions, key=ac_app.default_sort_key)
-
- assert ac_app.completion_matches == completions
-
-
-def test_flag_sorting(ac_app) -> None:
- # This test exercises the case where a positional arg has non-negative integers for its choices.
- # ArgparseCompleter will sort these numerically before converting them to strings. As a result,
- # cmd2.matches_sorted gets set to True. If no completion matches are returned and the entered
- # text looks like the beginning of a flag (e.g -), then ArgparseCompleter will try to complete
- # flag names next. Before it does this, cmd2.matches_sorted is reset to make sure the flag names
- # get sorted correctly.
- option_strings = []
- for action in ac_app.choices_parser._actions:
- option_strings.extend(action.option_strings)
- option_strings.sort(key=ac_app.default_sort_key)
-
- text = '-'
- line = f'choices arg1 arg2 arg3 {text}'
+def test_autocomp_positional_choices_completion(ac_app, pos, text, expected) -> None:
+ # Generate line where preceding positionals are already filled
+ line = "choices {} {}".format("foo " * (pos - 1), text)
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, ac_app)
- assert first_match is not None
- assert ac_app.completion_matches == option_strings
+ completions = ac_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
@pytest.mark.parametrize(
- ('flag', 'text', 'completions'),
- [('-c', '', ArgparseCompleterTester.completions_for_flag), ('--completer', 'f', ['flag', 'fairly'])],
+ ("flag", "text", "expected"),
+ [
+ ("-c", "", ArgparseCompleterTester.completions_for_flag),
+ ("--completer", "f", ["flag", "fairly"]),
+ ],
)
-def test_autocomp_flag_completers(ac_app, flag, text, completions) -> None:
- line = f'completer {flag} {text}'
+def test_autocomp_flag_completers(ac_app, flag, text, expected) -> None:
+ line = f"completer {flag} {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, ac_app)
- if completions:
- assert first_match is not None
- else:
- assert first_match is None
-
- assert ac_app.completion_matches == sorted(completions, key=ac_app.default_sort_key)
+ completions = ac_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
@pytest.mark.parametrize(
- ('pos', 'text', 'completions'),
+ ("pos", "text", "expected"),
[
- (1, '', ArgparseCompleterTester.completions_for_pos_1),
- (1, 'p', ['positional_1', 'probably']),
- (2, '', ArgparseCompleterTester.completions_for_pos_2),
- (2, 'm', ['missed', 'me']),
+ (1, "", ArgparseCompleterTester.completions_for_pos_1),
+ (1, "p", ["positional_1", "probably"]),
+ (2, "", ArgparseCompleterTester.completions_for_pos_2),
+ (2, "m", ["missed", "me"]),
],
)
-def test_autocomp_positional_completers(ac_app, pos, text, completions) -> None:
+def test_autocomp_positional_completers(ac_app, pos, text, expected) -> None:
# Generate line were preceding positionals are already filled
- line = 'completer {} {}'.format('foo ' * (pos - 1), text)
+ line = "completer {} {}".format("foo " * (pos - 1), text)
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, ac_app)
- if completions:
- assert first_match is not None
- else:
- assert first_match is None
-
- assert ac_app.completion_matches == sorted(completions, key=ac_app.default_sort_key)
+ completions = ac_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
def test_autocomp_blank_token(ac_app) -> None:
@@ -689,432 +662,413 @@ def test_autocomp_blank_token(ac_app) -> None:
ArgparseCompleter,
)
- blank = ''
+ blank = ""
# Blank flag arg will be consumed. Therefore we expect to be completing the first positional.
- text = ''
- line = f'completer -c {blank} {text}'
+ text = ""
+ line = f"completer -c {blank} {text}"
endidx = len(line)
begidx = endidx - len(text)
completer = ArgparseCompleter(ac_app.completer_parser, ac_app)
- tokens = ['-c', blank, text]
+ tokens = ["-c", blank, text]
completions = completer.complete(text, line, begidx, endidx, tokens)
- assert sorted(completions) == sorted(ArgparseCompleterTester.completions_for_pos_1)
+ expected = ArgparseCompleterTester.completions_for_pos_1
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
# Blank arg for first positional will be consumed. Therefore we expect to be completing the second positional.
- text = ''
- line = f'completer {blank} {text}'
+ text = ""
+ line = f"completer {blank} {text}"
endidx = len(line)
begidx = endidx - len(text)
completer = ArgparseCompleter(ac_app.completer_parser, ac_app)
tokens = [blank, text]
completions = completer.complete(text, line, begidx, endidx, tokens)
- assert sorted(completions) == sorted(ArgparseCompleterTester.completions_for_pos_2)
+ expected = ArgparseCompleterTester.completions_for_pos_2
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
@with_ansi_style(ru.AllowStyle.ALWAYS)
-def test_completion_items(ac_app) -> None:
- # First test CompletionItems created from strings
- text = ''
- line = f'choices --completion_items {text}'
+def test_completion_tables_strings(ac_app) -> None:
+ # Test completion table created from strings
+ text = ""
+ line = f"choices --completion_items {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, ac_app)
- assert first_match is not None
- assert len(ac_app.completion_matches) == len(ac_app.completion_item_choices)
- assert len(ac_app.display_matches) == len(ac_app.completion_item_choices)
+ completions = ac_app.complete(text, line, begidx, endidx)
+ assert len(completions) == len(ac_app.completion_item_choices)
+ assert completions.table is not None
+
+ # Verify the column for the item being completed
+ col_0_cells = list(completions.table.columns[0].cells)
+ assert len(col_0_cells) == 3
+
+ # Since the completed item column is all strings, it is left-aligned
+ assert completions.table.columns[0].justify == "left"
+ assert completions.table.columns[0].header == "COMPLETION_ITEMS"
- lines = ac_app.formatted_completions.splitlines()
+ # ArgparseCompleter converts all items in this column to Rich Text objects
+ assert col_0_cells[0].plain == "choice_1"
+ assert col_0_cells[1].plain == "choice_2"
+ assert col_0_cells[2].plain == "choice_3"
- # Since the CompletionItems were created from strings, the left-most column is left-aligned.
- # Therefore choice_1 will begin the line (with 1 space for padding).
- assert lines[2].startswith(' choice_1')
- assert lines[2].strip().endswith('Description 1')
+ # Verify the column containing contextual data about the item being completed
+ col_1_cells = list(completions.table.columns[1].cells)
+ assert len(col_1_cells) == 3
- # Verify that the styled string was converted to a Rich Text object so that
- # Rich could correctly calculate its display width. Since it was the longest
- # description in the table, we should only see one space of padding after it.
- assert lines[3].endswith("\x1b[34mString with style\x1b[0m ")
+ # Strings with no ANSI style remain strings
+ assert col_1_cells[0] == "Description 1"
- # Verify that the styled Rich Text also rendered.
- assert lines[4].endswith("\x1b[31mText with style \x1b[0m ")
+ # CompletionItem converts strings with ANSI styles to Rich Text objects
+ assert col_1_cells[1].plain == "String with style"
- # Now test CompletionItems created from numbers
- text = ''
- line = f'choices --num_completion_items {text}'
+ # This item was already a Rich Text object
+ assert col_1_cells[2].plain == "Text with style"
+
+
+@with_ansi_style(ru.AllowStyle.ALWAYS)
+def test_completion_tables_numbers(ac_app) -> None:
+ # Test completion table created from numbers
+ text = ""
+ line = f"choices --num_completion_items {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, ac_app)
- assert first_match is not None
- assert len(ac_app.completion_matches) == len(ac_app.num_completion_items)
- assert len(ac_app.display_matches) == len(ac_app.num_completion_items)
+ completions = ac_app.complete(text, line, begidx, endidx)
+ assert len(completions) == len(ac_app.num_completion_items)
+ assert completions.table is not None
+
+ # Verify the column for the item being completed
+ col_0_cells = list(completions.table.columns[0].cells)
+ assert len(col_0_cells) == 3
- lines = ac_app.formatted_completions.splitlines()
+ # Since the completed item column is all numbers, it is right-aligned
+ assert completions.table.columns[0].justify == "right"
- # Since the CompletionItems were created from numbers, the left-most column is right-aligned.
- # Therefore 1.5 will be right-aligned.
- assert lines[2].startswith(" 1.5")
- assert lines[2].strip().endswith('One.Five')
+ # ArgparseCompleter converts all items in this column to Rich Text objects
+ assert col_0_cells[0].plain == "1.5"
+ assert col_0_cells[1].plain == "2"
+ assert col_0_cells[2].plain == "5"
+
+ # Verify the column containing contextual data about the item being completed
+ col_1_cells = list(completions.table.columns[1].cells)
+ assert len(col_1_cells) == 3
+ assert col_1_cells[0] == "One.Five"
+ assert col_1_cells[1] == "Two"
+ assert col_1_cells[2] == "Five"
@pytest.mark.parametrize(
- ('num_aliases', 'show_description'),
+ ("num_aliases", "show_table"),
[
- # The number of completion results determines if the description field of CompletionItems gets displayed
- # in the tab completions. The count must be greater than 1 and less than ac_app.max_completion_items,
+ # The number of completion results determines if a completion table is displayed.
+ # The count must be greater than 1 and less than ac_app.max_completion_table_items,
# which defaults to 50.
(1, False),
(5, True),
(100, False),
],
)
-def test_max_completion_items(ac_app, num_aliases, show_description) -> None:
+def test_max_completion_table_items(ac_app, num_aliases, show_table) -> None:
# Create aliases
for i in range(num_aliases):
- run_cmd(ac_app, f'alias create fake_alias{i} help')
+ run_cmd(ac_app, f"alias create fake_alias{i} help")
assert len(ac_app.aliases) == num_aliases
- text = 'fake_alias'
- line = f'alias list {text}'
+ text = "fake_alias"
+ line = f"alias list {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, ac_app)
- assert first_match is not None
- assert len(ac_app.completion_matches) == num_aliases
- assert len(ac_app.display_matches) == num_aliases
-
- assert bool(ac_app.formatted_completions) == show_description
- if show_description:
- # If show_description is True, the table will show both the alias name and value
- description_displayed = False
- for line in ac_app.formatted_completions.splitlines():
- if 'fake_alias0' in line and 'help' in line:
- description_displayed = True
- break
-
- assert description_displayed
+ completions = ac_app.complete(text, line, begidx, endidx)
+ assert len(completions) == num_aliases
+ assert show_table == (completions.table is not None)
@pytest.mark.parametrize(
- ('args', 'completions'),
+ ("args", "expected"),
[
# Flag with nargs = 2
- ('--set_value', ArgparseCompleterTester.set_value_choices),
- ('--set_value set', ['value', 'choices']),
+ ("--set_value", ArgparseCompleterTester.set_value_choices),
+ ("--set_value set", ["value", "choices"]),
# Both args are filled. At positional arg now.
- ('--set_value set value', ArgparseCompleterTester.positional_choices),
+ ("--set_value set value", ArgparseCompleterTester.positional_choices),
# Using the flag again will reset the choices available
- ('--set_value set value --set_value', ArgparseCompleterTester.set_value_choices),
+ ("--set_value set value --set_value", ArgparseCompleterTester.set_value_choices),
# Flag with nargs = ONE_OR_MORE
- ('--one_or_more', ArgparseCompleterTester.one_or_more_choices),
- ('--one_or_more one', ['or', 'more', 'choices']),
+ ("--one_or_more", ArgparseCompleterTester.one_or_more_choices),
+ ("--one_or_more one", ["or", "more", "choices"]),
# Flag with nargs = OPTIONAL
- ('--optional', ArgparseCompleterTester.optional_choices),
+ ("--optional", ArgparseCompleterTester.optional_choices),
# Only one arg allowed for an OPTIONAL. At positional now.
- ('--optional optional', ArgparseCompleterTester.positional_choices),
+ ("--optional optional", ArgparseCompleterTester.positional_choices),
# Flag with nargs range (1, 2)
- ('--range', ArgparseCompleterTester.range_choices),
- ('--range some', ['range', 'choices']),
+ ("--range", ArgparseCompleterTester.range_choices),
+ ("--range some", ["range", "choices"]),
# Already used 2 args so at positional
- ('--range some range', ArgparseCompleterTester.positional_choices),
+ ("--range some range", ArgparseCompleterTester.positional_choices),
# Flag with nargs = REMAINDER
- ('--remainder', ArgparseCompleterTester.remainder_choices),
- ('--remainder remainder ', ['choices ']),
+ ("--remainder", ArgparseCompleterTester.remainder_choices),
+ ("--remainder remainder ", ["choices"]),
# No more flags can appear after a REMAINDER flag)
- ('--remainder choices --set_value', ['remainder ']),
+ ("--remainder choices --set_value", ["remainder"]),
# Double dash ends the current flag
- ('--range choice --', ArgparseCompleterTester.positional_choices),
+ ("--range choice --", ArgparseCompleterTester.positional_choices),
# Double dash ends a REMAINDER flag
- ('--remainder remainder --', ArgparseCompleterTester.positional_choices),
+ ("--remainder remainder --", ArgparseCompleterTester.positional_choices),
# No more flags after a double dash
- ('-- --one_or_more ', ArgparseCompleterTester.positional_choices),
+ ("-- --one_or_more ", ArgparseCompleterTester.positional_choices),
# Consume positional
- ('', ArgparseCompleterTester.positional_choices),
- ('positional', ['the', 'choices']),
+ ("", ArgparseCompleterTester.positional_choices),
+ ("positional", ["the", "choices"]),
# Intermixed flag and positional
- ('positional --set_value', ArgparseCompleterTester.set_value_choices),
- ('positional --set_value set', ['choices', 'value']),
+ ("positional --set_value", ArgparseCompleterTester.set_value_choices),
+ ("positional --set_value set", ["choices", "value"]),
# Intermixed flag and positional with flag finishing
- ('positional --set_value set value', ['the', 'choices']),
- ('positional --range choice --', ['the', 'choices']),
+ ("positional --set_value set value", ["the", "choices"]),
+ ("positional --range choice --", ["the", "choices"]),
# REMAINDER positional
- ('the positional', ArgparseCompleterTester.remainder_choices),
- ('the positional remainder', ['choices ']),
- ('the positional remainder choices', []),
+ ("the positional", ArgparseCompleterTester.remainder_choices),
+ ("the positional remainder", ["choices"]),
+ ("the positional remainder choices", []),
# REMAINDER positional. Flags don't work in REMAINDER
- ('the positional --set_value', ArgparseCompleterTester.remainder_choices),
- ('the positional remainder --set_value', ['choices ']),
+ ("the positional --set_value", ArgparseCompleterTester.remainder_choices),
+ ("the positional remainder --set_value", ["choices"]),
],
)
-def test_autcomp_nargs(ac_app, args, completions) -> None:
- text = ''
- line = f'nargs {args} {text}'
+def test_autcomp_nargs(ac_app, args, expected) -> None:
+ text = ""
+ line = f"nargs {args} {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, ac_app)
- if completions:
- assert first_match is not None
- else:
- assert first_match is None
-
- assert ac_app.completion_matches == sorted(completions, key=ac_app.default_sort_key)
+ completions = ac_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
@pytest.mark.parametrize(
- ('command_and_args', 'text', 'is_error'),
+ ("command_and_args", "text", "is_error"),
[
# Flag is finished before moving on
- ('hint --flag foo --', '', False),
- ('hint --flag foo --help', '', False),
- ('hint --flag foo', '--', False),
- ('nargs --one_or_more one --', '', False),
- ('nargs --one_or_more one or --set_value', '', False),
- ('nargs --one_or_more one or more', '--', False),
- ('nargs --set_value set value --', '', False),
- ('nargs --set_value set value --one_or_more', '', False),
- ('nargs --set_value set value', '--', False),
- ('nargs --set_val set value', '--', False), # This exercises our abbreviated flag detection
- ('nargs --range choices --', '', False),
- ('nargs --range choices range --set_value', '', False),
- ('nargs --range range', '--', False),
+ ("hint --flag foo --", "", False),
+ ("hint --flag foo --help", "", False),
+ ("hint --flag foo", "--", False),
+ ("nargs --one_or_more one --", "", False),
+ ("nargs --one_or_more one or --set_value", "", False),
+ ("nargs --one_or_more one or more", "--", False),
+ ("nargs --set_value set value --", "", False),
+ ("nargs --set_value set value --one_or_more", "", False),
+ ("nargs --set_value set value", "--", False),
+ ("nargs --set_val set value", "--", False), # This exercises our abbreviated flag detection
+ ("nargs --range choices --", "", False),
+ ("nargs --range choices range --set_value", "", False),
+ ("nargs --range range", "--", False),
# Flag is not finished before moving on
- ('hint --flag --', '', True),
- ('hint --flag --help', '', True),
- ('hint --flag', '--', True),
- ('nargs --one_or_more --', '', True),
- ('nargs --one_or_more --set_value', '', True),
- ('nargs --one_or_more', '--', True),
- ('nargs --set_value set --', '', True),
- ('nargs --set_value set --one_or_more', '', True),
- ('nargs --set_value set', '--', True),
- ('nargs --set_val set', '--', True), # This exercises our abbreviated flag detection
- ('nargs --range --', '', True),
- ('nargs --range --set_value', '', True),
- ('nargs --range', '--', True),
+ ("hint --flag --", "", True),
+ ("hint --flag --help", "", True),
+ ("hint --flag", "--", True),
+ ("nargs --one_or_more --", "", True),
+ ("nargs --one_or_more --set_value", "", True),
+ ("nargs --one_or_more", "--", True),
+ ("nargs --set_value set --", "", True),
+ ("nargs --set_value set --one_or_more", "", True),
+ ("nargs --set_value set", "--", True),
+ ("nargs --set_val set", "--", True), # This exercises our abbreviated flag detection
+ ("nargs --range --", "", True),
+ ("nargs --range --set_value", "", True),
+ ("nargs --range", "--", True),
],
)
-def test_unfinished_flag_error(ac_app, command_and_args, text, is_error, capsys) -> None:
- line = f'{command_and_args} {text}'
+def test_unfinished_flag_error(ac_app, command_and_args, text, is_error) -> None:
+ line = f"{command_and_args} {text}"
endidx = len(line)
begidx = endidx - len(text)
- complete_tester(text, line, begidx, endidx, ac_app)
-
- out, _err = capsys.readouterr()
- assert is_error == all(x in out for x in ["Error: argument", "expected"])
+ completions = ac_app.complete(text, line, begidx, endidx)
+ assert is_error == all(x in completions.error for x in ["Error: argument", "expected"])
-def test_completion_items_arg_header(ac_app) -> None:
+def test_completion_table_metavar(ac_app) -> None:
# Test when metavar is None
- text = ''
- line = f'choices --desc_header {text}'
+ text = ""
+ line = f"choices --no_metavar {text}"
endidx = len(line)
begidx = endidx - len(text)
- complete_tester(text, line, begidx, endidx, ac_app)
- assert "DESC_HEADER" in normalize(ac_app.formatted_completions)[0]
+ completions = ac_app.complete(text, line, begidx, endidx)
+ assert completions.table is not None
+ assert completions.table.columns[0].header == "NO_METAVAR"
# Test when metavar is a string
- text = ''
- line = f'choices --no_header {text}'
+ text = ""
+ line = f"choices --str_metavar {text}"
endidx = len(line)
begidx = endidx - len(text)
- complete_tester(text, line, begidx, endidx, ac_app)
- assert ac_app.STR_METAVAR in normalize(ac_app.formatted_completions)[0]
+ completions = ac_app.complete(text, line, begidx, endidx)
+ assert completions.table is not None
+ assert completions.table.columns[0].header == ac_app.STR_METAVAR
# Test when metavar is a tuple
- text = ''
- line = f'choices --tuple_metavar {text}'
+ text = ""
+ line = f"choices --tuple_metavar {text}"
endidx = len(line)
begidx = endidx - len(text)
# We are completing the first argument of this flag. The first element in the tuple should be the column header.
- complete_tester(text, line, begidx, endidx, ac_app)
- assert ac_app.TUPLE_METAVAR[0].upper() in normalize(ac_app.formatted_completions)[0]
+ completions = ac_app.complete(text, line, begidx, endidx)
+ assert completions.table is not None
+ assert completions.table.columns[0].header == ac_app.TUPLE_METAVAR[0].upper()
- text = ''
- line = f'choices --tuple_metavar token_1 {text}'
+ text = ""
+ line = f"choices --tuple_metavar token_1 {text}"
endidx = len(line)
begidx = endidx - len(text)
# We are completing the second argument of this flag. The second element in the tuple should be the column header.
- complete_tester(text, line, begidx, endidx, ac_app)
- assert ac_app.TUPLE_METAVAR[1].upper() in normalize(ac_app.formatted_completions)[0]
+ completions = ac_app.complete(text, line, begidx, endidx)
+ assert completions.table is not None
+ assert completions.table.columns[0].header == ac_app.TUPLE_METAVAR[1].upper()
- text = ''
- line = f'choices --tuple_metavar token_1 token_2 {text}'
+ text = ""
+ line = f"choices --tuple_metavar token_1 token_2 {text}"
endidx = len(line)
begidx = endidx - len(text)
# We are completing the third argument of this flag. It should still be the second tuple element
# in the column header since the tuple only has two strings in it.
- complete_tester(text, line, begidx, endidx, ac_app)
- assert ac_app.TUPLE_METAVAR[1].upper() in normalize(ac_app.formatted_completions)[0]
-
-
-def test_completion_items_descriptive_headers(ac_app) -> None:
- from cmd2.argparse_completer import (
- DEFAULT_DESCRIPTIVE_HEADERS,
- )
-
- # This argument provided a descriptive header
- text = ''
- line = f'choices --desc_header {text}'
- endidx = len(line)
- begidx = endidx - len(text)
-
- complete_tester(text, line, begidx, endidx, ac_app)
- assert ac_app.CUSTOM_DESC_HEADERS[0] in normalize(ac_app.formatted_completions)[0]
-
- # This argument did not provide a descriptive header, so it should be DEFAULT_DESCRIPTIVE_HEADERS
- text = ''
- line = f'choices --no_header {text}'
- endidx = len(line)
- begidx = endidx - len(text)
-
- complete_tester(text, line, begidx, endidx, ac_app)
- assert DEFAULT_DESCRIPTIVE_HEADERS[0] in normalize(ac_app.formatted_completions)[0]
+ completions = ac_app.complete(text, line, begidx, endidx)
+ assert completions.table is not None
+ assert completions.table.columns[0].header == ac_app.TUPLE_METAVAR[1].upper()
@pytest.mark.parametrize(
- ('command_and_args', 'text', 'has_hint'),
+ ("command_and_args", "text", "has_hint"),
[
# Normal cases
- ('hint', '', True),
- ('hint --flag', '', True),
- ('hint --suppressed_help', '', False),
- ('hint --suppressed_hint', '', False),
+ ("hint", "", True),
+ ("hint --flag", "", True),
+ ("hint --suppressed_help", "", False),
+ ("hint --suppressed_hint", "", False),
# Hint because flag does not have enough values to be considered finished
- ('nargs --one_or_more', '-', True),
+ ("nargs --one_or_more", "-", True),
# This flag has reached its minimum value count and therefore a new flag could start.
# However the flag can still consume values and the text is not a single prefix character.
# Therefore a hint will be shown.
- ('nargs --one_or_more choices', 'bad_completion', True),
+ ("nargs --one_or_more choices", "bad_completion", True),
# Like the previous case, but this time text is a single prefix character which will cause flag
# name completion to occur instead of a hint for the current flag.
- ('nargs --one_or_more choices', '-', False),
+ ("nargs --one_or_more choices", "-", False),
# Hint because this is a REMAINDER flag and therefore no more flag name completions occur.
- ('nargs --remainder', '-', True),
+ ("nargs --remainder", "-", True),
# No hint for the positional because text is a single prefix character which results in flag name completion
- ('hint', '-', False),
+ ("hint", "-", False),
# Hint because this is a REMAINDER positional and therefore no more flag name completions occur.
- ('nargs the choices', '-', True),
- ('nargs the choices remainder', '-', True),
+ ("nargs the choices", "-", True),
+ ("nargs the choices remainder", "-", True),
],
)
-def test_autocomp_hint(ac_app, command_and_args, text, has_hint, capsys) -> None:
- line = f'{command_and_args} {text}'
+def test_autocomp_no_results_hint(ac_app, command_and_args, text, has_hint) -> None:
+ """Test whether _NoResultsErrors include hint text."""
+ line = f"{command_and_args} {text}"
endidx = len(line)
begidx = endidx - len(text)
- complete_tester(text, line, begidx, endidx, ac_app)
- out, _err = capsys.readouterr()
+ completions = ac_app.complete(text, line, begidx, endidx)
if has_hint:
- assert "Hint:\n" in out
+ assert "Hint:\n" in completions.error
else:
- assert not out
+ assert not completions.error
-def test_autocomp_hint_no_help_text(ac_app, capsys) -> None:
- text = ''
- line = f'hint foo {text}'
+def test_autocomp_hint_no_help_text(ac_app) -> None:
+ """Tests that a hint for an arg with no help text only includes the arg's name."""
+ text = ""
+ line = f"hint foo {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, ac_app)
- out, _err = capsys.readouterr()
-
- assert first_match is None
- assert out != '''\nHint:\n NO_HELP_POS\n\n'''
+ completions = ac_app.complete(text, line, begidx, endidx)
+ assert completions.error.strip() == "Hint:\n no_help_pos"
@pytest.mark.parametrize(
- ('args', 'text'),
+ ("args", "text"),
[
# Exercise a flag arg and choices function that raises a CompletionError
- ('--choice ', 'choice'),
+ ("--choice ", "choice"),
# Exercise a positional arg and completer that raises a CompletionError
- ('', 'completer'),
+ ("", "completer"),
],
)
-def test_completion_error(ac_app, capsys, args, text) -> None:
- line = f'raise_completion_error {args} {text}'
+def test_completion_error(ac_app, args, text) -> None:
+ line = f"raise_completion_error {args} {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, ac_app)
- out, _err = capsys.readouterr()
-
- assert first_match is None
- assert f"{text} broke something" in out
+ completions = ac_app.complete(text, line, begidx, endidx)
+ assert f"{text} broke something" in completions.error
@pytest.mark.parametrize(
- ('command_and_args', 'completions'),
+ ("command_and_args", "expected"),
[
# Exercise a choices function that receives arg_tokens dictionary
- ('arg_tokens choice subcmd', ['choice', 'subcmd']),
+ ("arg_tokens choice subcmd", ["choice", "subcmd"]),
# Exercise a completer that receives arg_tokens dictionary
- ('arg_tokens completer subcmd fake', ['completer', 'subcmd']),
+ ("arg_tokens completer subcmd fake", ["completer", "subcmd"]),
# Exercise overriding parent_arg from the subcommand
- ('arg_tokens completer subcmd --parent_arg override fake', ['override', 'subcmd']),
+ ("arg_tokens completer subcmd --parent_arg override fake", ["override", "subcmd"]),
],
)
-def test_arg_tokens(ac_app, command_and_args, completions) -> None:
- text = ''
- line = f'{command_and_args} {text}'
+def test_arg_tokens(ac_app, command_and_args, expected) -> None:
+ text = ""
+ line = f"{command_and_args} {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, ac_app)
- if completions:
- assert first_match is not None
- else:
- assert first_match is None
-
- assert ac_app.completion_matches == sorted(completions, key=ac_app.default_sort_key)
+ completions = ac_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
@pytest.mark.parametrize(
- ('command_and_args', 'text', 'output_contains', 'first_match'),
+ ("command_and_args", "text", "output_contains", "first_match"),
[
- # Group isn't done. Hint will show for optional positional and no completions returned
- ('mutex', '', 'the optional positional', None),
+ # Group isn't done. The optional positional's hint will show and flags will not complete.
+ ("mutex", "", "the optional positional", None),
# Group isn't done. Flag name will still complete.
- ('mutex', '--fl', '', '--flag '),
+ ("mutex", "--fl", "", "--flag"),
# Group isn't done. Flag hint will show.
- ('mutex --flag', '', 'the flag arg', None),
+ ("mutex --flag", "", "the flag arg", None),
# Group finished by optional positional. No flag name will complete.
- ('mutex pos_val', '--fl', '', None),
+ ("mutex pos_val", "--fl", "", None),
# Group finished by optional positional. Error will display trying to complete the flag's value.
- ('mutex pos_val --flag', '', 'f/--flag: not allowed with argument optional_pos', None),
+ ("mutex pos_val --flag", "", "f/--flag: not allowed with argument optional_pos", None),
# Group finished by --flag. Optional positional will be skipped and last_arg will show its hint.
- ('mutex --flag flag_val', '', 'the last arg', None),
+ ("mutex --flag flag_val", "", "the last arg", None),
# Group finished by --flag. Other flag name won't complete.
- ('mutex --flag flag_val', '--oth', '', None),
+ ("mutex --flag flag_val", "--oth", "", None),
# Group finished by --flag. Error will display trying to complete other flag's value.
- ('mutex --flag flag_val --other', '', '-o/--other_flag: not allowed with argument -f/--flag', None),
+ ("mutex --flag flag_val --other", "", "-o/--other_flag: not allowed with argument -f/--flag", None),
# Group finished by --flag. That same flag can be used again so it's hint will show.
- ('mutex --flag flag_val --flag', '', 'the flag arg', None),
+ ("mutex --flag flag_val --flag", "", "the flag arg", None),
],
)
-def test_complete_mutex_group(ac_app, command_and_args, text, output_contains, first_match, capsys) -> None:
- line = f'{command_and_args} {text}'
+def test_complete_mutex_group(ac_app, command_and_args, text, output_contains, first_match) -> None:
+ line = f"{command_and_args} {text}"
endidx = len(line)
begidx = endidx - len(text)
- assert first_match == complete_tester(text, line, begidx, endidx, ac_app)
+ completions = ac_app.complete(text, line, begidx, endidx)
+ if first_match is None:
+ assert not completions
+ else:
+ assert first_match == completions[0].text
- out, _err = capsys.readouterr()
- assert output_contains in out
+ assert output_contains in completions.error
def test_single_prefix_char() -> None:
@@ -1122,18 +1076,18 @@ def test_single_prefix_char() -> None:
_single_prefix_char,
)
- parser = Cmd2ArgumentParser(prefix_chars='-+')
+ parser = Cmd2ArgumentParser(prefix_chars="-+")
# Invalid
- assert not _single_prefix_char('', parser)
- assert not _single_prefix_char('--', parser)
- assert not _single_prefix_char('-+', parser)
- assert not _single_prefix_char('++has space', parser)
- assert not _single_prefix_char('foo', parser)
+ assert not _single_prefix_char("", parser)
+ assert not _single_prefix_char("--", parser)
+ assert not _single_prefix_char("-+", parser)
+ assert not _single_prefix_char("++has space", parser)
+ assert not _single_prefix_char("foo", parser)
# Valid
- assert _single_prefix_char('-', parser)
- assert _single_prefix_char('+', parser)
+ assert _single_prefix_char("-", parser)
+ assert _single_prefix_char("+", parser)
def test_looks_like_flag() -> None:
@@ -1144,16 +1098,16 @@ def test_looks_like_flag() -> None:
parser = Cmd2ArgumentParser()
# Does not start like a flag
- assert not _looks_like_flag('', parser)
- assert not _looks_like_flag('non-flag', parser)
- assert not _looks_like_flag('-', parser)
- assert not _looks_like_flag('--has space', parser)
- assert not _looks_like_flag('-2', parser)
+ assert not _looks_like_flag("", parser)
+ assert not _looks_like_flag("non-flag", parser)
+ assert not _looks_like_flag("-", parser)
+ assert not _looks_like_flag("--has space", parser)
+ assert not _looks_like_flag("-2", parser)
# Does start like a flag
- assert _looks_like_flag('--', parser)
- assert _looks_like_flag('-flag', parser)
- assert _looks_like_flag('--flag', parser)
+ assert _looks_like_flag("--", parser)
+ assert _looks_like_flag("-flag", parser)
+ assert _looks_like_flag("--flag", parser)
def test_complete_command_no_tokens(ac_app) -> None:
@@ -1164,7 +1118,7 @@ def test_complete_command_no_tokens(ac_app) -> None:
parser = Cmd2ArgumentParser()
ac = ArgparseCompleter(parser, ac_app)
- completions = ac.complete(text='', line='', begidx=0, endidx=0, tokens=[])
+ completions = ac.complete(text="", line="", begidx=0, endidx=0, tokens=[])
assert not completions
@@ -1176,40 +1130,156 @@ def test_complete_command_help_no_tokens(ac_app) -> None:
parser = Cmd2ArgumentParser()
ac = ArgparseCompleter(parser, ac_app)
- completions = ac.complete_subcommand_help(text='', line='', begidx=0, endidx=0, tokens=[])
+ completions = ac.complete_subcommand_help(text="", line="", begidx=0, endidx=0, tokens=[])
assert not completions
@pytest.mark.parametrize(
- ('flag', 'completions'), [('--provider', standalone_choices), ('--completer', standalone_completions)]
+ ("flag", "expected"),
+ [
+ ("--provider", standalone_choices),
+ ("--completer", standalone_completions),
+ ],
)
-def test_complete_standalone(ac_app, flag, completions) -> None:
- text = ''
- line = f'standalone {flag} {text}'
+def test_complete_standalone(ac_app, flag, expected) -> None:
+ text = ""
+ line = f"standalone {flag} {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, ac_app)
- assert first_match is not None
- assert ac_app.completion_matches == sorted(completions, key=ac_app.default_sort_key)
+ completions = ac_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
+
+
+@pytest.mark.parametrize(
+ ("subcommand", "flag", "display_meta"),
+ [
+ ("helpful", "", "my helpful text"),
+ ("helpful", "--helpful_flag", "a helpful flag"),
+ ("helpless", "", ""),
+ ("helpless", "--helpless_flag", ""),
+ ],
+)
+def test_display_meta(ac_app, subcommand, flag, display_meta) -> None:
+ """Test that subcommands and flags can have display_meta data."""
+ if flag:
+ text = flag
+ line = f"meta {subcommand} {text}"
+ else:
+ text = subcommand
+ line = f"meta {text}"
+
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ completions = ac_app.complete(text, line, begidx, endidx)
+ assert completions[0].display_meta == display_meta
+
+
+def test_validate_table_data_no_table() -> None:
+ action = argparse.Action(option_strings=["-f"], dest="foo")
+ action.set_table_columns(None)
+ arg_state = argparse_completer._ArgumentState(action)
+ completions = Completions(
+ [
+ CompletionItem("item1"),
+ CompletionItem("item2"),
+ ]
+ )
+
+ # This should not raise an exception
+ argparse_completer.ArgparseCompleter._validate_table_data(arg_state, completions)
+
+
+def test_validate_table_data_missing_columns() -> None:
+ action = argparse.Action(option_strings=["-f"], dest="foo")
+ action.set_table_columns(None)
+ arg_state = argparse_completer._ArgumentState(action)
+
+ completions = Completions(
+ [
+ CompletionItem("item1", table_data=["data1"]),
+ CompletionItem("item2", table_data=["data2"]),
+ ]
+ )
+
+ with pytest.raises(
+ ValueError,
+ match="Argument 'foo' has CompletionItems with table_data, but no table_columns were defined",
+ ):
+ argparse_completer.ArgparseCompleter._validate_table_data(arg_state, completions)
+
+
+def test_validate_table_data_missing_item_data() -> None:
+ action = argparse.Action(option_strings=["-f"], dest="foo")
+ action.set_table_columns(["Col1"])
+ arg_state = argparse_completer._ArgumentState(action)
+
+ completions = Completions(
+ [
+ CompletionItem("item1", table_data=["data1"]),
+ CompletionItem("item2"), # Missing table_data
+ ]
+ )
+
+ with pytest.raises(
+ ValueError,
+ match="Argument 'foo' has table_columns defined, but the CompletionItem for 'item2' is missing table_data",
+ ):
+ argparse_completer.ArgparseCompleter._validate_table_data(arg_state, completions)
+
+
+def test_validate_table_data_length_mismatch() -> None:
+ action = argparse.Action(option_strings=["-f"], dest="foo")
+ action.set_table_columns(["Col1", "Col2"])
+ arg_state = argparse_completer._ArgumentState(action)
+
+ completions = Completions(
+ [
+ CompletionItem("item1", table_data=["data1a", "data1b"]),
+ CompletionItem("item2", table_data=["only_one"]),
+ ]
+ )
+
+ with pytest.raises(
+ ValueError,
+ match=r"Argument 'foo': table_data length \(1\) does not match table_columns length \(2\) for item 'item2'.",
+ ):
+ argparse_completer.ArgparseCompleter._validate_table_data(arg_state, completions)
+
+
+def test_validate_table_data_valid() -> None:
+ action = argparse.Action(option_strings=["-f"], dest="foo")
+ action.get_table_columns = lambda: ["Col1", "Col2"]
+ arg_state = argparse_completer._ArgumentState(action)
+
+ completions = Completions(
+ [
+ CompletionItem("item1", table_data=["data1a", "data1b"]),
+ CompletionItem("item2", table_data=["data2a", "data2b"]),
+ ]
+ )
+
+ # This should not raise an exception
+ argparse_completer.ArgparseCompleter._validate_table_data(arg_state, completions)
# Custom ArgparseCompleter-based class
class CustomCompleter(argparse_completer.ArgparseCompleter):
- def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, matched_flags: list[str]) -> list[str]:
+ def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, used_flags: set[str]) -> Completions:
"""Override so flags with 'complete_when_ready' set to True will complete only when app is ready"""
- # Find flags which should not be completed and place them in matched_flags
+ # Find flags which should not be completed and place them in used_flags
for flag in self._flags:
action = self._flag_to_action[flag]
app: CustomCompleterApp = cast(CustomCompleterApp, self._cmd2_app)
- if action.get_complete_when_ready() is True and not app.is_ready:
- matched_flags.append(flag)
+ if action.get_complete_when_ready() and not app.is_ready:
+ used_flags.append(flag)
- return super()._complete_flags(text, line, begidx, endidx, matched_flags)
+ return super()._complete_flags(text, line, begidx, endidx, used_flags)
# Add a custom argparse action attribute
-argparse_custom.register_argparse_argument_parameter('complete_when_ready', bool)
+argparse_utils.register_argparse_argument_parameter("complete_when_ready")
# App used to test custom ArgparseCompleter types and custom argparse attributes
@@ -1220,7 +1290,7 @@ def __init__(self) -> None:
# Parser that's used to test setting the app-wide default ArgparseCompleter type
default_completer_parser = Cmd2ArgumentParser(description="Testing app-wide argparse completer")
- default_completer_parser.add_argument('--myflag', complete_when_ready=True)
+ default_completer_parser.add_argument("--myflag", complete_when_ready=True)
@with_argparser(default_completer_parser)
def do_default_completer(self, args: argparse.Namespace) -> None:
@@ -1230,7 +1300,7 @@ def do_default_completer(self, args: argparse.Namespace) -> None:
custom_completer_parser = Cmd2ArgumentParser(
description="Testing parser-specific argparse completer", ap_completer_type=CustomCompleter
)
- custom_completer_parser.add_argument('--myflag', complete_when_ready=True)
+ custom_completer_parser.add_argument("--myflag", complete_when_ready=True)
@with_argparser(custom_completer_parser)
def do_custom_completer(self, args: argparse.Namespace) -> None:
@@ -1238,28 +1308,27 @@ def do_custom_completer(self, args: argparse.Namespace) -> None:
# Test as_subcommand_to decorator with custom completer
top_parser = Cmd2ArgumentParser(description="Top Command")
- top_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND', required=True)
+ top_parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND", required=True)
@with_argparser(top_parser)
def do_top(self, args: argparse.Namespace) -> None:
"""Top level command"""
# Call handler for whatever subcommand was selected
- handler = args.cmd2_handler.get()
- handler(args)
+ args.cmd2_subcmd_handler(args)
# Parser for a subcommand with no custom completer type
no_custom_completer_parser = Cmd2ArgumentParser(description="No custom completer")
- no_custom_completer_parser.add_argument('--myflag', complete_when_ready=True)
+ no_custom_completer_parser.add_argument("--myflag", complete_when_ready=True)
- @cmd2.as_subcommand_to('top', 'no_custom', no_custom_completer_parser, help="no custom completer")
+ @cmd2.as_subcommand_to("top", "no_custom", no_custom_completer_parser, help="no custom completer")
def _subcmd_no_custom(self, args: argparse.Namespace) -> None:
pass
# Parser for a subcommand with a custom completer type
custom_completer_parser2 = Cmd2ArgumentParser(description="Custom completer", ap_completer_type=CustomCompleter)
- custom_completer_parser2.add_argument('--myflag', complete_when_ready=True)
+ custom_completer_parser2.add_argument("--myflag", complete_when_ready=True)
- @cmd2.as_subcommand_to('top', 'custom', custom_completer_parser2, help="custom completer")
+ @cmd2.as_subcommand_to("top", "custom", custom_completer_parser2, help="custom completer")
def _subcmd_custom(self, args: argparse.Namespace) -> None:
pass
@@ -1274,20 +1343,20 @@ def test_default_custom_completer_type(custom_completer_app: CustomCompleterApp)
try:
argparse_completer.set_default_ap_completer_type(CustomCompleter)
- text = '--m'
- line = f'default_completer {text}'
+ text = "--m"
+ line = f"default_completer {text}"
endidx = len(line)
begidx = endidx - len(text)
# The flag should complete because app is ready
custom_completer_app.is_ready = True
- assert complete_tester(text, line, begidx, endidx, custom_completer_app) is not None
- assert custom_completer_app.completion_matches == ['--myflag ']
+ completions = custom_completer_app.complete(text, line, begidx, endidx)
+ assert completions.items[0].text == "--myflag"
# The flag should not complete because app is not ready
custom_completer_app.is_ready = False
- assert complete_tester(text, line, begidx, endidx, custom_completer_app) is None
- assert not custom_completer_app.completion_matches
+ completions = custom_completer_app.complete(text, line, begidx, endidx)
+ assert not completions
finally:
# Restore the default completer
@@ -1296,54 +1365,54 @@ def test_default_custom_completer_type(custom_completer_app: CustomCompleterApp)
def test_custom_completer_type(custom_completer_app: CustomCompleterApp) -> None:
"""Test parser with a specific custom ArgparseCompleter type"""
- text = '--m'
- line = f'custom_completer {text}'
+ text = "--m"
+ line = f"custom_completer {text}"
endidx = len(line)
begidx = endidx - len(text)
# The flag should complete because app is ready
custom_completer_app.is_ready = True
- assert complete_tester(text, line, begidx, endidx, custom_completer_app) is not None
- assert custom_completer_app.completion_matches == ['--myflag ']
+ completions = custom_completer_app.complete(text, line, begidx, endidx)
+ assert completions.items[0].text == "--myflag"
# The flag should not complete because app is not ready
custom_completer_app.is_ready = False
- assert complete_tester(text, line, begidx, endidx, custom_completer_app) is None
- assert not custom_completer_app.completion_matches
+ completions = custom_completer_app.complete(text, line, begidx, endidx)
+ assert not completions
def test_decorated_subcmd_custom_completer(custom_completer_app: CustomCompleterApp) -> None:
"""Tests custom completer type on a subcommand created with @cmd2.as_subcommand_to"""
# First test the subcommand without the custom completer
- text = '--m'
- line = f'top no_custom {text}'
+ text = "--m"
+ line = f"top no_custom {text}"
endidx = len(line)
begidx = endidx - len(text)
# The flag should complete regardless of ready state since this subcommand isn't using the custom completer
custom_completer_app.is_ready = True
- assert complete_tester(text, line, begidx, endidx, custom_completer_app) is not None
- assert custom_completer_app.completion_matches == ['--myflag ']
+ completions = custom_completer_app.complete(text, line, begidx, endidx)
+ assert completions.items[0].text == "--myflag"
custom_completer_app.is_ready = False
- assert complete_tester(text, line, begidx, endidx, custom_completer_app) is not None
- assert custom_completer_app.completion_matches == ['--myflag ']
+ completions = custom_completer_app.complete(text, line, begidx, endidx)
+ assert completions.items[0].text == "--myflag"
# Now test the subcommand with the custom completer
- text = '--m'
- line = f'top custom {text}'
+ text = "--m"
+ line = f"top custom {text}"
endidx = len(line)
begidx = endidx - len(text)
# The flag should complete because app is ready
custom_completer_app.is_ready = True
- assert complete_tester(text, line, begidx, endidx, custom_completer_app) is not None
- assert custom_completer_app.completion_matches == ['--myflag ']
+ completions = custom_completer_app.complete(text, line, begidx, endidx)
+ assert completions.items[0].text == "--myflag"
# The flag should not complete because app is not ready
custom_completer_app.is_ready = False
- assert complete_tester(text, line, begidx, endidx, custom_completer_app) is None
- assert not custom_completer_app.completion_matches
+ completions = custom_completer_app.complete(text, line, begidx, endidx)
+ assert not completions
def test_add_parser_custom_completer() -> None:
@@ -1351,8 +1420,10 @@ def test_add_parser_custom_completer() -> None:
parser = Cmd2ArgumentParser()
subparsers = parser.add_subparsers()
- no_custom_completer_parser = subparsers.add_parser(name="no_custom_completer")
- assert no_custom_completer_parser.get_ap_completer_type() is None # type: ignore[attr-defined]
+ no_custom_completer_parser: Cmd2ArgumentParser = subparsers.add_parser(name="no_custom_completer")
+ assert no_custom_completer_parser.ap_completer_type is None
- custom_completer_parser = subparsers.add_parser(name="custom_completer", ap_completer_type=CustomCompleter)
- assert custom_completer_parser.get_ap_completer_type() is CustomCompleter # type: ignore[attr-defined]
+ custom_completer_parser: Cmd2ArgumentParser = subparsers.add_parser(
+ name="custom_completer", ap_completer_type=CustomCompleter
+ )
+ assert custom_completer_parser.ap_completer_type is CustomCompleter
diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py
deleted file mode 100644
index 3889be147..000000000
--- a/tests/test_argparse_custom.py
+++ /dev/null
@@ -1,348 +0,0 @@
-"""Unit/functional testing for argparse customizations in cmd2"""
-
-import argparse
-
-import pytest
-
-import cmd2
-from cmd2 import (
- Cmd2ArgumentParser,
- constants,
-)
-from cmd2.argparse_custom import generate_range_error
-
-from .conftest import run_cmd
-
-
-class ApCustomTestApp(cmd2.Cmd):
- """Test app for cmd2's argparse customization"""
-
- def __init__(self, *args, **kwargs) -> None:
- super().__init__(*args, **kwargs)
-
- range_parser = Cmd2ArgumentParser()
- range_parser.add_argument('--arg0', nargs=1)
- range_parser.add_argument('--arg1', nargs=2)
- range_parser.add_argument('--arg2', nargs=(3,))
- range_parser.add_argument('--arg3', nargs=(2, 3))
-
- @cmd2.with_argparser(range_parser)
- def do_range(self, _) -> None:
- pass
-
-
-@pytest.fixture
-def cust_app():
- return ApCustomTestApp()
-
-
-def fake_func() -> None:
- pass
-
-
-@pytest.mark.parametrize(
- ('kwargs', 'is_valid'),
- [
- ({'choices_provider': fake_func}, True),
- ({'completer': fake_func}, True),
- ({'choices_provider': fake_func, 'completer': fake_func}, False),
- ],
-)
-def test_apcustom_choices_callable_count(kwargs, is_valid) -> None:
- parser = Cmd2ArgumentParser()
- if is_valid:
- parser.add_argument('name', **kwargs)
- else:
- expected_err = 'Only one of the following parameters'
- with pytest.raises(ValueError, match=expected_err):
- parser.add_argument('name', **kwargs)
-
-
-@pytest.mark.parametrize('kwargs', [({'choices_provider': fake_func}), ({'completer': fake_func})])
-def test_apcustom_no_choices_callables_alongside_choices(kwargs) -> None:
- parser = Cmd2ArgumentParser()
- with pytest.raises(TypeError) as excinfo:
- parser.add_argument('name', choices=['my', 'choices', 'list'], **kwargs)
- assert 'None of the following parameters can be used alongside a choices parameter' in str(excinfo.value)
-
-
-@pytest.mark.parametrize('kwargs', [({'choices_provider': fake_func}), ({'completer': fake_func})])
-def test_apcustom_no_choices_callables_when_nargs_is_0(kwargs) -> None:
- parser = Cmd2ArgumentParser()
- with pytest.raises(TypeError) as excinfo:
- parser.add_argument('--name', action='store_true', **kwargs)
- assert 'None of the following parameters can be used on an action that takes no arguments' in str(excinfo.value)
-
-
-def test_apcustom_usage() -> None:
- usage = "A custom usage statement"
- parser = Cmd2ArgumentParser(usage=usage)
- assert usage in parser.format_help()
-
-
-def test_apcustom_nargs_help_format(cust_app) -> None:
- out, _err = run_cmd(cust_app, 'help range')
- assert 'Usage: range [-h] [--arg0 ARG0] [--arg1 ARG1{2}] [--arg2 ARG2{3+}]' in out[0]
- assert ' [--arg3 ARG3{2..3}]' in out[1]
-
-
-def test_apcustom_nargs_range_validation(cust_app) -> None:
- # nargs = (3,) # noqa: ERA001
- _out, err = run_cmd(cust_app, 'range --arg2 one two')
- assert 'Error: argument --arg2: expected at least 3 arguments' in err[2]
-
- _out, err = run_cmd(cust_app, 'range --arg2 one two three')
- assert not err
-
- _out, err = run_cmd(cust_app, 'range --arg2 one two three four')
- assert not err
-
- # nargs = (2,3) # noqa: ERA001
- _out, err = run_cmd(cust_app, 'range --arg3 one')
- assert 'Error: argument --arg3: expected 2 to 3 arguments' in err[2]
-
- _out, err = run_cmd(cust_app, 'range --arg3 one two')
- assert not err
-
- _out, err = run_cmd(cust_app, 'range --arg2 one two three')
- assert not err
-
-
-@pytest.mark.parametrize(
- ('nargs', 'expected_parts'),
- [
- # arg{2}
- (
- 2,
- [("arg", True), ("{2}", False)],
- ),
- # arg{2+}
- (
- (2,),
- [("arg", True), ("{2+}", False)],
- ),
- # arg{0..5}
- (
- (0, 5),
- [("arg", True), ("{0..5}", False)],
- ),
- ],
-)
-def test_rich_metavar_parts(
- nargs: int | tuple[int, int | float],
- expected_parts: list[tuple[str, bool]],
-) -> None:
- """
- Test cmd2's override of _rich_metavar_parts which handles custom nargs formats.
-
- :param nargs: the arguments nargs value
- :param expected_parts: list to compare to _rich_metavar_parts's return value
-
- Each element in this list is a 2-item tuple.
- item 1: one part of the argument string outputted by _format_args
- item 2: boolean stating whether rich-argparse should color this part
- """
- parser = Cmd2ArgumentParser()
- help_formatter = parser._get_formatter()
-
- action = parser.add_argument("arg", nargs=nargs) # type: ignore[arg-type]
- default_metavar = help_formatter._get_default_metavar_for_positional(action)
-
- parts = help_formatter._rich_metavar_parts(action, default_metavar)
- assert list(parts) == expected_parts
-
-
-@pytest.mark.parametrize(
- 'nargs_tuple',
- [
- (),
- ('f', 5),
- (5, 'f'),
- (1, 2, 3),
- ],
-)
-def test_apcustom_narg_invalid_tuples(nargs_tuple) -> None:
- parser = Cmd2ArgumentParser()
- expected_err = 'Ranged values for nargs must be a tuple of 1 or 2 integers'
- with pytest.raises(ValueError, match=expected_err):
- parser.add_argument('invalid_tuple', nargs=nargs_tuple)
-
-
-def test_apcustom_narg_tuple_order() -> None:
- parser = Cmd2ArgumentParser()
- expected_err = 'Invalid nargs range. The first value must be less than the second'
- with pytest.raises(ValueError, match=expected_err):
- parser.add_argument('invalid_tuple', nargs=(2, 1))
-
-
-def test_apcustom_narg_tuple_negative() -> None:
- parser = Cmd2ArgumentParser()
- expected_err = 'Negative numbers are invalid for nargs range'
- with pytest.raises(ValueError, match=expected_err):
- parser.add_argument('invalid_tuple', nargs=(-1, 1))
-
-
-def test_apcustom_narg_tuple_zero_base() -> None:
- parser = Cmd2ArgumentParser()
- arg = parser.add_argument('arg', nargs=(0,))
- assert arg.nargs == argparse.ZERO_OR_MORE
- assert arg.nargs_range is None
- assert "[arg ...]" in parser.format_help()
-
- parser = Cmd2ArgumentParser()
- arg = parser.add_argument('arg', nargs=(0, 1))
- assert arg.nargs == argparse.OPTIONAL
- assert arg.nargs_range is None
- assert "[arg]" in parser.format_help()
-
- parser = Cmd2ArgumentParser()
- arg = parser.add_argument('arg', nargs=(0, 3))
- assert arg.nargs == argparse.ZERO_OR_MORE
- assert arg.nargs_range == (0, 3)
- assert "arg{0..3}" in parser.format_help()
-
-
-def test_apcustom_narg_tuple_one_base() -> None:
- parser = Cmd2ArgumentParser()
- arg = parser.add_argument('arg', nargs=(1,))
- assert arg.nargs == argparse.ONE_OR_MORE
- assert arg.nargs_range is None
- assert "arg [arg ...]" in parser.format_help()
-
- parser = Cmd2ArgumentParser()
- arg = parser.add_argument('arg', nargs=(1, 5))
- assert arg.nargs == argparse.ONE_OR_MORE
- assert arg.nargs_range == (1, 5)
- assert "arg{1..5}" in parser.format_help()
-
-
-def test_apcustom_narg_tuple_other_ranges() -> None:
- # Test range with no upper bound on max
- parser = Cmd2ArgumentParser()
- arg = parser.add_argument('arg', nargs=(2,))
- assert arg.nargs == argparse.ONE_OR_MORE
- assert arg.nargs_range == (2, constants.INFINITY)
-
- # Test finite range
- parser = Cmd2ArgumentParser()
- arg = parser.add_argument('arg', nargs=(2, 5))
- assert arg.nargs == argparse.ONE_OR_MORE
- assert arg.nargs_range == (2, 5)
-
-
-def test_apcustom_print_message(capsys) -> None:
- import sys
-
- test_message = 'The test message'
-
- # Specify the file
- parser = Cmd2ArgumentParser()
- parser._print_message(test_message, file=sys.stdout)
- out, err = capsys.readouterr()
- assert test_message in out
-
- # Make sure file defaults to sys.stderr
- parser = Cmd2ArgumentParser()
- parser._print_message(test_message)
- out, err = capsys.readouterr()
- assert test_message in err
-
-
-def test_generate_range_error() -> None:
- # max is INFINITY
- err_str = generate_range_error(1, constants.INFINITY)
- assert err_str == "expected at least 1 argument"
-
- err_str = generate_range_error(2, constants.INFINITY)
- assert err_str == "expected at least 2 arguments"
-
- # min and max are equal
- err_str = generate_range_error(1, 1)
- assert err_str == "expected 1 argument"
-
- err_str = generate_range_error(2, 2)
- assert err_str == "expected 2 arguments"
-
- # min and max are not equal
- err_str = generate_range_error(0, 1)
- assert err_str == "expected 0 to 1 argument"
-
- err_str = generate_range_error(0, 2)
- assert err_str == "expected 0 to 2 arguments"
-
-
-def test_apcustom_required_options() -> None:
- # Make sure a 'required arguments' section shows when a flag is marked required
- parser = Cmd2ArgumentParser()
- parser.add_argument('--required_flag', required=True)
- assert 'Required Arguments' in parser.format_help()
-
-
-def test_apcustom_metavar_tuple() -> None:
- # Test the case when a tuple metavar is used with nargs an integer > 1
- parser = Cmd2ArgumentParser()
- parser.add_argument('--aflag', nargs=2, metavar=('foo', 'bar'), help='This is a test')
- assert '[--aflag foo bar]' in parser.format_help()
-
-
-def test_cmd2_attribute_wrapper() -> None:
- initial_val = 5
- wrapper = cmd2.Cmd2AttributeWrapper(initial_val)
- assert wrapper.get() == initial_val
-
- new_val = 22
- wrapper.set(new_val)
- assert wrapper.get() == new_val
-
-
-def test_completion_items_as_choices(capsys) -> None:
- """Test cmd2's patch to Argparse._check_value() which supports CompletionItems as choices.
- Choices are compared to CompletionItems.orig_value instead of the CompletionItem instance.
- """
- from cmd2.argparse_custom import (
- CompletionItem,
- )
-
- ##############################################################
- # Test CompletionItems with str values
- ##############################################################
- choices = [CompletionItem("1", "Description One"), CompletionItem("2", "Two")]
- parser = Cmd2ArgumentParser()
- parser.add_argument("choices_arg", type=str, choices=choices)
-
- # First test valid choices. Confirm the parsed data matches the correct type of str.
- args = parser.parse_args(['1'])
- assert args.choices_arg == '1'
-
- args = parser.parse_args(['2'])
- assert args.choices_arg == '2'
-
- # Next test invalid choice
- with pytest.raises(SystemExit):
- args = parser.parse_args(['3'])
-
- # Confirm error text contains correct value type of str
- _out, err = capsys.readouterr()
- assert "invalid choice: '3' (choose from '1', '2')" in err
-
- ##############################################################
- # Test CompletionItems with int values
- ##############################################################
- choices = [CompletionItem(1, "Description One"), CompletionItem(2, "Two")]
- parser = Cmd2ArgumentParser()
- parser.add_argument("choices_arg", type=int, choices=choices)
-
- # First test valid choices. Confirm the parsed data matches the correct type of int.
- args = parser.parse_args(['1'])
- assert args.choices_arg == 1
-
- args = parser.parse_args(['2'])
- assert args.choices_arg == 2
-
- # Next test invalid choice
- with pytest.raises(SystemExit):
- args = parser.parse_args(['3'])
-
- # Confirm error text contains correct value type of int
- _out, err = capsys.readouterr()
- assert 'invalid choice: 3 (choose from 1, 2)' in err
diff --git a/tests/test_argparse_subcommands.py b/tests/test_argparse_subcommands.py
index 558924d1e..968f42259 100644
--- a/tests/test_argparse_subcommands.py
+++ b/tests/test_argparse_subcommands.py
@@ -23,33 +23,31 @@ def base_foo(self, args) -> None:
def base_bar(self, args) -> None:
"""Bar subcommand of base command"""
- self._cmd.poutput(f'(({args.z}))')
+ self._cmd.poutput(f"(({args.z}))")
def base_helpless(self, args) -> None:
"""Helpless subcommand of base command"""
- self._cmd.poutput(f'(({args.z}))')
+ self._cmd.poutput(f"(({args.z}))")
# create the top-level parser for the base command
base_parser = cmd2.Cmd2ArgumentParser()
- base_subparsers = base_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND', required=True)
+ base_subparsers = base_parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND", required=True)
# create the parser for the "foo" subcommand
- parser_foo = base_subparsers.add_parser('foo', help='foo help')
- parser_foo.add_argument('-x', type=int, default=1, help='integer')
- parser_foo.add_argument('y', type=float, help='float')
+ parser_foo = base_subparsers.add_parser("foo", help="foo help")
+ parser_foo.add_argument("-x", type=int, default=1, help="integer")
+ parser_foo.add_argument("y", type=float, help="float")
parser_foo.set_defaults(func=base_foo)
# create the parser for the "bar" subcommand
- parser_bar = base_subparsers.add_parser('bar', help='bar help', aliases=['bar_1', 'bar_2'])
- parser_bar.add_argument('z', help='string')
+ parser_bar = base_subparsers.add_parser("bar", help="bar help", aliases=["bar_1", "bar_2"])
+ parser_bar.add_argument("z", help="string")
parser_bar.set_defaults(func=base_bar)
# create the parser for the "helpless" subcommand
- # This subcommand has aliases and no help text. It exists to prevent changes to set_parser_prog() which
- # use an approach which relies on action._choices_actions list. See comment in that function for more
- # details.
- parser_helpless = base_subparsers.add_parser('helpless', aliases=['helpless_1', 'helpless_2'])
- parser_helpless.add_argument('z', help='string')
+ # This subcommand has aliases and no help text.
+ parser_helpless = base_subparsers.add_parser("helpless", aliases=["helpless_1", "helpless_2"])
+ parser_helpless.add_argument("z", help="string")
parser_helpless.set_defaults(func=base_helpless)
@cmd2.with_argparser(base_parser)
@@ -66,68 +64,68 @@ def subcommand_app():
def test_subcommand_foo(subcommand_app) -> None:
- out, _err = run_cmd(subcommand_app, 'base foo -x2 5.0')
- assert out == ['10.0']
+ out, _err = run_cmd(subcommand_app, "base foo -x2 5.0")
+ assert out == ["10.0"]
def test_subcommand_bar(subcommand_app) -> None:
- out, _err = run_cmd(subcommand_app, 'base bar baz')
- assert out == ['((baz))']
+ out, _err = run_cmd(subcommand_app, "base bar baz")
+ assert out == ["((baz))"]
def test_subcommand_invalid(subcommand_app) -> None:
- _out, err = run_cmd(subcommand_app, 'base baz')
- assert err[0].startswith('Usage: base')
+ _out, err = run_cmd(subcommand_app, "base baz")
+ assert err[0].startswith("Usage: base")
assert err[1].startswith("Error: argument SUBCOMMAND: invalid choice: 'baz'")
def test_subcommand_base_help(subcommand_app) -> None:
- out, _err = run_cmd(subcommand_app, 'help base')
- assert out[0].startswith('Usage: base')
- assert out[1] == ''
- assert out[2] == 'Base command help'
+ out, _err = run_cmd(subcommand_app, "help base")
+ assert out[0].startswith("Usage: base")
+ assert out[1] == ""
+ assert out[2] == "Base command help"
def test_subcommand_help(subcommand_app) -> None:
# foo has no aliases
- out, _err = run_cmd(subcommand_app, 'help base foo')
- assert out[0].startswith('Usage: base foo')
- assert out[1] == ''
- assert out[2] == 'Positional Arguments:'
+ out, _err = run_cmd(subcommand_app, "help base foo")
+ assert out[0].startswith("Usage: base foo")
+ assert out[1] == ""
+ assert out[2] == "Positional Arguments:"
# bar has aliases (usage should never show alias name)
- out, _err = run_cmd(subcommand_app, 'help base bar')
- assert out[0].startswith('Usage: base bar')
- assert out[1] == ''
- assert out[2] == 'Positional Arguments:'
+ out, _err = run_cmd(subcommand_app, "help base bar")
+ assert out[0].startswith("Usage: base bar")
+ assert out[1] == ""
+ assert out[2] == "Positional Arguments:"
- out, _err = run_cmd(subcommand_app, 'help base bar_1')
- assert out[0].startswith('Usage: base bar')
- assert out[1] == ''
- assert out[2] == 'Positional Arguments:'
+ out, _err = run_cmd(subcommand_app, "help base bar_1")
+ assert out[0].startswith("Usage: base bar")
+ assert out[1] == ""
+ assert out[2] == "Positional Arguments:"
- out, _err = run_cmd(subcommand_app, 'help base bar_2')
- assert out[0].startswith('Usage: base bar')
- assert out[1] == ''
- assert out[2] == 'Positional Arguments:'
+ out, _err = run_cmd(subcommand_app, "help base bar_2")
+ assert out[0].startswith("Usage: base bar")
+ assert out[1] == ""
+ assert out[2] == "Positional Arguments:"
# helpless has aliases and no help text (usage should never show alias name)
- out, _err = run_cmd(subcommand_app, 'help base helpless')
- assert out[0].startswith('Usage: base helpless')
- assert out[1] == ''
- assert out[2] == 'Positional Arguments:'
+ out, _err = run_cmd(subcommand_app, "help base helpless")
+ assert out[0].startswith("Usage: base helpless")
+ assert out[1] == ""
+ assert out[2] == "Positional Arguments:"
- out, _err = run_cmd(subcommand_app, 'help base helpless_1')
- assert out[0].startswith('Usage: base helpless')
- assert out[1] == ''
- assert out[2] == 'Positional Arguments:'
+ out, _err = run_cmd(subcommand_app, "help base helpless_1")
+ assert out[0].startswith("Usage: base helpless")
+ assert out[1] == ""
+ assert out[2] == "Positional Arguments:"
- out, _err = run_cmd(subcommand_app, 'help base helpless_2')
- assert out[0].startswith('Usage: base helpless')
- assert out[1] == ''
- assert out[2] == 'Positional Arguments:'
+ out, _err = run_cmd(subcommand_app, "help base helpless_2")
+ assert out[0].startswith("Usage: base helpless")
+ assert out[1] == ""
+ assert out[2] == "Positional Arguments:"
def test_subcommand_invalid_help(subcommand_app) -> None:
- out, _err = run_cmd(subcommand_app, 'help base baz')
- assert out[0].startswith('Usage: base')
+ out, _err = run_cmd(subcommand_app, "help base baz")
+ assert out[0].startswith("Usage: base")
diff --git a/tests/test_argparse_utils.py b/tests/test_argparse_utils.py
new file mode 100644
index 000000000..3be2263f4
--- /dev/null
+++ b/tests/test_argparse_utils.py
@@ -0,0 +1,614 @@
+"""Unit/functional testing for argparse customizations in cmd2"""
+
+import argparse
+import sys
+
+import pytest
+
+import cmd2
+from cmd2 import (
+ Choices,
+ Cmd2ArgumentParser,
+ argparse_utils,
+ constants,
+)
+from cmd2.argparse_utils import (
+ build_range_error,
+ register_argparse_argument_parameter,
+)
+
+from .conftest import run_cmd
+
+
+class ApCustomTestApp(cmd2.Cmd):
+ """Test app for cmd2's argparse customization"""
+
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+
+ range_parser = Cmd2ArgumentParser()
+ range_parser.add_argument("--arg0", nargs=1)
+ range_parser.add_argument("--arg1", nargs=2)
+ range_parser.add_argument("--arg2", nargs=(3,))
+ range_parser.add_argument("--arg3", nargs=(2, 3))
+
+ @cmd2.with_argparser(range_parser)
+ def do_range(self, _) -> None:
+ pass
+
+
+@pytest.fixture
+def cust_app():
+ return ApCustomTestApp()
+
+
+def fake_func() -> None:
+ pass
+
+
+@pytest.mark.parametrize(
+ ("kwargs", "is_valid"),
+ [
+ ({"choices_provider": fake_func}, True),
+ ({"completer": fake_func}, True),
+ ({"choices_provider": fake_func, "completer": fake_func}, False),
+ ],
+)
+def test_apcustom_completion_callable_count(kwargs, is_valid) -> None:
+ parser = Cmd2ArgumentParser()
+ if is_valid:
+ parser.add_argument("name", **kwargs)
+ else:
+ expected_err = "Only one of the following parameters"
+ with pytest.raises(ValueError, match=expected_err):
+ parser.add_argument("name", **kwargs)
+
+
+@pytest.mark.parametrize("kwargs", [({"choices_provider": fake_func}), ({"completer": fake_func})])
+def test_apcustom_no_completion_callable_alongside_choices(kwargs) -> None:
+ parser = Cmd2ArgumentParser()
+
+ expected_err = "None of the following parameters can be used alongside a choices parameter"
+ with pytest.raises(ValueError, match=expected_err):
+ parser.add_argument("name", choices=["my", "choices", "list"], **kwargs)
+
+
+@pytest.mark.parametrize("kwargs", [({"choices_provider": fake_func}), ({"completer": fake_func})])
+def test_apcustom_no_completion_callable_when_nargs_is_0(kwargs) -> None:
+ parser = Cmd2ArgumentParser()
+
+ expected_err = "None of the following parameters can be used on an action that takes no arguments"
+ with pytest.raises(ValueError, match=expected_err):
+ parser.add_argument("--name", action="store_true", **kwargs)
+
+
+def test_apcustom_usage() -> None:
+ usage = "A custom usage statement"
+ parser = Cmd2ArgumentParser(usage=usage)
+ assert usage in parser.format_help()
+
+
+def test_apcustom_nargs_help_format(cust_app) -> None:
+ out, _err = run_cmd(cust_app, "help range")
+ assert "Usage: range [-h] [--arg0 ARG0] [--arg1 ARG1{2}] [--arg2 ARG2{3+}]" in out[0]
+ assert " [--arg3 ARG3{2..3}]" in out[1]
+
+
+def test_apcustom_nargs_range_validation(cust_app) -> None:
+ # nargs = (3,) # noqa: ERA001
+ _out, err = run_cmd(cust_app, "range --arg2 one two")
+ assert "Error: argument --arg2: expected at least 3 arguments" in err[2]
+
+ _out, err = run_cmd(cust_app, "range --arg2 one two three")
+ assert not err
+
+ _out, err = run_cmd(cust_app, "range --arg2 one two three four")
+ assert not err
+
+ # nargs = (2,3) # noqa: ERA001
+ _out, err = run_cmd(cust_app, "range --arg3 one")
+ assert "Error: argument --arg3: expected 2 to 3 arguments" in err[2]
+
+ _out, err = run_cmd(cust_app, "range --arg3 one two")
+ assert not err
+
+ _out, err = run_cmd(cust_app, "range --arg2 one two three")
+ assert not err
+
+
+@pytest.mark.parametrize(
+ ("nargs", "expected_parts"),
+ [
+ # arg{2}
+ (
+ 2,
+ [("arg", True), ("{2}", False)],
+ ),
+ # arg{2+}
+ (
+ (2,),
+ [("arg", True), ("{2+}", False)],
+ ),
+ # arg{0..5}
+ (
+ (0, 5),
+ [("arg", True), ("{0..5}", False)],
+ ),
+ ],
+)
+def test_rich_metavar_parts(
+ nargs: int | tuple[int, int | float],
+ expected_parts: list[tuple[str, bool]],
+) -> None:
+ """
+ Test cmd2's override of _rich_metavar_parts which handles custom nargs formats.
+
+ :param nargs: the arguments nargs value
+ :param expected_parts: list to compare to _rich_metavar_parts's return value
+
+ Each element in this list is a 2-item tuple.
+ item 1: one part of the argument string outputted by _format_args
+ item 2: boolean stating whether rich-argparse should color this part
+ """
+ parser = Cmd2ArgumentParser()
+ help_formatter = parser._get_formatter()
+
+ action = parser.add_argument("arg", nargs=nargs) # type: ignore[arg-type]
+ default_metavar = help_formatter._get_default_metavar_for_positional(action)
+
+ parts = help_formatter._rich_metavar_parts(action, default_metavar)
+ assert list(parts) == expected_parts
+
+
+@pytest.mark.parametrize(
+ "nargs_tuple",
+ [
+ (),
+ ("f", 5),
+ (5, "f"),
+ (1, 2, 3),
+ ],
+)
+def test_apcustom_narg_invalid_tuples(nargs_tuple) -> None:
+ parser = Cmd2ArgumentParser()
+ expected_err = "Ranged values for nargs must be a tuple of 1 or 2 integers"
+ with pytest.raises(ValueError, match=expected_err):
+ parser.add_argument("invalid_tuple", nargs=nargs_tuple)
+
+
+def test_apcustom_narg_tuple_order() -> None:
+ parser = Cmd2ArgumentParser()
+ expected_err = "Invalid nargs range. The first value must be less than the second"
+ with pytest.raises(ValueError, match=expected_err):
+ parser.add_argument("invalid_tuple", nargs=(2, 1))
+
+
+def test_apcustom_narg_tuple_negative() -> None:
+ parser = Cmd2ArgumentParser()
+ expected_err = "Negative numbers are invalid for nargs range"
+ with pytest.raises(ValueError, match=expected_err):
+ parser.add_argument("invalid_tuple", nargs=(-1, 1))
+
+
+def test_apcustom_narg_tuple_zero_base() -> None:
+ parser = Cmd2ArgumentParser()
+ arg = parser.add_argument("arg", nargs=(0,))
+ assert arg.nargs == argparse.ZERO_OR_MORE
+ assert arg.get_nargs_range() is None
+ assert "[arg ...]" in parser.format_help()
+
+ parser = Cmd2ArgumentParser()
+ arg = parser.add_argument("arg", nargs=(0, 1))
+ assert arg.nargs == argparse.OPTIONAL
+ assert arg.get_nargs_range() is None
+ assert "[arg]" in parser.format_help()
+
+ parser = Cmd2ArgumentParser()
+ arg = parser.add_argument("arg", nargs=(0, 3))
+ assert arg.nargs == argparse.ZERO_OR_MORE
+ assert arg.get_nargs_range() == (0, 3)
+ assert "arg{0..3}" in parser.format_help()
+
+
+def test_apcustom_narg_tuple_one_base() -> None:
+ parser = Cmd2ArgumentParser()
+ arg = parser.add_argument("arg", nargs=(1,))
+ assert arg.nargs == argparse.ONE_OR_MORE
+ assert arg.get_nargs_range() is None
+ assert "arg [arg ...]" in parser.format_help()
+
+ parser = Cmd2ArgumentParser()
+ arg = parser.add_argument("arg", nargs=(1, 5))
+ assert arg.nargs == argparse.ONE_OR_MORE
+ assert arg.get_nargs_range() == (1, 5)
+ assert "arg{1..5}" in parser.format_help()
+
+
+def test_apcustom_narg_tuple_other_ranges() -> None:
+ # Test range with no upper bound on max
+ parser = Cmd2ArgumentParser()
+ arg = parser.add_argument("arg", nargs=(2,))
+ assert arg.nargs == argparse.ONE_OR_MORE
+ assert arg.get_nargs_range() == (2, constants.INFINITY)
+
+ # Test finite range
+ parser = Cmd2ArgumentParser()
+ arg = parser.add_argument("arg", nargs=(2, 5))
+ assert arg.nargs == argparse.ONE_OR_MORE
+ assert arg.get_nargs_range() == (2, 5)
+
+
+def test_apcustom_print_message(capsys) -> None:
+ test_message = "The test message"
+
+ # Specify the file
+ parser = Cmd2ArgumentParser()
+ parser._print_message(test_message, file=sys.stdout)
+ out, err = capsys.readouterr()
+ assert test_message in out
+
+ # Make sure file defaults to sys.stderr
+ parser = Cmd2ArgumentParser()
+ parser._print_message(test_message)
+ out, err = capsys.readouterr()
+ assert test_message in err
+
+
+def test_build_range_error() -> None:
+ # max is INFINITY
+ err_msg = build_range_error(1, constants.INFINITY)
+ assert err_msg == "expected at least 1 argument"
+
+ err_msg = build_range_error(2, constants.INFINITY)
+ assert err_msg == "expected at least 2 arguments"
+
+ # min and max are equal
+ err_msg = build_range_error(1, 1)
+ assert err_msg == "expected 1 argument"
+
+ err_msg = build_range_error(2, 2)
+ assert err_msg == "expected 2 arguments"
+
+ # min and max are not equal
+ err_msg = build_range_error(0, 1)
+ assert err_msg == "expected 0 to 1 argument"
+
+ err_msg = build_range_error(0, 2)
+ assert err_msg == "expected 0 to 2 arguments"
+
+
+def test_apcustom_metavar_tuple() -> None:
+ # Test the case when a tuple metavar is used with nargs an integer > 1
+ parser = Cmd2ArgumentParser()
+ parser.add_argument("--aflag", nargs=2, metavar=("foo", "bar"), help="This is a test")
+ assert "[--aflag foo bar]" in parser.format_help()
+
+
+def test_register_argparse_argument_parameter() -> None:
+ # Test successful registration
+ param_name = "test_unique_param"
+ register_argparse_argument_parameter(param_name)
+
+ assert param_name in argparse_utils._CUSTOM_ACTION_ATTRIBS
+ assert hasattr(argparse.Action, f"get_{param_name}")
+ assert hasattr(argparse.Action, f"set_{param_name}")
+
+ # Test duplicate registration
+ expected_err = "already registered"
+ with pytest.raises(KeyError, match=expected_err):
+ register_argparse_argument_parameter(param_name)
+
+ # Test invalid identifier
+ expected_err = "must be a valid Python identifier"
+ with pytest.raises(ValueError, match=expected_err):
+ register_argparse_argument_parameter("invalid name")
+
+ # Test collision with standard argparse.Action attribute
+ expected_err = "conflicts with an existing attribute on argparse.Action"
+ with pytest.raises(KeyError, match=expected_err):
+ register_argparse_argument_parameter("format_usage")
+
+ # Test collision with existing accessor methods
+ try:
+ argparse.Action.get_colliding_param = lambda self: None
+ expected_err = "Accessor methods for 'colliding_param' already exist on argparse.Action"
+ with pytest.raises(KeyError, match=expected_err):
+ register_argparse_argument_parameter("colliding_param")
+ finally:
+ delattr(argparse.Action, "get_colliding_param")
+
+ # Test collision with internal attribute
+ try:
+ attr_name = constants.cmd2_private_attr_name("internal_collision")
+ setattr(argparse.Action, attr_name, None)
+ expected_err = f"The internal attribute '{attr_name}' already exists on argparse.Action"
+ with pytest.raises(KeyError, match=expected_err):
+ register_argparse_argument_parameter("internal_collision")
+ finally:
+ delattr(argparse.Action, attr_name)
+
+
+def test_subcommand_attachment() -> None:
+ """Test Cmd2ArgumentParser convenience methods for attaching and detaching subcommands."""
+
+ ###############################
+ # Set up parsers
+ ###############################
+ root_parser = Cmd2ArgumentParser(prog="root", description="root command")
+ root_subparsers = root_parser.add_subparsers()
+
+ child_parser = Cmd2ArgumentParser(prog="child", description="child command")
+ child_subparsers = child_parser.add_subparsers() # Must have subparsers to host grandchild
+
+ grandchild_parser = Cmd2ArgumentParser(prog="grandchild", description="grandchild command")
+
+ ###############################
+ # Attach subcommands
+ ###############################
+
+ # Attach child to root
+ root_parser.attach_subcommand(
+ [],
+ "child",
+ child_parser,
+ help="a child command",
+ aliases=["child_alias"],
+ )
+
+ # Attach grandchild to child
+ root_parser.attach_subcommand(
+ ["child"],
+ "grandchild",
+ grandchild_parser,
+ help="a grandchild command",
+ )
+
+ ###############################
+ # Verify hierarchy navigation
+ ###############################
+
+ assert root_parser.find_parser(["child", "grandchild"]) is grandchild_parser
+ assert root_parser.find_parser(["child"]) is child_parser
+ assert root_parser.find_parser([]) is root_parser
+
+ ###############################
+ # Verify attachments
+ ###############################
+
+ # Verify child attachment and aliases
+ assert root_subparsers._name_parser_map["child"] is child_parser
+ assert root_subparsers._name_parser_map["child_alias"] is child_parser
+
+ # Verify grandchild attachment
+ assert child_subparsers._name_parser_map["grandchild"] is grandchild_parser
+
+ # Verify help entries are present
+ assert any(action.dest == "child" for action in root_subparsers._choices_actions)
+ assert any(action.dest == "grandchild" for action in child_subparsers._choices_actions)
+
+ ###############################
+ # Detach subcommands
+ ###############################
+
+ # Detach grandchild from child
+ detached_grandchild = root_parser.detach_subcommand(["child"], "grandchild")
+ assert detached_grandchild is grandchild_parser
+ assert "grandchild" not in child_subparsers._name_parser_map
+ assert not any(action.dest == "grandchild" for action in child_subparsers._choices_actions)
+
+ # Detach child from root
+ detached_child = root_parser.detach_subcommand([], "child")
+ assert detached_child is child_parser
+ assert "child" not in root_subparsers._name_parser_map
+ assert "child_alias" not in root_subparsers._name_parser_map
+ assert not any(action.dest == "child" for action in root_subparsers._choices_actions)
+
+
+def test_detach_subcommand_by_alias() -> None:
+ """Test detaching a subcommand using one of its alias names."""
+ root_parser = Cmd2ArgumentParser(prog="root")
+ root_subparsers = root_parser.add_subparsers()
+
+ child_parser = Cmd2ArgumentParser(prog="child")
+ root_parser.attach_subcommand(
+ [],
+ "child",
+ child_parser,
+ help="a child command",
+ aliases=["alias1", "alias2"],
+ )
+
+ # Verify all names map to the parser
+ assert root_subparsers._name_parser_map["child"] is child_parser
+ assert root_subparsers._name_parser_map["alias1"] is child_parser
+ assert root_subparsers._name_parser_map["alias2"] is child_parser
+
+ # Verify help entry is present
+ assert any(action.dest == "child" for action in root_subparsers._choices_actions)
+
+ # Detach using an alias
+ detached = root_parser.detach_subcommand([], "alias1")
+ assert detached is child_parser
+
+ # Verify all names are gone
+ assert "child" not in root_subparsers._name_parser_map
+ assert "alias1" not in root_subparsers._name_parser_map
+ assert "alias2" not in root_subparsers._name_parser_map
+
+ # Verify help entry is gone
+ assert not any(action.dest == "child" for action in root_subparsers._choices_actions)
+
+
+def test_subcommand_attachment_errors() -> None:
+ root_parser = Cmd2ArgumentParser(prog="root", description="root command")
+ child_parser = Cmd2ArgumentParser(prog="child", description="child command")
+
+ # Verify ValueError when subcommands are not supported
+ with pytest.raises(ValueError, match="Command 'root' does not support subcommands"):
+ root_parser.attach_subcommand([], "anything", child_parser)
+ with pytest.raises(ValueError, match="Command 'root' does not support subcommands"):
+ root_parser.detach_subcommand([], "anything")
+
+ # Allow subcommands for the next tests
+ root_parser.add_subparsers()
+
+ # Verify ValueError when path is invalid (find_parser() fails)
+ with pytest.raises(ValueError, match="Subcommand 'nonexistent' does not exist for 'root'"):
+ root_parser.attach_subcommand(["nonexistent"], "anything", child_parser)
+ with pytest.raises(ValueError, match="Subcommand 'nonexistent' does not exist for 'root'"):
+ root_parser.detach_subcommand(["nonexistent"], "anything")
+
+ # Verify ValueError when path is valid but subcommand name is wrong
+ with pytest.raises(ValueError, match="Subcommand 'fake' does not exist for 'root'"):
+ root_parser.detach_subcommand([], "fake")
+
+ # Verify TypeError when attaching a non-Cmd2ArgumentParser type
+ ap_parser = argparse.ArgumentParser(prog="non-cmd2-parser")
+ with pytest.raises(TypeError, match=r"must be an instance of 'Cmd2ArgumentParser' \(or a subclass\)"):
+ root_parser.attach_subcommand([], "sub", ap_parser) # type: ignore[arg-type]
+
+ # Verify ValueError when subcommand name already exists
+ sub_parser = Cmd2ArgumentParser(prog="sub")
+ root_parser.attach_subcommand([], "sub", sub_parser)
+ with pytest.raises(ValueError, match="Subcommand 'sub' already exists for 'root'"):
+ root_parser.attach_subcommand([], "sub", sub_parser)
+
+
+def test_subcommand_attachment_parser_class_override() -> None:
+ class MyParser(Cmd2ArgumentParser):
+ pass
+
+ class MySubParser(MyParser):
+ pass
+
+ root_parser = Cmd2ArgumentParser(prog="root")
+
+ # Explicitly override parser_class for this subparsers action
+ root_parser.add_subparsers(parser_class=MyParser)
+
+ # Attaching a MyParser instance should succeed
+ my_parser = MyParser(prog="sub")
+ root_parser.attach_subcommand([], "sub", my_parser)
+
+ # Attaching a MySubParser instance should also succeed (isinstance check)
+ my_sub_parser = MySubParser(prog="sub2")
+ root_parser.attach_subcommand([], "sub2", my_sub_parser)
+
+ # Attaching a standard Cmd2ArgumentParser instance should fail
+ standard_parser = Cmd2ArgumentParser(prog="standard")
+ with pytest.raises(TypeError, match=r"must be an instance of 'MyParser' \(or a subclass\)"):
+ root_parser.attach_subcommand([], "fail", standard_parser)
+
+
+def test_completion_items_as_choices(capsys) -> None:
+ """Test cmd2's patch to Argparse._check_value() which supports CompletionItems as choices.
+ Choices are compared to CompletionItems.orig_value instead of the CompletionItem instance.
+ """
+
+ ##############################################################
+ # Test CompletionItems with str values
+ ##############################################################
+ choices = Choices.from_values(["1", "2"])
+ parser = Cmd2ArgumentParser()
+ parser.add_argument("choices_arg", type=str, choices=choices)
+
+ # First test valid choices. Confirm the parsed data matches the correct type of str.
+ args = parser.parse_args(["1"])
+ assert args.choices_arg == "1"
+
+ args = parser.parse_args(["2"])
+ assert args.choices_arg == "2"
+
+ # Next test invalid choice
+ with pytest.raises(SystemExit):
+ args = parser.parse_args(["3"])
+
+ # Confirm error text contains correct value type of str
+ _out, err = capsys.readouterr()
+ assert "invalid choice: '3' (choose from '1', '2')" in err
+
+ ##############################################################
+ # Test CompletionItems with int values
+ ##############################################################
+ choices = Choices.from_values([1, 2])
+ parser = Cmd2ArgumentParser()
+ parser.add_argument("choices_arg", type=int, choices=choices)
+
+ # First test valid choices. Confirm the parsed data matches the correct type of int.
+ args = parser.parse_args(["1"])
+ assert args.choices_arg == 1
+
+ args = parser.parse_args(["2"])
+ assert args.choices_arg == 2
+
+ # Next test invalid choice
+ with pytest.raises(SystemExit):
+ args = parser.parse_args(["3"])
+
+ # Confirm error text contains correct value type of int
+ _out, err = capsys.readouterr()
+ assert "invalid choice: 3 (choose from 1, 2)" in err
+
+
+def test_update_prog() -> None:
+ """Test Cmd2ArgumentParser.update_prog() across various scenarios."""
+
+ # Set up a complex parser hierarchy
+ old_app = "old_app"
+ root = Cmd2ArgumentParser(prog=old_app)
+
+ # Positionals before subcommand
+ root.add_argument("pos1")
+
+ # Mutually exclusive group with positionals
+ group = root.add_mutually_exclusive_group(required=True)
+ group.add_argument("posA", nargs="?")
+ group.add_argument("posB", nargs="?")
+
+ # Subparsers with aliases and no help text
+ root_subparsers = root.add_subparsers(dest="cmd")
+
+ # Subcommand with aliases
+ sub1 = root_subparsers.add_parser("sub1", aliases=["s1", "alias1"], help="help for sub1")
+
+ # Subcommand with no help text
+ sub2 = root_subparsers.add_parser("sub2")
+
+ # Nested subparser
+ sub2.add_argument("inner_pos")
+ sub2_subparsers = sub2.add_subparsers(dest="sub2_cmd")
+ leaf = sub2_subparsers.add_parser("leaf", help="leaf help")
+
+ # Save initial prog values
+ orig_root_prog = root.prog
+ orig_sub1_prog = sub1.prog
+ orig_sub2_prog = sub2.prog
+ orig_leaf_prog = leaf.prog
+
+ # Perform update
+ new_app = "new_app"
+ root.update_prog(new_app)
+
+ # Verify updated prog values
+ assert root.prog.startswith(new_app)
+ assert root.prog == orig_root_prog.replace(old_app, new_app, 1)
+
+ assert sub1.prog.startswith(new_app)
+ assert sub1.prog == orig_sub1_prog.replace(old_app, new_app, 1)
+
+ assert sub2.prog.startswith(new_app)
+ assert sub2.prog == orig_sub2_prog.replace(old_app, new_app, 1)
+
+ assert leaf.prog.startswith(new_app)
+ assert leaf.prog == orig_leaf_prog.replace(old_app, new_app, 1)
+
+ # Verify that action._prog_prefix was updated by adding a new subparser
+ sub3 = root_subparsers.add_parser("sub3")
+ assert sub3.prog.startswith(new_app)
+ assert sub3.prog == root_subparsers._prog_prefix + " sub3"
+
+ # Verify aliases still point to the correct parser
+ for action in root._actions:
+ if isinstance(action, argparse._SubParsersAction):
+ assert action.choices["s1"].prog == sub1.prog
+ assert action.choices["alias1"].prog == sub1.prog
diff --git a/tests/test_categories.py b/tests/test_categories.py
index 8150c5e7d..eaf05641b 100644
--- a/tests/test_categories.py
+++ b/tests/test_categories.py
@@ -1,109 +1,130 @@
-"""Simple example demonstrating basic CommandSet usage."""
+"""Tests help categories for Cmd and CommandSet objects."""
-from typing import Any
+import argparse
import cmd2
from cmd2 import (
+ Cmd2ArgumentParser,
CommandSet,
- with_default_category,
+ with_argparser,
+ with_category,
)
-@with_default_category('Default Category')
-class MyBaseCommandSet(CommandSet):
- """Defines a default category for all sub-class CommandSets"""
-
- def __init__(self, _: Any) -> None:
- super().__init__()
+class NoCategoryCmd(cmd2.Cmd):
+ """Example to demonstrate a Cmd-based class which does not define its own DEFAULT_CATEGORY.
+ Its commands will inherit the parent class's DEFAULT_CATEGORY.
+ """
-class ChildInheritsParentCategories(MyBaseCommandSet):
- """This subclass doesn't declare any categories so all commands here are also categorized under 'Default Category'"""
+ def do_inherit(self, _: cmd2.Statement) -> None:
+ """This function has a docstring.
- def do_hello(self, _: cmd2.Statement) -> None:
- self._cmd.poutput('Hello')
+ Since this class does NOT define its own DEFAULT_CATEGORY,
+ this command will show in cmd2.Cmd.DEFAULT_CATEGORY
+ """
- def do_world(self, _: cmd2.Statement) -> None:
- self._cmd.poutput('World')
+class CategoryCmd(cmd2.Cmd):
+ """Example to demonstrate custom DEFAULT_CATEGORY in a Cmd-based class.
-@with_default_category('Non-Heritable Category', heritable=False)
-class ChildOverridesParentCategoriesNonHeritable(MyBaseCommandSet):
- """This subclass overrides the 'Default Category' from the parent, but in a non-heritable fashion. Sub-classes of this
- CommandSet will not inherit this category and will, instead, inherit 'Default Category'
+ It also includes functions to fully exercise Cmd._build_command_info.
"""
- def do_goodbye(self, _: cmd2.Statement) -> None:
- self._cmd.poutput('Goodbye')
+ DEFAULT_CATEGORY = "CategoryCmd Commands"
+ def do_cmd_command(self, _: cmd2.Statement) -> None:
+ """The cmd command.
-class GrandchildInheritsGrandparentCategory(ChildOverridesParentCategoriesNonHeritable):
- """This subclass's parent class declared its default category non-heritable. Instead, it inherits the category defined
- by the grandparent class.
- """
+ Since this class DOES define its own DEFAULT_CATEGORY,
+ this command will show in CategoryCmd.DEFAULT_CATEGORY
+ """
- def do_aloha(self, _: cmd2.Statement) -> None:
- self._cmd.poutput('Aloha')
+ @with_argparser(Cmd2ArgumentParser(description="Overridden quit command"))
+ def do_quit(self, _: argparse.Namespace) -> None:
+ """This function overrides the cmd2.Cmd quit command.
+ Since this override does not use the with_category decorator,
+ it will be in CategoryCmd.DEFAULT_CATEGORY and not cmd2.Cmd.DEFAULT_CATEGORY.
+ """
-@with_default_category('Heritable Category')
-class ChildOverridesParentCategories(MyBaseCommandSet):
- """This subclass is decorated with a default category that is heritable. This overrides the parent class's default
- category declaration.
- """
+ @with_category(cmd2.Cmd.DEFAULT_CATEGORY)
+ @with_argparser(Cmd2ArgumentParser(description="Overridden shortcuts command"))
+ def do_shortcuts(self, _: argparse.Namespace) -> None:
+ """This function overrides the cmd2.Cmd shortcut command.
- def do_bonjour(self, _: cmd2.Statement) -> None:
- self._cmd.poutput('Bonjour')
+ It also uses the with_category decorator to keep shortcuts in
+ cmd2.Cmd.DEFAULT_CATEGORY for the parent class.
+ """
+ def do_has_help_func(self, _: cmd2.Statement) -> None:
+ """This command has a help function."""
-class GrandchildInheritsHeritable(ChildOverridesParentCategories):
- """This subclass's parent declares a default category that overrides its parent. As a result, commands in this
- CommandSet will be categorized under 'Heritable Category'
- """
+ def help_has_help_func(self) -> None:
+ """Help function for the has_help_func command."""
+ self.poutput("has_help_func help text.")
+
+ def help_coding(self) -> None:
+ """This help function not tied to a command.
+
+ It will be in help topics.
+ """
+ self.poutput("Read a book.")
- def do_monde(self, _: cmd2.Statement) -> None:
- self._cmd.poutput('Monde')
+def test_no_category_cmd() -> None:
+ app = NoCategoryCmd()
+ cmds_cats, _help_topics = app._build_command_info()
+ assert "inherit" in cmds_cats[cmd2.Cmd.DEFAULT_CATEGORY]
+
+
+def test_category_cmd() -> None:
+ app = CategoryCmd()
+ cmds_cats, help_topics = app._build_command_info()
+
+ assert "cmd_command" in cmds_cats[CategoryCmd.DEFAULT_CATEGORY]
+ assert "quit" in cmds_cats[CategoryCmd.DEFAULT_CATEGORY]
+ assert "shortcuts" in cmds_cats[cmd2.Cmd.DEFAULT_CATEGORY]
+ assert "has_help_func" in cmds_cats[CategoryCmd.DEFAULT_CATEGORY]
+ assert "coding" in help_topics
+
+
+class NoCategoryCommandSet(CommandSet[cmd2.Cmd]):
+ """Example to demonstrate a CommandSet which does not define its own DEFAULT_CATEGORY.
+
+ Its commands will inherit the parent class's DEFAULT_CATEGORY.
+ """
-class ExampleApp(cmd2.Cmd):
- """Example to demonstrate heritable default categories"""
+ def do_inherit(self, _: cmd2.Statement) -> None:
+ """This function has a docstring.
- def __init__(self) -> None:
- super().__init__(auto_load_commands=False)
+ Since this class does NOT define its own DEFAULT_CATEGORY,
+ this command will show in CommandSet.DEFAULT_CATEGORY
+ """
- def do_something(self, arg) -> None:
- self.poutput('this is the something command')
+class CategoryCommandSet(CommandSet[cmd2.Cmd]):
+ """Example to demonstrate custom DEFAULT_CATEGORY in a CommandSet."""
-def test_heritable_categories() -> None:
- app = ExampleApp()
+ DEFAULT_CATEGORY = "CategoryCommandSet Commands"
- base_cs = MyBaseCommandSet(0)
- assert getattr(base_cs, cmd2.constants.CLASS_ATTR_DEFAULT_HELP_CATEGORY, None) == 'Default Category'
+ def do_cmdset_command(self, _: cmd2.Statement) -> None:
+ """The cmdset command.
- child1 = ChildInheritsParentCategories(1)
- assert getattr(child1, cmd2.constants.CLASS_ATTR_DEFAULT_HELP_CATEGORY, None) == 'Default Category'
- app.register_command_set(child1)
- assert getattr(app.cmd_func('hello').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY, None) == 'Default Category'
- app.unregister_command_set(child1)
+ Since this class DOES define its own DEFAULT_CATEGORY,
+ this command will show in CategoryCommandSet.DEFAULT_CATEGORY
+ """
- child_nonheritable = ChildOverridesParentCategoriesNonHeritable(2)
- assert getattr(child_nonheritable, cmd2.constants.CLASS_ATTR_DEFAULT_HELP_CATEGORY, None) != 'Non-Heritable Category'
- app.register_command_set(child_nonheritable)
- assert getattr(app.cmd_func('goodbye').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY, None) == 'Non-Heritable Category'
- app.unregister_command_set(child_nonheritable)
- grandchild1 = GrandchildInheritsGrandparentCategory(3)
- assert getattr(grandchild1, cmd2.constants.CLASS_ATTR_DEFAULT_HELP_CATEGORY, None) == 'Default Category'
- app.register_command_set(grandchild1)
- assert getattr(app.cmd_func('aloha').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY, None) == 'Default Category'
- app.unregister_command_set(grandchild1)
+def test_no_category_command_set() -> None:
+ app = cmd2.Cmd()
+ app.register_command_set(NoCategoryCommandSet())
+ cmds_cats, _help_topics = app._build_command_info()
+ assert "inherit" in cmds_cats[CommandSet.DEFAULT_CATEGORY]
- child_overrides = ChildOverridesParentCategories(4)
- assert getattr(child_overrides, cmd2.constants.CLASS_ATTR_DEFAULT_HELP_CATEGORY, None) == 'Heritable Category'
- app.register_command_set(child_overrides)
- assert getattr(app.cmd_func('bonjour').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY, None) == 'Heritable Category'
- app.unregister_command_set(child_overrides)
- grandchild2 = GrandchildInheritsHeritable(5)
- assert getattr(grandchild2, cmd2.constants.CLASS_ATTR_DEFAULT_HELP_CATEGORY, None) == 'Heritable Category'
+def test_category_command_set() -> None:
+ app = cmd2.Cmd()
+ app.register_command_set(CategoryCommandSet())
+ cmds_cats, _help_topics = app._build_command_info()
+ assert "cmdset_command" in cmds_cats[CategoryCommandSet.DEFAULT_CATEGORY]
diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py
index 62c1569b1..d0a52c964 100644
--- a/tests/test_cmd2.py
+++ b/tests/test_cmd2.py
@@ -1,18 +1,23 @@
"""Cmd2 unit/functional testing"""
-import builtins
import io
import os
import signal
import sys
import tempfile
-from code import (
- InteractiveConsole,
+from code import InteractiveConsole
+from typing import (
+ NoReturn,
+ cast,
)
-from typing import NoReturn
from unittest import mock
import pytest
+from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
+from prompt_toolkit.completion import DummyCompleter
+from prompt_toolkit.input import DummyInput, create_pipe_input
+from prompt_toolkit.output import DummyOutput
+from prompt_toolkit.shortcuts import PromptSession
from rich.text import Text
import cmd2
@@ -21,7 +26,7 @@
Cmd2Style,
Color,
CommandSet,
- RichPrintKwargs,
+ Completions,
clipboard,
constants,
exceptions,
@@ -31,13 +36,10 @@
)
from cmd2 import rich_utils as ru
from cmd2 import string_utils as su
-
-# This ensures gnureadline is used in macOS tests
-from cmd2.rl_utils import readline # type: ignore[atrr-defined]
+from cmd2.types import BoundCommandFunc
from .conftest import (
SHORTCUTS_TXT,
- complete_tester,
normalize,
odd_file_names,
run_cmd,
@@ -67,7 +69,7 @@ def test_not_in_main_thread(base_app, capsys) -> None:
# Mock threading.main_thread() to return our fake thread
saved_main_thread = threading.main_thread
fake_main = threading.Thread()
- threading.main_thread = mock.MagicMock(name='main_thread', return_value=fake_main)
+ threading.main_thread = mock.MagicMock(name="main_thread", return_value=fake_main)
with pytest.raises(RuntimeError) as excinfo:
base_app.cmdloop()
@@ -78,19 +80,19 @@ def test_not_in_main_thread(base_app, capsys) -> None:
def test_empty_statement(base_app) -> None:
- out, _err = run_cmd(base_app, '')
- expected = normalize('')
+ out, _err = run_cmd(base_app, "")
+ expected = normalize("")
assert out == expected
def test_base_help(base_app) -> None:
- out, _err = run_cmd(base_app, 'help')
+ out, _err = run_cmd(base_app, "help")
assert base_app.last_result is True
verify_help_text(base_app, out)
def test_base_help_verbose(base_app) -> None:
- out, _err = run_cmd(base_app, 'help -v')
+ out, _err = run_cmd(base_app, "help -v")
assert base_app.last_result is True
verify_help_text(base_app, out)
@@ -99,31 +101,31 @@ def test_base_help_verbose(base_app) -> None:
help_doc += "\n:param fake param"
base_app.do_help.__func__.__doc__ = help_doc
- out, _err = run_cmd(base_app, 'help --verbose')
+ out, _err = run_cmd(base_app, "help --verbose")
assert base_app.last_result is True
verify_help_text(base_app, out)
- assert ':param' not in ''.join(out)
+ assert ":param" not in "".join(out)
def test_base_argparse_help(base_app) -> None:
# Verify that "set -h" gives the same output as "help set" and that it starts in a way that makes sense
- out1, _err1 = run_cmd(base_app, 'set -h')
- out2, _err2 = run_cmd(base_app, 'help set')
+ out1, _err1 = run_cmd(base_app, "set -h")
+ out2, _err2 = run_cmd(base_app, "help set")
assert out1 == out2
- assert out1[0].startswith('Usage: set')
- assert out1[1] == ''
- assert out1[2].startswith('Set a settable parameter')
+ assert out1[0].startswith("Usage: set")
+ assert out1[1] == ""
+ assert out1[2].startswith("Set a settable parameter")
def test_base_invalid_option(base_app) -> None:
- _out, err = run_cmd(base_app, 'set -z')
- assert err[0] == 'Usage: set [-h] [param] [value]'
- assert 'Error: unrecognized arguments: -z' in err[1]
+ _out, err = run_cmd(base_app, "set -z")
+ assert err[0] == "Usage: set [-h] [param] [value]"
+ assert "Error: unrecognized arguments: -z" in err[1]
def test_base_shortcuts(base_app) -> None:
- out, _err = run_cmd(base_app, 'shortcuts')
+ out, _err = run_cmd(base_app, "shortcuts")
expected = normalize(SHORTCUTS_TXT)
assert out == expected
assert base_app.last_result is True
@@ -132,12 +134,12 @@ def test_base_shortcuts(base_app) -> None:
def test_command_starts_with_shortcut() -> None:
expected_err = "Invalid command name 'help'"
with pytest.raises(ValueError, match=expected_err):
- cmd2.Cmd(shortcuts={'help': 'fake'})
+ cmd2.Cmd(shortcuts={"help": "fake"})
def test_base_set(base_app) -> None:
# Make sure all settables appear in output.
- out, _err = run_cmd(base_app, 'set')
+ out, _err = run_cmd(base_app, "set")
settables = sorted(base_app.settables.keys())
# The settables will appear in order in the table.
@@ -158,7 +160,7 @@ def test_base_set(base_app) -> None:
def test_set(base_app) -> None:
- out, _err = run_cmd(base_app, 'set quiet True')
+ out, _err = run_cmd(base_app, "set quiet True")
expected = normalize(
"""
quiet - was: False
@@ -169,7 +171,7 @@ def test_set(base_app) -> None:
assert base_app.last_result is True
line_found = False
- out, _err = run_cmd(base_app, 'set quiet')
+ out, _err = run_cmd(base_app, "set quiet")
for line in out:
if "quiet" in line and "True" in line and "False" not in line:
line_found = True
@@ -177,25 +179,25 @@ def test_set(base_app) -> None:
assert line_found
assert len(base_app.last_result) == 1
- assert base_app.last_result['quiet'] is True
+ assert base_app.last_result["quiet"] is True
def test_set_val_empty(base_app) -> None:
base_app.editor = "fake"
_out, _err = run_cmd(base_app, 'set editor ""')
- assert base_app.editor == ''
+ assert base_app.editor == ""
assert base_app.last_result is True
def test_set_val_is_flag(base_app) -> None:
base_app.editor = "fake"
_out, _err = run_cmd(base_app, 'set editor "-h"')
- assert base_app.editor == '-h'
+ assert base_app.editor == "-h"
assert base_app.last_result is True
def test_set_not_supported(base_app) -> None:
- _out, err = run_cmd(base_app, 'set qqq True')
+ _out, err = run_cmd(base_app, "set qqq True")
expected = normalize(
"""
Parameter 'qqq' not supported (type 'set' for list of parameters).
@@ -207,28 +209,28 @@ def test_set_not_supported(base_app) -> None:
def test_set_no_settables(base_app) -> None:
base_app._settables.clear()
- _out, err = run_cmd(base_app, 'set quiet True')
+ _out, err = run_cmd(base_app, "set quiet True")
expected = normalize("There are no settable parameters")
assert err == expected
assert base_app.last_result is False
@pytest.mark.parametrize(
- ('new_val', 'is_valid', 'expected'),
+ ("new_val", "is_valid", "expected"),
[
(ru.AllowStyle.NEVER, True, ru.AllowStyle.NEVER),
- ('neVeR', True, ru.AllowStyle.NEVER),
+ ("neVeR", True, ru.AllowStyle.NEVER),
(ru.AllowStyle.TERMINAL, True, ru.AllowStyle.TERMINAL),
- ('TeRMInal', True, ru.AllowStyle.TERMINAL),
+ ("TeRMInal", True, ru.AllowStyle.TERMINAL),
(ru.AllowStyle.ALWAYS, True, ru.AllowStyle.ALWAYS),
- ('AlWaYs', True, ru.AllowStyle.ALWAYS),
- ('invalid', False, ru.AllowStyle.TERMINAL),
+ ("AlWaYs", True, ru.AllowStyle.ALWAYS),
+ ("invalid", False, ru.AllowStyle.TERMINAL),
],
)
@with_ansi_style(ru.AllowStyle.TERMINAL)
def test_set_allow_style(base_app, new_val, is_valid, expected) -> None:
# Use the set command to alter allow_style
- out, err = run_cmd(base_app, f'set allow_style {new_val}')
+ out, err = run_cmd(base_app, f"set allow_style {new_val}")
assert base_app.last_result is is_valid
# Verify the results
@@ -238,21 +240,40 @@ def test_set_allow_style(base_app, new_val, is_valid, expected) -> None:
assert out
+def test_set_traceback_show_locals(base_app: cmd2.Cmd) -> None:
+ # Use the set command to alter traceback_show_locals
+
+ # Clear any existing value
+ base_app.traceback_kwargs.pop("show_locals", None)
+ assert "show_locals" not in base_app.traceback_kwargs
+
+ # Test that we receive a default value of False if not present
+ orig_val = base_app.traceback_show_locals
+ assert orig_val is False
+ assert "show_locals" not in base_app.traceback_kwargs
+
+ # Test setting it
+ new_val = not orig_val
+ run_cmd(base_app, f"set traceback_show_locals {new_val}")
+ assert base_app.traceback_show_locals is new_val
+ assert base_app.traceback_kwargs["show_locals"] is new_val
+
+
def test_set_with_choices(base_app) -> None:
"""Test choices validation of Settables"""
- fake_choices = ['valid', 'choices']
+ fake_choices = ["valid", "choices"]
base_app.fake = fake_choices[0]
- fake_settable = cmd2.Settable('fake', type(base_app.fake), "fake description", base_app, choices=fake_choices)
+ fake_settable = cmd2.Settable("fake", type(base_app.fake), "fake description", base_app, choices=fake_choices)
base_app.add_settable(fake_settable)
# Try a valid choice
- _out, err = run_cmd(base_app, f'set fake {fake_choices[1]}')
+ _out, err = run_cmd(base_app, f"set fake {fake_choices[1]}")
assert base_app.last_result is True
assert not err
# Try an invalid choice
- _out, err = run_cmd(base_app, 'set fake bad_value')
+ _out, err = run_cmd(base_app, "set fake bad_value")
assert base_app.last_result is False
assert err[0].startswith("Error setting fake: invalid choice")
@@ -260,7 +281,7 @@ def test_set_with_choices(base_app) -> None:
class OnChangeHookApp(cmd2.Cmd):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
- self.add_settable(utils.Settable('quiet', bool, "my description", self, onchange_cb=self._onchange_quiet))
+ self.add_settable(utils.Settable("quiet", bool, "my description", self, onchange_cb=self._onchange_quiet))
def _onchange_quiet(self, name, old, new) -> None:
"""Runs when quiet is changed via set command"""
@@ -273,7 +294,7 @@ def onchange_app():
def test_set_onchange_hook(onchange_app) -> None:
- out, _err = run_cmd(onchange_app, 'set quiet True')
+ out, _err = run_cmd(onchange_app, "set quiet True")
expected = normalize(
"""
You changed quiet
@@ -287,32 +308,32 @@ def test_set_onchange_hook(onchange_app) -> None:
def test_base_shell(base_app, monkeypatch) -> None:
m = mock.Mock()
- monkeypatch.setattr("{}.Popen".format('subprocess'), m)
- out, _err = run_cmd(base_app, 'shell echo a')
+ monkeypatch.setattr("{}.Popen".format("subprocess"), m)
+ out, _err = run_cmd(base_app, "shell echo a")
assert out == []
assert m.called
def test_shell_last_result(base_app) -> None:
base_app.last_result = None
- run_cmd(base_app, 'shell fake')
+ run_cmd(base_app, "shell fake")
assert base_app.last_result is not None
def test_shell_manual_call(base_app) -> None:
# Verifies crash from Issue #986 doesn't happen
cmds = ['echo "hi"', 'echo "there"', 'echo "cmd2!"']
- cmd = ';'.join(cmds)
+ cmd = ";".join(cmds)
base_app.do_shell(cmd)
- cmd = '&&'.join(cmds)
+ cmd = "&&".join(cmds)
base_app.do_shell(cmd)
def test_base_error(base_app) -> None:
- _out, err = run_cmd(base_app, 'meow')
+ _out, err = run_cmd(base_app, "meow")
assert "is not a recognized command" in err[0]
@@ -320,7 +341,7 @@ def test_base_error_suggest_command(base_app) -> None:
try:
old_suggest_similar_command = base_app.suggest_similar_command
base_app.suggest_similar_command = True
- _out, err = run_cmd(base_app, 'historic')
+ _out, err = run_cmd(base_app, "historic")
assert "history" in err[1]
finally:
base_app.suggest_similar_command = old_suggest_similar_command
@@ -328,20 +349,20 @@ def test_base_error_suggest_command(base_app) -> None:
def test_run_script(base_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
- filename = os.path.join(test_dir, 'script.txt')
+ filename = os.path.join(test_dir, "script.txt")
assert base_app._script_dir == []
assert base_app._current_script_dir is None
# Get output out the script
- script_out, script_err = run_cmd(base_app, f'run_script {filename}')
+ script_out, script_err = run_cmd(base_app, f"run_script {filename}")
assert base_app.last_result is True
assert base_app._script_dir == []
assert base_app._current_script_dir is None
# Now run the commands manually and compare their output to script's
- with open(filename, encoding='utf-8') as file:
+ with open(filename, encoding="utf-8") as file:
script_commands = file.read().splitlines()
manual_out = []
@@ -356,28 +377,28 @@ def test_run_script(base_app, request) -> None:
def test_run_script_with_empty_args(base_app) -> None:
- _out, err = run_cmd(base_app, 'run_script')
+ _out, err = run_cmd(base_app, "run_script")
assert "the following arguments are required" in err[1]
assert base_app.last_result is None
def test_run_script_with_invalid_file(base_app, request) -> None:
# Path does not exist
- _out, err = run_cmd(base_app, 'run_script does_not_exist.txt')
+ _out, err = run_cmd(base_app, "run_script does_not_exist.txt")
assert "Problem accessing script from " in err[0]
assert base_app.last_result is False
# Path is a directory
test_dir = os.path.dirname(request.module.__file__)
- _out, err = run_cmd(base_app, f'run_script {test_dir}')
+ _out, err = run_cmd(base_app, f"run_script {test_dir}")
assert "Problem accessing script from " in err[0]
assert base_app.last_result is False
def test_run_script_with_empty_file(base_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
- filename = os.path.join(test_dir, 'scripts', 'empty.txt')
- out, err = run_cmd(base_app, f'run_script {filename}')
+ filename = os.path.join(test_dir, "scripts", "empty.txt")
+ out, err = run_cmd(base_app, f"run_script {filename}")
assert not out
assert not err
assert base_app.last_result is True
@@ -385,39 +406,39 @@ def test_run_script_with_empty_file(base_app, request) -> None:
def test_run_script_with_binary_file(base_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
- filename = os.path.join(test_dir, 'scripts', 'binary.bin')
- _out, err = run_cmd(base_app, f'run_script {filename}')
+ filename = os.path.join(test_dir, "scripts", "binary.bin")
+ _out, err = run_cmd(base_app, f"run_script {filename}")
assert "is not an ASCII or UTF-8 encoded text file" in err[0]
assert base_app.last_result is False
-def test_run_script_with_python_file(base_app, request) -> None:
- m = mock.MagicMock(name='input', return_value='2')
- builtins.input = m
+def test_run_script_with_python_file(base_app, request, monkeypatch) -> None:
+ read_input_mock = mock.MagicMock(name="read_input", return_value="2")
+ monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
test_dir = os.path.dirname(request.module.__file__)
- filename = os.path.join(test_dir, 'pyscript', 'stop.py')
- _out, err = run_cmd(base_app, f'run_script {filename}')
+ filename = os.path.join(test_dir, "pyscript", "stop.py")
+ _out, err = run_cmd(base_app, f"run_script {filename}")
assert "appears to be a Python file" in err[0]
assert base_app.last_result is False
def test_run_script_with_utf8_file(base_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
- filename = os.path.join(test_dir, 'scripts', 'utf8.txt')
+ filename = os.path.join(test_dir, "scripts", "utf8.txt")
assert base_app._script_dir == []
assert base_app._current_script_dir is None
# Get output out the script
- script_out, script_err = run_cmd(base_app, f'run_script {filename}')
+ script_out, script_err = run_cmd(base_app, f"run_script {filename}")
assert base_app.last_result is True
assert base_app._script_dir == []
assert base_app._current_script_dir is None
# Now run the commands manually and compare their output to script's
- with open(filename, encoding='utf-8') as file:
+ with open(filename, encoding="utf-8") as file:
script_commands = file.read().splitlines()
manual_out = []
@@ -433,8 +454,8 @@ def test_run_script_with_utf8_file(base_app, request) -> None:
def test_scripts_add_to_history(base_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
- filename = os.path.join(test_dir, 'scripts', 'help.txt')
- command = f'run_script {filename}'
+ filename = os.path.join(test_dir, "scripts", "help.txt")
+ command = f"run_script {filename}"
# Add to history
base_app.scripts_add_to_history = True
@@ -442,7 +463,7 @@ def test_scripts_add_to_history(base_app, request) -> None:
run_cmd(base_app, command)
assert len(base_app.history) == 2
assert base_app.history.get(1).raw == command
- assert base_app.history.get(2).raw == 'help -v'
+ assert base_app.history.get(2).raw == "help -v"
# Do not add to history
base_app.scripts_add_to_history = False
@@ -456,10 +477,10 @@ def test_run_script_nested_run_scripts(base_app, request) -> None:
# Verify that running a script with nested run_script commands works correctly,
# and runs the nested script commands in the correct order.
test_dir = os.path.dirname(request.module.__file__)
- filename = os.path.join(test_dir, 'scripts', 'nested.txt')
+ filename = os.path.join(test_dir, "scripts", "nested.txt")
# Run the top level script
- initial_run = 'run_script ' + filename
+ initial_run = "run_script " + filename
run_cmd(base_app, initial_run)
assert base_app.last_result is True
@@ -471,26 +492,26 @@ def test_run_script_nested_run_scripts(base_app, request) -> None:
help
shortcuts
_relative_run_script postcmds.txt
-set allow_style Never"""
- out, _err = run_cmd(base_app, 'history -s')
+set allow_style Terminal"""
+ out, _err = run_cmd(base_app, "history -s")
assert out == normalize(expected)
def test_runcmds_plus_hooks(base_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
- prefilepath = os.path.join(test_dir, 'scripts', 'precmds.txt')
- postfilepath = os.path.join(test_dir, 'scripts', 'postcmds.txt')
+ prefilepath = os.path.join(test_dir, "scripts", "precmds.txt")
+ postfilepath = os.path.join(test_dir, "scripts", "postcmds.txt")
- base_app.runcmds_plus_hooks(['run_script ' + prefilepath, 'help', 'shortcuts', 'run_script ' + postfilepath])
+ base_app.runcmds_plus_hooks(["run_script " + prefilepath, "help", "shortcuts", "run_script " + postfilepath])
expected = f"""
run_script {prefilepath}
set allow_style Always
help
shortcuts
run_script {postfilepath}
-set allow_style Never"""
+set allow_style Terminal"""
- out, _err = run_cmd(base_app, 'history -s')
+ out, _err = run_cmd(base_app, "history -s")
assert out == normalize(expected)
@@ -499,20 +520,20 @@ def test_runcmds_plus_hooks_ctrl_c(base_app, capsys) -> None:
import types
def do_keyboard_interrupt(self, _) -> NoReturn:
- raise KeyboardInterrupt('Interrupting this command')
+ raise KeyboardInterrupt("Interrupting this command")
base_app.do_keyboard_interrupt = types.MethodType(do_keyboard_interrupt, base_app)
# Default behavior is to not stop runcmds_plus_hooks() on Ctrl-C
base_app.history.clear()
- base_app.runcmds_plus_hooks(['help', 'keyboard_interrupt', 'shortcuts'])
+ base_app.runcmds_plus_hooks(["help", "keyboard_interrupt", "shortcuts"])
_out, err = capsys.readouterr()
assert not err
assert len(base_app.history) == 3
# Ctrl-C should stop runcmds_plus_hooks() in this case
base_app.history.clear()
- base_app.runcmds_plus_hooks(['help', 'keyboard_interrupt', 'shortcuts'], stop_on_keyboard_interrupt=True)
+ base_app.runcmds_plus_hooks(["help", "keyboard_interrupt", "shortcuts"], stop_on_keyboard_interrupt=True)
_out, err = capsys.readouterr()
assert err.startswith("Interrupting this command")
assert len(base_app.history) == 2
@@ -520,20 +541,20 @@ def do_keyboard_interrupt(self, _) -> NoReturn:
def test_relative_run_script(base_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
- filename = os.path.join(test_dir, 'script.txt')
+ filename = os.path.join(test_dir, "script.txt")
assert base_app._script_dir == []
assert base_app._current_script_dir is None
# Get output out the script
- script_out, script_err = run_cmd(base_app, f'_relative_run_script {filename}')
+ script_out, script_err = run_cmd(base_app, f"_relative_run_script {filename}")
assert base_app.last_result is True
assert base_app._script_dir == []
assert base_app._current_script_dir is None
# Now run the commands manually and compare their output to script's
- with open(filename, encoding='utf-8') as file:
+ with open(filename, encoding="utf-8") as file:
script_commands = file.read().splitlines()
manual_out = []
@@ -547,11 +568,11 @@ def test_relative_run_script(base_app, request) -> None:
assert script_err == manual_err
-@pytest.mark.parametrize('file_name', odd_file_names)
+@pytest.mark.parametrize("file_name", odd_file_names)
def test_relative_run_script_with_odd_file_names(base_app, file_name, monkeypatch) -> None:
"""Test file names with various patterns"""
# Mock out the do_run_script call to see what args are passed to it
- run_script_mock = mock.MagicMock(name='do_run_script')
+ run_script_mock = mock.MagicMock(name="do_run_script")
monkeypatch.setattr("cmd2.Cmd.do_run_script", run_script_mock)
run_cmd(base_app, f"_relative_run_script {su.quote(file_name)}")
@@ -559,8 +580,8 @@ def test_relative_run_script_with_odd_file_names(base_app, file_name, monkeypatc
def test_relative_run_script_requires_an_argument(base_app) -> None:
- _out, err = run_cmd(base_app, '_relative_run_script')
- assert 'Error: the following arguments' in err[1]
+ _out, err = run_cmd(base_app, "_relative_run_script")
+ assert "Error: the following arguments" in err[1]
assert base_app.last_result is None
@@ -577,8 +598,8 @@ def hook(self: cmd2.Cmd, data: plugin.CommandFinalizationData) -> plugin.Command
hook_app = HookApp()
test_dir = os.path.dirname(request.module.__file__)
- filename = os.path.join(test_dir, 'script.txt')
- out, _err = run_cmd(hook_app, f'run_script {filename}')
+ filename = os.path.join(test_dir, "script.txt")
+ out, _err = run_cmd(hook_app, f"run_script {filename}")
assert "WE ARE IN SCRIPT" in out[-1]
@@ -594,7 +615,7 @@ def do_system_exit(self, _) -> NoReturn:
base_app.do_system_exit = types.MethodType(do_system_exit, base_app)
- stop = base_app.onecmd_plus_hooks('system_exit')
+ stop = base_app.onecmd_plus_hooks("system_exit")
assert stop
assert base_app.exit_code == exit_code
@@ -612,7 +633,7 @@ def do_passthrough(self, _) -> NoReturn:
base_app.do_passthrough = types.MethodType(do_passthrough, base_app)
with pytest.raises(OSError, match=expected_err):
- base_app.onecmd_plus_hooks('passthrough')
+ base_app.onecmd_plus_hooks("passthrough")
class RedirectionApp(cmd2.Cmd):
@@ -635,19 +656,19 @@ def redirection_app():
def test_output_redirection(redirection_app) -> None:
- fd, filename = tempfile.mkstemp(prefix='cmd2_test', suffix='.txt')
+ fd, filename = tempfile.mkstemp(prefix="cmd2_test", suffix=".txt")
os.close(fd)
try:
# Verify that writing to a file works
- run_cmd(redirection_app, f'print_output > {filename}')
+ run_cmd(redirection_app, f"print_output > {filename}")
with open(filename) as f:
lines = f.read().splitlines()
assert lines[0] == "print"
assert lines[1] == "poutput"
# Verify that appending to a file also works
- run_cmd(redirection_app, f'print_output >> {filename}')
+ run_cmd(redirection_app, f"print_output >> {filename}")
with open(filename) as f:
lines = f.read().splitlines()
assert lines[0] == "print"
@@ -660,20 +681,20 @@ def test_output_redirection(redirection_app) -> None:
def test_output_redirection_custom_stdout(redirection_app) -> None:
"""sys.stdout should not redirect if it's different than self.stdout."""
- fd, filename = tempfile.mkstemp(prefix='cmd2_test', suffix='.txt')
+ fd, filename = tempfile.mkstemp(prefix="cmd2_test", suffix=".txt")
os.close(fd)
redirection_app.stdout = io.StringIO()
try:
# Verify that we only see output written to self.stdout
- run_cmd(redirection_app, f'print_output > {filename}')
+ run_cmd(redirection_app, f"print_output > {filename}")
with open(filename) as f:
lines = f.read().splitlines()
assert "print" not in lines
assert lines[0] == "poutput"
# Verify that appending to a file also works
- run_cmd(redirection_app, f'print_output >> {filename}')
+ run_cmd(redirection_app, f"print_output >> {filename}")
with open(filename) as f:
lines = f.read().splitlines()
assert "print" not in lines
@@ -684,38 +705,38 @@ def test_output_redirection_custom_stdout(redirection_app) -> None:
def test_output_redirection_to_nonexistent_directory(redirection_app) -> None:
- filename = '~/fakedir/this_does_not_exist.txt'
+ filename = "~/fakedir/this_does_not_exist.txt"
- _out, err = run_cmd(redirection_app, f'print_output > {filename}')
- assert 'Failed to redirect' in err[0]
+ _out, err = run_cmd(redirection_app, f"print_output > {filename}")
+ assert "Failed to redirect" in err[0]
- _out, err = run_cmd(redirection_app, f'print_output >> {filename}')
- assert 'Failed to redirect' in err[0]
+ _out, err = run_cmd(redirection_app, f"print_output >> {filename}")
+ assert "Failed to redirect" in err[0]
def test_output_redirection_to_too_long_filename(redirection_app) -> None:
filename = (
- '~/sdkfhksdjfhkjdshfkjsdhfkjsdhfkjdshfkjdshfkjshdfkhdsfkjhewfuihewiufhweiufhiweufhiuewhiuewhfiuwehfia'
- 'ewhfiuewhfiuewhfiuewhiuewhfiuewhfiuewfhiuwehewiufhewiuhfiweuhfiuwehfiuewfhiuwehiuewfhiuewhiewuhfiueh'
- 'fiuwefhewiuhewiufhewiufhewiufhewiufhewiufhewiufhewiufhewiuhewiufhewiufhewiuheiufhiuewheiwufhewiufheu'
- 'fheiufhieuwhfewiuhfeiufhiuewfhiuewheiwuhfiuewhfiuewhfeiuwfhewiufhiuewhiuewhfeiuwhfiuwehfuiwehfiuehie'
- 'whfieuwfhieufhiuewhfeiuwfhiuefhueiwhfw'
+ "~/sdkfhksdjfhkjdshfkjsdhfkjsdhfkjdshfkjdshfkjshdfkhdsfkjhewfuihewiufhweiufhiweufhiuewhiuewhfiuwehfia"
+ "ewhfiuewhfiuewhfiuewhiuewhfiuewhfiuewfhiuwehewiufhewiuhfiweuhfiuwehfiuewfhiuwehiuewfhiuewhiewuhfiueh"
+ "fiuwefhewiuhewiufhewiufhewiufhewiufhewiufhewiufhewiufhewiuhewiufhewiufhewiuheiufhiuewheiwufhewiufheu"
+ "fheiufhieuwhfewiuhfeiufhiuewfhiuewheiwuhfiuewhfiuewhfeiuwfhewiufhiuewhiuewhfeiuwhfiuwehfuiwehfiuehie"
+ "whfieuwfhieufhiuewhfeiuwfhiuefhueiwhfw"
)
- _out, err = run_cmd(redirection_app, f'print_output > {filename}')
- assert 'Failed to redirect' in err[0]
+ _out, err = run_cmd(redirection_app, f"print_output > {filename}")
+ assert "Failed to redirect" in err[0]
- _out, err = run_cmd(redirection_app, f'print_output >> {filename}')
- assert 'Failed to redirect' in err[0]
+ _out, err = run_cmd(redirection_app, f"print_output >> {filename}")
+ assert "Failed to redirect" in err[0]
def test_feedback_to_output_true(redirection_app) -> None:
redirection_app.feedback_to_output = True
- f, filename = tempfile.mkstemp(prefix='cmd2_test', suffix='.txt')
+ f, filename = tempfile.mkstemp(prefix="cmd2_test", suffix=".txt")
os.close(f)
try:
- run_cmd(redirection_app, f'print_feedback > {filename}')
+ run_cmd(redirection_app, f"print_feedback > {filename}")
with open(filename) as f:
content = f.read().splitlines()
assert "feedback" in content
@@ -725,11 +746,11 @@ def test_feedback_to_output_true(redirection_app) -> None:
def test_feedback_to_output_false(redirection_app) -> None:
redirection_app.feedback_to_output = False
- f, filename = tempfile.mkstemp(prefix='feedback_to_output', suffix='.txt')
+ f, filename = tempfile.mkstemp(prefix="feedback_to_output", suffix=".txt")
os.close(f)
try:
- _out, err = run_cmd(redirection_app, f'print_feedback > {filename}')
+ _out, err = run_cmd(redirection_app, f"print_feedback > {filename}")
with open(filename) as f:
content = f.read().splitlines()
@@ -743,10 +764,10 @@ def test_disallow_redirection(redirection_app) -> None:
# Set allow_redirection to False
redirection_app.allow_redirection = False
- filename = 'test_allow_redirect.txt'
+ filename = "test_allow_redirect.txt"
# Verify output wasn't redirected
- out, _err = run_cmd(redirection_app, f'print_output > {filename}')
+ out, _err = run_cmd(redirection_app, f"print_output > {filename}")
assert "print" in out
assert "poutput" in out
@@ -771,7 +792,7 @@ def test_pipe_to_shell_custom_stdout(redirection_app) -> None:
def test_pipe_to_shell_and_redirect(redirection_app) -> None:
- filename = 'out.txt'
+ filename = "out.txt"
out, err = run_cmd(redirection_app, f"print_output | sort > {filename}")
assert not out
assert not err
@@ -781,7 +802,7 @@ def test_pipe_to_shell_and_redirect(redirection_app) -> None:
def test_pipe_to_shell_error(redirection_app) -> None:
# Try to pipe command output to a shell command that doesn't exist in order to produce an error
- out, err = run_cmd(redirection_app, 'print_output | foobarbaz.this_does_not_exist')
+ out, err = run_cmd(redirection_app, "print_output | foobarbaz.this_does_not_exist")
assert not out
assert "Pipe process exited with code" in err[0]
@@ -803,13 +824,13 @@ def test_pipe_to_shell_error(redirection_app) -> None:
@pytest.mark.skipif(not can_paste, reason="Pyperclip could not find a copy/paste mechanism for your system")
def test_send_to_paste_buffer(redirection_app) -> None:
# Test writing to the PasteBuffer/Clipboard
- run_cmd(redirection_app, 'print_output >')
+ run_cmd(redirection_app, "print_output >")
lines = cmd2.cmd2.get_paste_buffer().splitlines()
assert lines[0] == "print"
assert lines[1] == "poutput"
# Test appending to the PasteBuffer/Clipboard
- run_cmd(redirection_app, 'print_output >>')
+ run_cmd(redirection_app, "print_output >>")
lines = cmd2.cmd2.get_paste_buffer().splitlines()
assert lines[0] == "print"
assert lines[1] == "poutput"
@@ -823,13 +844,13 @@ def test_send_to_paste_buffer_custom_stdout(redirection_app) -> None:
redirection_app.stdout = io.StringIO()
# Verify that we only see output written to self.stdout
- run_cmd(redirection_app, 'print_output >')
+ run_cmd(redirection_app, "print_output >")
lines = cmd2.cmd2.get_paste_buffer().splitlines()
assert "print" not in lines
assert lines[0] == "poutput"
# Test appending to the PasteBuffer/Clipboard
- run_cmd(redirection_app, 'print_output >>')
+ run_cmd(redirection_app, "print_output >>")
lines = cmd2.cmd2.get_paste_buffer().splitlines()
assert "print" not in lines
assert lines[0] == "poutput"
@@ -838,18 +859,18 @@ def test_send_to_paste_buffer_custom_stdout(redirection_app) -> None:
def test_get_paste_buffer_exception(redirection_app, mocker, capsys) -> None:
# Force get_paste_buffer to throw an exception
- pastemock = mocker.patch('pyperclip.paste')
- pastemock.side_effect = ValueError('foo')
+ pastemock = mocker.patch("pyperclip.paste")
+ pastemock.side_effect = ValueError("foo")
# Redirect command output to the clipboard
- redirection_app.onecmd_plus_hooks('print_output > ')
+ redirection_app.onecmd_plus_hooks("print_output > ")
# Make sure we got the exception output
out, err = capsys.readouterr()
- assert out == ''
+ assert out == ""
# this just checks that cmd2 is surfacing whatever error gets raised by pyperclip.paste
- assert 'ValueError' in err
- assert 'foo' in err
+ assert "ValueError" in err
+ assert "foo" in err
def test_allow_clipboard_initializer(redirection_app) -> None:
@@ -864,14 +885,14 @@ def test_allow_clipboard_initializer(redirection_app) -> None:
# work in the test environment, like we do for test_send_to_paste_buffer()
def test_allow_clipboard(base_app) -> None:
base_app.allow_clipboard = False
- out, err = run_cmd(base_app, 'help >')
+ out, err = run_cmd(base_app, "help >")
assert not out
assert "Clipboard access not allowed" in err
def test_base_timing(base_app) -> None:
base_app.feedback_to_output = False
- out, err = run_cmd(base_app, 'set timing True')
+ out, err = run_cmd(base_app, "set timing True")
expected = normalize(
"""timing - was: False
now: True
@@ -879,10 +900,10 @@ def test_base_timing(base_app) -> None:
)
assert out == expected
- if sys.platform == 'win32':
- assert err[0].startswith('Elapsed: 0:00:00')
+ if sys.platform == "win32":
+ assert err[0].startswith("Elapsed: 0:00:00")
else:
- assert err[0].startswith('Elapsed: 0:00:00.0')
+ assert err[0].startswith("Elapsed: 0:00:00.0")
def test_base_debug(base_app) -> None:
@@ -890,12 +911,12 @@ def test_base_debug(base_app) -> None:
base_app.editor = None
# Make sure we get an exception, but cmd2 handles it
- out, err = run_cmd(base_app, 'edit')
+ out, err = run_cmd(base_app, "edit")
assert "ValueError: Please use 'set editor'" in err[0]
assert "To enable full traceback" in err[3]
# Set debug true
- out, err = run_cmd(base_app, 'set debug True')
+ out, err = run_cmd(base_app, "set debug True")
expected = normalize(
"""
debug - was: False
@@ -905,26 +926,26 @@ def test_base_debug(base_app) -> None:
assert out == expected
# Verify that we now see the exception traceback
- out, err = run_cmd(base_app, 'edit')
- assert 'Traceback (most recent call last)' in err[0]
+ out, err = run_cmd(base_app, "edit")
+ assert "Traceback (most recent call last)" in err[0]
def test_debug_not_settable(base_app) -> None:
# Set debug to False and make it unsettable
base_app.debug = False
- base_app.remove_settable('debug')
+ base_app.remove_settable("debug")
# Cause an exception by setting editor to None and running edit
base_app.editor = None
- _out, err = run_cmd(base_app, 'edit')
+ _out, err = run_cmd(base_app, "edit")
# Since debug is unsettable, the user will not be given the option to enable a full traceback
- assert err == ["ValueError: Please use 'set editor' to specify your text editing program of", 'choice.']
+ assert err == ["ValueError: Please use 'set editor' to specify your text editing program of", "choice."]
def test_blank_exception(mocker, base_app):
mocker.patch("cmd2.Cmd.do_help", side_effect=Exception)
- _out, err = run_cmd(base_app, 'help')
+ _out, err = run_cmd(base_app, "help")
# When an exception has no message, the first error line is just its type.
assert err[0] == "Exception"
@@ -932,49 +953,49 @@ def test_blank_exception(mocker, base_app):
def test_remove_settable_keyerror(base_app) -> None:
with pytest.raises(KeyError):
- base_app.remove_settable('fake')
+ base_app.remove_settable("fake")
def test_edit_file(base_app, request, monkeypatch) -> None:
# Set a fake editor just to make sure we have one. We aren't really going to call it due to the mock
- base_app.editor = 'fooedit'
+ base_app.editor = "fooedit"
# Mock out the subprocess.Popen call so we don't actually open an editor
- m = mock.MagicMock(name='Popen')
+ m = mock.MagicMock(name="Popen")
monkeypatch.setattr("subprocess.Popen", m)
test_dir = os.path.dirname(request.module.__file__)
- filename = os.path.join(test_dir, 'script.txt')
+ filename = os.path.join(test_dir, "script.txt")
- run_cmd(base_app, f'edit {filename}')
+ run_cmd(base_app, f"edit {filename}")
# We think we have an editor, so should expect a Popen call
m.assert_called_once()
-@pytest.mark.parametrize('file_name', odd_file_names)
+@pytest.mark.parametrize("file_name", odd_file_names)
def test_edit_file_with_odd_file_names(base_app, file_name, monkeypatch) -> None:
"""Test editor and file names with various patterns"""
# Mock out the do_shell call to see what args are passed to it
- shell_mock = mock.MagicMock(name='do_shell')
+ shell_mock = mock.MagicMock(name="do_shell")
monkeypatch.setattr("cmd2.Cmd.do_shell", shell_mock)
- base_app.editor = 'fooedit'
- file_name = su.quote('nothingweird.py')
+ base_app.editor = "fooedit"
+ file_name = su.quote("nothingweird.py")
run_cmd(base_app, f"edit {su.quote(file_name)}")
shell_mock.assert_called_once_with(f'"fooedit" {su.quote(file_name)}')
def test_edit_file_with_spaces(base_app, request, monkeypatch) -> None:
# Set a fake editor just to make sure we have one. We aren't really going to call it due to the mock
- base_app.editor = 'fooedit'
+ base_app.editor = "fooedit"
# Mock out the subprocess.Popen call so we don't actually open an editor
- m = mock.MagicMock(name='Popen')
+ m = mock.MagicMock(name="Popen")
monkeypatch.setattr("subprocess.Popen", m)
test_dir = os.path.dirname(request.module.__file__)
- filename = os.path.join(test_dir, 'my commands.txt')
+ filename = os.path.join(test_dir, "my commands.txt")
run_cmd(base_app, f'edit "{filename}"')
@@ -984,13 +1005,13 @@ def test_edit_file_with_spaces(base_app, request, monkeypatch) -> None:
def test_edit_blank(base_app, monkeypatch) -> None:
# Set a fake editor just to make sure we have one. We aren't really going to call it due to the mock
- base_app.editor = 'fooedit'
+ base_app.editor = "fooedit"
# Mock out the subprocess.Popen call so we don't actually open an editor
- m = mock.MagicMock(name='Popen')
+ m = mock.MagicMock(name="Popen")
monkeypatch.setattr("subprocess.Popen", m)
- run_cmd(base_app, 'edit')
+ run_cmd(base_app, "edit")
# We have an editor, so should expect a Popen call
m.assert_called_once()
@@ -998,7 +1019,7 @@ def test_edit_blank(base_app, monkeypatch) -> None:
def test_base_py_interactive(base_app) -> None:
# Mock out the InteractiveConsole.interact() call so we don't actually wait for a user's response on stdin
- m = mock.MagicMock(name='interact')
+ m = mock.MagicMock(name="interact")
InteractiveConsole.interact = m
run_cmd(base_app, "py")
@@ -1008,17 +1029,15 @@ def test_base_py_interactive(base_app) -> None:
def test_base_cmdloop_with_startup_commands() -> None:
- intro = 'Hello World, this is an intro ...'
+ intro = "Hello World, this is an intro ..."
# Need to patch sys.argv so cmd2 doesn't think it was called with arguments equal to the py.test args
- testargs = ["prog", 'quit']
- expected = intro + '\n'
+ testargs = ["prog", "quit"]
+ expected = intro + "\n"
- with mock.patch.object(sys, 'argv', testargs):
+ with mock.patch.object(sys, "argv", testargs):
app = create_outsim_app()
- app.use_rawinput = True
-
# Run the command loop with custom intro
app.cmdloop(intro=intro)
@@ -1026,20 +1045,18 @@ def test_base_cmdloop_with_startup_commands() -> None:
assert out == expected
-def test_base_cmdloop_without_startup_commands() -> None:
+def test_base_cmdloop_without_startup_commands(monkeypatch) -> None:
# Need to patch sys.argv so cmd2 doesn't think it was called with arguments equal to the py.test args
testargs = ["prog"]
- with mock.patch.object(sys, 'argv', testargs):
+ with mock.patch.object(sys, "argv", testargs):
app = create_outsim_app()
- app.use_rawinput = True
- app.intro = 'Hello World, this is an intro ...'
+ app.intro = "Hello World, this is an intro ..."
- # Mock out the input call so we don't actually wait for a user's response on stdin
- m = mock.MagicMock(name='input', return_value='quit')
- builtins.input = m
+ read_command_mock = mock.MagicMock(name="_read_command_line", return_value="quit")
+ monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock)
- expected = app.intro + '\n'
+ expected = app.intro + "\n"
# Run the command loop
app.cmdloop()
@@ -1047,51 +1064,29 @@ def test_base_cmdloop_without_startup_commands() -> None:
assert out == expected
-def test_cmdloop_without_rawinput() -> None:
- # Need to patch sys.argv so cmd2 doesn't think it was called with arguments equal to the py.test args
- testargs = ["prog"]
- with mock.patch.object(sys, 'argv', testargs):
- app = create_outsim_app()
-
- app.use_rawinput = False
- app.echo = False
- app.intro = 'Hello World, this is an intro ...'
-
- # Mock out the input call so we don't actually wait for a user's response on stdin
- m = mock.MagicMock(name='input', return_value='quit')
- builtins.input = m
-
- expected = app.intro + '\n'
-
- with pytest.raises(OSError): # noqa: PT011
- app.cmdloop()
- out = app.stdout.getvalue()
- assert out == expected
-
-
def test_cmdfinalizations_runs(base_app, monkeypatch) -> None:
"""Make sure _run_cmdfinalization_hooks is run after each command."""
with (
- mock.patch('sys.stdin.isatty', mock.MagicMock(name='isatty', return_value=True)),
- mock.patch('sys.stdin.fileno', mock.MagicMock(name='fileno', return_value=0)),
+ mock.patch("sys.stdin.isatty", mock.MagicMock(name="isatty", return_value=True)),
+ mock.patch("sys.stdin.fileno", mock.MagicMock(name="fileno", return_value=0)),
):
monkeypatch.setattr(base_app.stdin, "fileno", lambda: 0)
monkeypatch.setattr(base_app.stdin, "isatty", lambda: True)
- cmd_fin = mock.MagicMock(name='cmdfinalization')
+ cmd_fin = mock.MagicMock(name="cmdfinalization")
monkeypatch.setattr("cmd2.Cmd._run_cmdfinalization_hooks", cmd_fin)
- base_app.onecmd_plus_hooks('help')
+ base_app.onecmd_plus_hooks("help")
cmd_fin.assert_called_once()
-@pytest.mark.skipif(sys.platform.startswith('win'), reason="termios is not available on Windows")
+@pytest.mark.skipif(sys.platform.startswith("win"), reason="termios is not available on Windows")
@pytest.mark.parametrize(
- ('is_tty', 'settings_set', 'raised_exception', 'should_call'),
+ ("is_tty", "settings_set", "raised_exception", "should_call"),
[
(True, True, None, True),
- (True, True, 'termios_error', True),
- (True, True, 'unsupported_operation', True),
+ (True, True, "termios_error", True),
+ (True, True, "unsupported_operation", True),
(False, True, None, False),
(True, False, None, False),
],
@@ -1104,12 +1099,12 @@ def test_restore_termios_settings(base_app, monkeypatch, is_tty, settings_set, r
termios_mock = mock.MagicMock()
# The error attribute needs to be the actual exception for isinstance checks
termios_mock.error = termios.error
- monkeypatch.setitem(sys.modules, 'termios', termios_mock)
+ monkeypatch.setitem(sys.modules, "termios", termios_mock)
# Set the exception to be raised by tcsetattr
- if raised_exception == 'termios_error':
+ if raised_exception == "termios_error":
termios_mock.tcsetattr.side_effect = termios.error("test termios error")
- elif raised_exception == 'unsupported_operation':
+ elif raised_exception == "unsupported_operation":
termios_mock.tcsetattr.side_effect = io.UnsupportedOperation("test io error")
# Set initial termios settings so the logic will run
@@ -1126,7 +1121,7 @@ def test_restore_termios_settings(base_app, monkeypatch, is_tty, settings_set, r
# Run a command to trigger _run_cmdfinalization_hooks
# This should not raise an exception
- base_app.onecmd_plus_hooks('help')
+ base_app.onecmd_plus_hooks("help")
# Verify that tcsetattr was called with the correct arguments
if should_call:
@@ -1148,10 +1143,10 @@ def test_sigint_handler(base_app) -> None:
def test_raise_keyboard_interrupt(base_app) -> None:
with pytest.raises(KeyboardInterrupt) as excinfo:
base_app._raise_keyboard_interrupt()
- assert 'Got a keyboard interrupt' in str(excinfo.value)
+ assert "Got a keyboard interrupt" in str(excinfo.value)
-@pytest.mark.skipif(sys.platform.startswith('win'), reason="SIGTERM only handled on Linux/Mac")
+@pytest.mark.skipif(sys.platform.startswith("win"), reason="SIGTERM only handled on Linux/Mac")
def test_termination_signal_handler(base_app) -> None:
with pytest.raises(SystemExit) as excinfo:
base_app.termination_signal_handler(signal.SIGHUP, 1)
@@ -1180,12 +1175,12 @@ def hook_failure():
def test_precmd_hook_success(base_app) -> None:
- out = base_app.onecmd_plus_hooks('help')
+ out = base_app.onecmd_plus_hooks("help")
assert out is False
def test_precmd_hook_failure(hook_failure) -> None:
- out = hook_failure.onecmd_plus_hooks('help')
+ out = hook_failure.onecmd_plus_hooks("help")
assert out is True
@@ -1204,89 +1199,150 @@ def say_app():
return app
-def test_ctrl_c_at_prompt(say_app) -> None:
- # Mock out the input call so we don't actually wait for a user's response on stdin
- m = mock.MagicMock(name='input')
- m.side_effect = ['say hello', KeyboardInterrupt(), 'say goodbye', 'eof']
- builtins.input = m
+def test_ctrl_c_at_prompt(say_app, monkeypatch) -> None:
+ read_command_mock = mock.MagicMock(name="_read_command_line")
+ read_command_mock.side_effect = ["say hello", KeyboardInterrupt(), "say goodbye", "quit"]
+ monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock)
say_app.cmdloop()
# And verify the expected output to stdout
out = say_app.stdout.getvalue()
- assert out == 'hello\n^C\ngoodbye\n\n'
-
-
-class ShellApp(cmd2.Cmd):
- def __init__(self, *args, **kwargs) -> None:
- super().__init__(*args, **kwargs)
- self.default_to_shell = True
+ assert out == "hello\n^C\ngoodbye\n"
-def test_default_to_shell(base_app, monkeypatch) -> None:
- if sys.platform.startswith('win'):
- line = 'dir'
- else:
- line = 'ls'
-
- base_app.default_to_shell = True
- m = mock.Mock()
- monkeypatch.setattr("{}.Popen".format('subprocess'), m)
- out, _err = run_cmd(base_app, line)
- assert out == []
- assert m.called
+def test_ctrl_d_at_prompt(say_app, monkeypatch) -> None:
+ read_command_mock = mock.MagicMock(name="_read_command_line")
+ read_command_mock.side_effect = ["say hello", EOFError()]
+ monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock)
+ say_app.cmdloop()
-def test_escaping_prompt() -> None:
- from cmd2.rl_utils import (
- rl_escape_prompt,
- rl_unescape_prompt,
- )
+ # And verify the expected output to stdout
+ out = say_app.stdout.getvalue()
+ assert out == "hello\n\n"
- # This prompt has nothing which needs to be escaped
- prompt = '(Cmd) '
- assert rl_escape_prompt(prompt) == prompt
- # This prompt has color which needs to be escaped
- prompt = stylize('InColor', style=Color.CYAN)
+@pytest.mark.skipif(
+ sys.platform.startswith("win"),
+ reason="Don't have a real Windows console with how we are currently running tests in GitHub Actions",
+)
+@pytest.mark.parametrize(
+ ("msg", "prompt", "is_stale"),
+ [
+ ("msg_text", None, False),
+ ("msg_text", "new_prompt> ", False),
+ ("msg_text", "new_prompt> ", True),
+ (None, "new_prompt> ", False),
+ (None, "new_prompt> ", True),
+ # Blank prompt is acceptable
+ ("msg_text", "", False),
+ (None, "", False),
+ ],
+)
+def test_async_alert(base_app, msg, prompt, is_stale) -> None:
+ import time
- escape_start = "\x01"
- escape_end = "\x02"
+ with (
+ mock.patch("cmd2.cmd2.print_formatted_text") as mock_print,
+ mock.patch("cmd2.cmd2.get_app") as mock_get_app,
+ ):
+ # Set up the chained mock: get_app() returns mock_app, which has invalidate()
+ mock_app = mock.MagicMock()
+ mock_get_app.return_value = mock_app
+
+ base_app.add_alert(msg=msg, prompt=prompt)
+ alert = base_app._alert_queue[0]
+
+ # Stale means alert was created before the current prompt.
+ if is_stale:
+ # In the past
+ alert.timestamp = 0.0
+ else:
+ # In the future
+ alert.timestamp = time.monotonic() + 99999999
+
+ with create_pipe_input() as pipe_input:
+ base_app.main_session = PromptSession(
+ input=pipe_input,
+ output=DummyOutput(),
+ history=base_app.main_session.history,
+ completer=base_app.main_session.completer,
+ )
+ pipe_input.send_text("quit\n")
+
+ base_app._cmdloop()
+
+ # If there was a message, patch_stdout handles the redraw (no invalidate)
+ if msg:
+ assert msg in str(mock_print.call_args_list[0])
+ mock_app.invalidate.assert_not_called()
+
+ # If there's only a prompt update, we expect invalidate() only if not continuation/stale
+ elif prompt is not None:
+ if is_stale:
+ mock_app.invalidate.assert_not_called()
+ else:
+ mock_app.invalidate.assert_called_once()
+
+ # The state of base_app.prompt should always be correct regardless of redraw
+ if prompt is not None:
+ if is_stale:
+ assert base_app.prompt != prompt
+ else:
+ assert base_app.prompt == prompt
+
+
+def test_add_alert(base_app) -> None:
+ orig_num_alerts = len(base_app._alert_queue)
+
+ # Nothing is added when both are None
+ base_app.add_alert(msg=None, prompt=None)
+ assert len(base_app._alert_queue) == orig_num_alerts
+
+ # Now test valid alert arguments
+ base_app.add_alert(msg="Hello", prompt=None)
+ base_app.add_alert(msg="Hello", prompt="prompt> ")
+ base_app.add_alert(msg=None, prompt="prompt> ")
+ assert len(base_app._alert_queue) == orig_num_alerts + 3
+
+
+def test_visible_prompt() -> None:
+ app = cmd2.Cmd()
- escaped_prompt = rl_escape_prompt(prompt)
- if sys.platform.startswith('win'):
- # PyReadline on Windows doesn't need to escape invisible characters
- assert escaped_prompt == prompt
- else:
- cyan = "\x1b[36m"
- reset_all = "\x1b[0m"
- assert escaped_prompt.startswith(escape_start + cyan + escape_end)
- assert escaped_prompt.endswith(escape_start + reset_all + escape_end)
+ # This prompt has nothing which needs to be stripped
+ app.prompt = "(Cmd) "
+ assert app.visible_prompt == app.prompt
+ assert su.str_width(app.prompt) == len(app.prompt)
- assert rl_unescape_prompt(escaped_prompt) == prompt
+ # This prompt has color which needs to be stripped
+ color_prompt = stylize("InColor", style=Color.CYAN) + "> "
+ app.prompt = color_prompt
+ assert app.visible_prompt == "InColor> "
+ assert su.str_width(app.prompt) == len("InColor> ")
class HelpApp(cmd2.Cmd):
"""Class for testing custom help_* methods which override docstring help."""
+ DEFAULT_CATEGORY = "My Default Category."
+ MISC_HEADER = "Various topics found here."
+
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.doc_leader = "I now present you with a list of help topics."
- self.doc_header = "My very custom doc header."
- self.misc_header = "Various topics found here."
- self.undoc_header = "Why did no one document these?"
def do_squat(self, arg) -> None:
"""This docstring help will never be shown because the help_squat method overrides it."""
def help_squat(self) -> None:
- self.stdout.write('This command does diddly squat...\n')
+ self.stdout.write("This command does diddly squat...\n")
def do_edit(self, arg) -> None:
"""This overrides the edit command and does nothing."""
- # This command will be in the "undocumented" section of the help menu
- def do_undoc(self, arg) -> None:
+ # This command has no help text
+ def do_no_help(self, arg) -> None:
pass
def do_multiline_docstr(self, arg) -> None:
@@ -1314,58 +1370,57 @@ def help_app():
def test_help_headers(capsys) -> None:
help_app = HelpApp()
- help_app.onecmd_plus_hooks('help')
+ help_app.onecmd_plus_hooks("help")
out, _err = capsys.readouterr()
assert help_app.doc_leader in out
- assert help_app.doc_header in out
- assert help_app.misc_header in out
- assert help_app.undoc_header in out
+ assert HelpApp.DEFAULT_CATEGORY in out
+ assert HelpApp.MISC_HEADER in out
assert help_app.last_result is True
def test_custom_command_help(help_app) -> None:
- out, _err = run_cmd(help_app, 'help squat')
- expected = normalize('This command does diddly squat...')
+ out, _err = run_cmd(help_app, "help squat")
+ expected = normalize("This command does diddly squat...")
assert out == expected
assert help_app.last_result is True
def test_custom_help_menu(help_app) -> None:
- out, _err = run_cmd(help_app, 'help')
+ out, _err = run_cmd(help_app, "help")
verify_help_text(help_app, out)
assert help_app.last_result is True
-def test_help_undocumented(help_app) -> None:
- _out, err = run_cmd(help_app, 'help undoc')
- assert err[0].startswith("No help on undoc")
+def test_help_no_help(help_app) -> None:
+ _out, err = run_cmd(help_app, "help no_help")
+ assert err[0].startswith("No help on no_help")
assert help_app.last_result is False
def test_help_overridden_method(help_app) -> None:
- out, _err = run_cmd(help_app, 'help edit')
- expected = normalize('This overrides the edit command and does nothing.')
+ out, _err = run_cmd(help_app, "help edit")
+ expected = normalize("This overrides the edit command and does nothing.")
assert out == expected
assert help_app.last_result is True
def test_help_multiline_docstring(help_app) -> None:
- out, _err = run_cmd(help_app, 'help multiline_docstr')
- expected = normalize('This documentation\nis multiple lines\nand there are no\ntabs')
+ out, _err = run_cmd(help_app, "help multiline_docstr")
+ expected = normalize("This documentation\nis multiple lines\nand there are no\ntabs")
assert out == expected
assert help_app.last_result is True
def test_miscellaneous_help_topic(help_app) -> None:
- out, _err = run_cmd(help_app, 'help physics')
+ out, _err = run_cmd(help_app, "help physics")
expected = normalize("Here is some help on physics.")
assert out == expected
assert help_app.last_result is True
def test_help_verbose_uses_parser_description(help_app: HelpApp) -> None:
- out, _err = run_cmd(help_app, 'help --verbose')
+ out, _err = run_cmd(help_app, "help --verbose")
expected_verbose = utils.strip_doc_annotations(help_app.do_parser_cmd.__doc__)
verify_help_text(help_app, out, verbose_strings=[expected_verbose])
@@ -1375,7 +1430,7 @@ def test_help_verbose_with_fake_command(capsys) -> None:
help_app = HelpApp()
cmds = ["alias", "fake_command"]
- help_app._print_documented_command_topics(help_app.doc_header, cmds, verbose=True)
+ help_app._print_documented_command_topics(help_app.DEFAULT_CATEGORY, cmds, verbose=True)
out, _err = capsys.readouterr()
assert cmds[0] in out
assert cmds[1] not in out
@@ -1421,7 +1476,7 @@ class HelpCategoriesApp(cmd2.Cmd):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
- @cmd2.with_category('Some Category')
+ @cmd2.with_category("Some Category")
def do_diddly(self, arg) -> None:
"""This command does diddly"""
@@ -1430,22 +1485,18 @@ def do_diddly(self, arg) -> None:
def do_cat_nodoc(self, arg) -> None:
pass
- # This command will show in the category labeled with self.default_category
+ # This command will show in the category labeled with DEFAULT_CATEGORY
def do_squat(self, arg) -> None:
"""This docstring help will never be shown because the help_squat method overrides it."""
def help_squat(self) -> None:
- self.stdout.write('This command does diddly squat...\n')
+ self.stdout.write("This command does diddly squat...\n")
def do_edit(self, arg) -> None:
"""This overrides the edit command and does nothing."""
cmd2.categorize((do_squat, do_edit), CUSTOM_CATEGORY)
- # This command will be in the "undocumented" section of the help menu
- def do_undoc(self, arg) -> None:
- pass
-
@pytest.fixture
def helpcat_app():
@@ -1453,63 +1504,63 @@ def helpcat_app():
def test_help_cat_base(helpcat_app) -> None:
- out, _err = run_cmd(helpcat_app, 'help')
+ out, _err = run_cmd(helpcat_app, "help")
assert helpcat_app.last_result is True
verify_help_text(helpcat_app, out)
- help_text = ''.join(out)
+ help_text = "".join(out)
assert helpcat_app.CUSTOM_CATEGORY in help_text
assert helpcat_app.SOME_CATEGORY in help_text
- assert helpcat_app.default_category in help_text
+ assert helpcat_app.DEFAULT_CATEGORY in help_text
def test_help_cat_verbose(helpcat_app) -> None:
- out, _err = run_cmd(helpcat_app, 'help --verbose')
+ out, _err = run_cmd(helpcat_app, "help --verbose")
assert helpcat_app.last_result is True
verify_help_text(helpcat_app, out)
- help_text = ''.join(out)
+ help_text = "".join(out)
assert helpcat_app.CUSTOM_CATEGORY in help_text
assert helpcat_app.SOME_CATEGORY in help_text
- assert helpcat_app.default_category in help_text
+ assert helpcat_app.DEFAULT_CATEGORY in help_text
class SelectApp(cmd2.Cmd):
def do_eat(self, arg) -> None:
"""Eat something, with a selection of sauces to choose from."""
# Pass in a single string of space-separated selections
- sauce = self.select('sweet salty', 'Sauce? ')
- result = '{food} with {sauce} sauce, yum!'
+ sauce = self.select("sweet salty", "Sauce? ")
+ result = "{food} with {sauce} sauce, yum!"
result = result.format(food=arg, sauce=sauce)
- self.stdout.write(result + '\n')
+ self.stdout.write(result + "\n")
def do_study(self, arg) -> None:
"""Learn something, with a selection of subjects to choose from."""
# Pass in a list of strings for selections
- subject = self.select(['math', 'science'], 'Subject? ')
- result = f'Good luck learning {subject}!\n'
+ subject = self.select(["math", "science"], "Subject? ")
+ result = f"Good luck learning {subject}!\n"
self.stdout.write(result)
def do_procrastinate(self, arg) -> None:
"""Waste time in your manner of choice."""
# Pass in a list of tuples for selections
leisure_activity = self.select(
- [('Netflix and chill', 'Netflix'), ('YouTube', 'WebSurfing')], 'How would you like to procrastinate? '
+ [("Netflix and chill", "Netflix"), ("YouTube", "WebSurfing")], "How would you like to procrastinate? "
)
- result = f'Have fun procrasinating with {leisure_activity}!\n'
+ result = f"Have fun procrasinating with {leisure_activity}!\n"
self.stdout.write(result)
def do_play(self, arg) -> None:
"""Play your favorite musical instrument."""
# Pass in an uneven list of tuples for selections
- instrument = self.select([('Guitar', 'Electric Guitar'), ('Drums',)], 'Instrument? ')
- result = f'Charm us with the {instrument}...\n'
+ instrument = self.select([("Guitar", "Electric Guitar"), ("Drums",)], "Instrument? ")
+ result = f"Charm us with the {instrument}...\n"
self.stdout.write(result)
def do_return_type(self, arg) -> None:
"""Test that return values can be non-strings"""
- choice = self.select([(1, 'Integer'), ("test_str", 'String'), (self.do_play, 'Method')], 'Choice? ')
- result = f'The return type is {type(choice)}\n'
+ choice = self.select([(1, "Integer"), ("test_str", "String"), (self.do_play, "Method")], "Choice? ")
+ result = f"The return type is {type(choice)}\n"
self.stdout.write(result)
@@ -1519,11 +1570,10 @@ def select_app():
def test_select_options(select_app, monkeypatch) -> None:
- # Mock out the read_input call so we don't actually wait for a user's response on stdin
- read_input_mock = mock.MagicMock(name='read_input', return_value='2')
+ read_input_mock = mock.MagicMock(name="read_input", return_value="2")
monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
- food = 'bacon'
+ food = "bacon"
out, _err = run_cmd(select_app, f"eat {food}")
expected = normalize(
f"""
@@ -1534,21 +1584,20 @@ def test_select_options(select_app, monkeypatch) -> None:
)
# Make sure our mock was called with the expected arguments
- read_input_mock.assert_called_once_with('Sauce? ')
+ read_input_mock.assert_called_once_with("Sauce? ")
# And verify the expected output to stdout
assert out == expected
def test_select_invalid_option_too_big(select_app, monkeypatch) -> None:
- # Mock out the input call so we don't actually wait for a user's response on stdin
- read_input_mock = mock.MagicMock(name='read_input')
+ read_input_mock = mock.MagicMock(name="read_input")
# If side_effect is an iterable then each call to the mock will return the next value from the iterable.
- read_input_mock.side_effect = ['3', '1'] # First pass an invalid selection, then pass a valid one
+ read_input_mock.side_effect = ["3", "1"] # First pass an invalid selection, then pass a valid one
monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
- food = 'fish'
+ food = "fish"
out, _err = run_cmd(select_app, f"eat {food}")
expected = normalize(
f"""
@@ -1560,7 +1609,7 @@ def test_select_invalid_option_too_big(select_app, monkeypatch) -> None:
)
# Make sure our mock was called exactly twice with the expected arguments
- arg = 'Sauce? '
+ arg = "Sauce? "
calls = [mock.call(arg), mock.call(arg)]
read_input_mock.assert_has_calls(calls)
assert read_input_mock.call_count == 2
@@ -1570,14 +1619,13 @@ def test_select_invalid_option_too_big(select_app, monkeypatch) -> None:
def test_select_invalid_option_too_small(select_app, monkeypatch) -> None:
- # Mock out the input call so we don't actually wait for a user's response on stdin
- read_input_mock = mock.MagicMock(name='read_input')
+ read_input_mock = mock.MagicMock(name="read_input")
# If side_effect is an iterable then each call to the mock will return the next value from the iterable.
- read_input_mock.side_effect = ['0', '1'] # First pass an invalid selection, then pass a valid one
+ read_input_mock.side_effect = ["0", "1"] # First pass an invalid selection, then pass a valid one
monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
- food = 'fish'
+ food = "fish"
out, _err = run_cmd(select_app, f"eat {food}")
expected = normalize(
f"""
@@ -1589,7 +1637,7 @@ def test_select_invalid_option_too_small(select_app, monkeypatch) -> None:
)
# Make sure our mock was called exactly twice with the expected arguments
- arg = 'Sauce? '
+ arg = "Sauce? "
calls = [mock.call(arg), mock.call(arg)]
read_input_mock.assert_has_calls(calls)
assert read_input_mock.call_count == 2
@@ -1599,8 +1647,7 @@ def test_select_invalid_option_too_small(select_app, monkeypatch) -> None:
def test_select_list_of_strings(select_app, monkeypatch) -> None:
- # Mock out the input call so we don't actually wait for a user's response on stdin
- read_input_mock = mock.MagicMock(name='read_input', return_value='2')
+ read_input_mock = mock.MagicMock(name="read_input", return_value="2")
monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
out, _err = run_cmd(select_app, "study")
@@ -1609,19 +1656,18 @@ def test_select_list_of_strings(select_app, monkeypatch) -> None:
1. math
2. science
Good luck learning {}!
-""".format('science')
+""".format("science")
)
# Make sure our mock was called with the expected arguments
- read_input_mock.assert_called_once_with('Subject? ')
+ read_input_mock.assert_called_once_with("Subject? ")
# And verify the expected output to stdout
assert out == expected
def test_select_list_of_tuples(select_app, monkeypatch) -> None:
- # Mock out the input call so we don't actually wait for a user's response on stdin
- read_input_mock = mock.MagicMock(name='read_input', return_value='2')
+ read_input_mock = mock.MagicMock(name="read_input", return_value="2")
monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
out, _err = run_cmd(select_app, "procrastinate")
@@ -1630,19 +1676,18 @@ def test_select_list_of_tuples(select_app, monkeypatch) -> None:
1. Netflix
2. WebSurfing
Have fun procrasinating with {}!
-""".format('YouTube')
+""".format("YouTube")
)
# Make sure our mock was called with the expected arguments
- read_input_mock.assert_called_once_with('How would you like to procrastinate? ')
+ read_input_mock.assert_called_once_with("How would you like to procrastinate? ")
# And verify the expected output to stdout
assert out == expected
def test_select_uneven_list_of_tuples(select_app, monkeypatch) -> None:
- # Mock out the input call so we don't actually wait for a user's response on stdin
- read_input_mock = mock.MagicMock(name='read_input', return_value='2')
+ read_input_mock = mock.MagicMock(name="read_input", return_value="2")
monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
out, _err = run_cmd(select_app, "play")
@@ -1651,27 +1696,26 @@ def test_select_uneven_list_of_tuples(select_app, monkeypatch) -> None:
1. Electric Guitar
2. Drums
Charm us with the {}...
-""".format('Drums')
+""".format("Drums")
)
# Make sure our mock was called with the expected arguments
- read_input_mock.assert_called_once_with('Instrument? ')
+ read_input_mock.assert_called_once_with("Instrument? ")
# And verify the expected output to stdout
assert out == expected
@pytest.mark.parametrize(
- ('selection', 'type_str'),
+ ("selection", "type_str"),
[
- ('1', ""),
- ('2', ""),
- ('3', ""),
+ ("1", ""),
+ ("2", ""),
+ ("3", ""),
],
)
def test_select_return_type(select_app, monkeypatch, selection, type_str) -> None:
- # Mock out the input call so we don't actually wait for a user's response on stdin
- read_input_mock = mock.MagicMock(name='read_input', return_value=selection)
+ read_input_mock = mock.MagicMock(name="read_input", return_value=selection)
monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
out, _err = run_cmd(select_app, "return_type")
@@ -1685,7 +1729,7 @@ def test_select_return_type(select_app, monkeypatch, selection, type_str) -> Non
)
# Make sure our mock was called with the expected arguments
- read_input_mock.assert_called_once_with('Choice? ')
+ read_input_mock.assert_called_once_with("Choice? ")
# And verify the expected output to stdout
assert out == expected
@@ -1693,14 +1737,14 @@ def test_select_return_type(select_app, monkeypatch, selection, type_str) -> Non
def test_select_eof(select_app, monkeypatch) -> None:
# Ctrl-D during select causes an EOFError that just reprompts the user
- read_input_mock = mock.MagicMock(name='read_input', side_effect=[EOFError, 2])
+ read_input_mock = mock.MagicMock(name="read_input", side_effect=[EOFError, 2])
monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
- food = 'fish'
+ food = "fish"
_out, _err = run_cmd(select_app, f"eat {food}")
# Make sure our mock was called exactly twice with the expected arguments
- arg = 'Sauce? '
+ arg = "Sauce? "
calls = [mock.call(arg), mock.call(arg)]
read_input_mock.assert_has_calls(calls)
assert read_input_mock.call_count == 2
@@ -1708,38 +1752,121 @@ def test_select_eof(select_app, monkeypatch) -> None:
def test_select_ctrl_c(outsim_app, monkeypatch) -> None:
# Ctrl-C during select prints ^C and raises a KeyboardInterrupt
- read_input_mock = mock.MagicMock(name='read_input', side_effect=KeyboardInterrupt)
+ read_input_mock = mock.MagicMock(name="read_input", side_effect=KeyboardInterrupt)
monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
with pytest.raises(KeyboardInterrupt):
- outsim_app.select([('Guitar', 'Electric Guitar'), ('Drums',)], 'Instrument? ')
+ outsim_app.select([("Guitar", "Electric Guitar"), ("Drums",)], "Instrument? ")
+
+ out = outsim_app.stdout.getvalue()
+ assert out.rstrip().endswith("^C")
+
+
+def test_select_choice_tty(outsim_app, monkeypatch) -> None:
+ # Mock choice to return the first option
+ choice_mock = mock.MagicMock(name="choice", return_value="sweet")
+ monkeypatch.setattr("cmd2.cmd2.choice", choice_mock)
+
+ prompt = "Sauce? "
+ options = ["sweet", "salty"]
+
+ with create_pipe_input() as pipe_input:
+ outsim_app.main_session = PromptSession(
+ input=pipe_input,
+ output=DummyOutput(),
+ )
+
+ result = outsim_app.select(options, prompt)
+
+ assert result == "sweet"
+ choice_mock.assert_called_once_with(message=prompt, options=[("sweet", "sweet"), ("salty", "salty")])
+
+
+def test_select_choice_tty_ctrl_c(outsim_app, monkeypatch) -> None:
+ # Mock choice to raise KeyboardInterrupt
+ choice_mock = mock.MagicMock(name="choice", side_effect=KeyboardInterrupt)
+ monkeypatch.setattr("cmd2.cmd2.choice", choice_mock)
+
+ prompt = "Sauce? "
+ options = ["sweet", "salty"]
+
+ # Mock isatty to be True for both stdin and stdout
+ with create_pipe_input() as pipe_input:
+ outsim_app.main_session = PromptSession(
+ input=pipe_input,
+ output=DummyOutput(),
+ )
+
+ with pytest.raises(KeyboardInterrupt):
+ outsim_app.select(options, prompt)
+
+ out = outsim_app.stdout.getvalue()
+ assert out.rstrip().endswith("^C")
+
+
+def test_select_uneven_tuples_labels(outsim_app, monkeypatch) -> None:
+ # Test that uneven tuples still work and labels are handled correctly
+ # Case 1: (value, label) - normal
+ # Case 2: (value,) - label should be value
+ # Case 3: (value, None) - label should be value
+ options = [("v1", "l1"), ("v2",), ("v3", None)]
+
+ # Mock read_input to return '1'
+ read_input_mock = mock.MagicMock(name="read_input", return_value="1")
+ monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
+
+ result = outsim_app.select(options, "Choice? ")
+ assert result == "v1"
+
+ out = outsim_app.stdout.getvalue()
+ assert "1. l1" in out
+ assert "2. v2" in out
+ assert "3. v3" in out
+
+
+def test_select_indexable_no_len(outsim_app, monkeypatch) -> None:
+ # Test that an object with __getitem__ but no __len__ works.
+ # This covers the except (IndexError, TypeError) block in select()
+ class IndexableNoLen:
+ def __getitem__(self, item: int) -> str:
+ if item == 0:
+ return "value"
+ raise IndexError
+
+ # Mock read_input to return '1'
+ read_input_mock = mock.MagicMock(name="read_input", return_value="1")
+ monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
+
+ options = [IndexableNoLen()]
+ result = outsim_app.select(options, "Choice? ")
+ assert result == "value"
out = outsim_app.stdout.getvalue()
- assert out.rstrip().endswith('^C')
+ assert "1. value" in out
class HelpNoDocstringApp(cmd2.Cmd):
greet_parser = cmd2.Cmd2ArgumentParser()
- greet_parser.add_argument('-s', '--shout', action="store_true", help="N00B EMULATION MODE")
+ greet_parser.add_argument("-s", "--shout", action="store_true", help="N00B EMULATION MODE")
@cmd2.with_argparser(greet_parser, with_unknown_args=True)
def do_greet(self, opts, arg) -> None:
- arg = ''.join(arg)
+ arg = "".join(arg)
if opts.shout:
arg = arg.upper()
- self.stdout.write(arg + '\n')
+ self.stdout.write(arg + "\n")
def test_help_with_no_docstring(capsys) -> None:
app = HelpNoDocstringApp()
- app.onecmd_plus_hooks('greet -h')
+ app.onecmd_plus_hooks("greet -h")
out, err = capsys.readouterr()
- assert err == ''
+ assert err == ""
assert (
out
== """Usage: greet [-h] [-s]
-Optional Arguments:
+Options:
-h, --help show this help message and exit
-s, --shout N00B EMULATION MODE
@@ -1749,17 +1876,17 @@ def test_help_with_no_docstring(capsys) -> None:
class MultilineApp(cmd2.Cmd):
def __init__(self, *args, **kwargs) -> None:
- super().__init__(*args, multiline_commands=['orate'], **kwargs)
+ super().__init__(*args, multiline_commands=["orate"], **kwargs)
orate_parser = cmd2.Cmd2ArgumentParser()
- orate_parser.add_argument('-s', '--shout', action="store_true", help="N00B EMULATION MODE")
+ orate_parser.add_argument("-s", "--shout", action="store_true", help="N00B EMULATION MODE")
@cmd2.with_argparser(orate_parser, with_unknown_args=True)
def do_orate(self, opts, arg) -> None:
- arg = ''.join(arg)
+ arg = "".join(arg)
if opts.shout:
arg = arg.upper()
- self.stdout.write(arg + '\n')
+ self.stdout.write(arg + "\n")
@pytest.fixture
@@ -1769,146 +1896,104 @@ def multiline_app():
def test_multiline_complete_empty_statement_raises_exception(multiline_app) -> None:
with pytest.raises(exceptions.EmptyStatement):
- multiline_app._complete_statement('')
+ multiline_app._complete_statement("")
-def test_multiline_complete_statement_without_terminator(multiline_app) -> None:
- # Mock out the input call so we don't actually wait for a user's response
- # on stdin when it looks for more input
- m = mock.MagicMock(name='input', return_value='\n')
- builtins.input = m
+def test_multiline_complete_statement_without_terminator(multiline_app, monkeypatch) -> None:
+ read_command_mock = mock.MagicMock(name="_read_command_line", return_value="\n")
+ monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock)
- command = 'orate'
- args = 'hello world'
- line = f'{command} {args}'
+ command = "orate"
+ args = "hello world"
+ line = f"{command} {args}"
statement = multiline_app._complete_statement(line)
assert statement == args
assert statement.command == command
- assert statement.multiline_command == command
+ assert statement.multiline_command
-def test_multiline_complete_statement_with_unclosed_quotes(multiline_app) -> None:
- # Mock out the input call so we don't actually wait for a user's response
- # on stdin when it looks for more input
- m = mock.MagicMock(name='input', side_effect=['quotes', '" now closed;'])
- builtins.input = m
+def test_multiline_complete_statement_with_unclosed_quotes(multiline_app, monkeypatch) -> None:
+ read_command_mock = mock.MagicMock(name="_read_command_line", side_effect=["quotes", '" now closed;'])
+ monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock)
line = 'orate hi "partially open'
statement = multiline_app._complete_statement(line)
assert statement == 'hi "partially open\nquotes\n" now closed'
- assert statement.command == 'orate'
- assert statement.multiline_command == 'orate'
- assert statement.terminator == ';'
+ assert statement.command == "orate"
+ assert statement.multiline_command
+ assert statement.terminator == ";"
-def test_multiline_input_line_to_statement(multiline_app) -> None:
+def test_multiline_input_line_to_statement(multiline_app, monkeypatch) -> None:
# Verify _input_line_to_statement saves the fully entered input line for multiline commands
+ read_command_mock = mock.MagicMock(name="_read_command_line", side_effect=["person", "\n"])
+ monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock)
- # Mock out the input call so we don't actually wait for a user's response
- # on stdin when it looks for more input
- m = mock.MagicMock(name='input', side_effect=['person', '\n'])
- builtins.input = m
-
- line = 'orate hi'
- statement = multiline_app._input_line_to_statement(line)
- assert statement.raw == 'orate hi\nperson\n'
- assert statement == 'hi person'
- assert statement.command == 'orate'
- assert statement.multiline_command == 'orate'
-
-
-def test_multiline_history_no_prior_history(multiline_app) -> None:
- # Test no existing history prior to typing the command
- m = mock.MagicMock(name='input', side_effect=['person', '\n'])
- builtins.input = m
-
- # Set orig_rl_history_length to 0 before the first line is typed.
- readline.clear_history()
- orig_rl_history_length = readline.get_current_history_length()
-
- line = "orate hi"
- readline.add_history(line)
- multiline_app._complete_statement(line, orig_rl_history_length=orig_rl_history_length)
-
- assert readline.get_current_history_length() == orig_rl_history_length + 1
- assert readline.get_history_item(1) == "orate hi person"
-
-
-def test_multiline_history_first_line_matches_prev_entry(multiline_app) -> None:
- # Test when first line of multiline command matches previous history entry
- m = mock.MagicMock(name='input', side_effect=['person', '\n'])
- builtins.input = m
-
- # Since the first line of our command matches the previous entry,
- # orig_rl_history_length is set before the first line is typed.
line = "orate hi"
- readline.clear_history()
- readline.add_history(line)
- orig_rl_history_length = readline.get_current_history_length()
-
- multiline_app._complete_statement(line, orig_rl_history_length=orig_rl_history_length)
+ statement = multiline_app._input_line_to_statement(line)
+ assert statement.raw == "orate hi\nperson\n\n"
+ assert statement == "hi person"
+ assert statement.command == "orate"
+ assert statement.multiline_command
- assert readline.get_current_history_length() == orig_rl_history_length + 1
- assert readline.get_history_item(1) == line
- assert readline.get_history_item(2) == "orate hi person"
+def test_multiline_history_added(multiline_app, monkeypatch) -> None:
+ # Test that multiline commands are added to history as a single item
+ run_cmd(multiline_app, "history --clear")
-def test_multiline_history_matches_prev_entry(multiline_app) -> None:
- # Test combined multiline command that matches previous history entry
- m = mock.MagicMock(name='input', side_effect=['person', '\n'])
- builtins.input = m
+ read_command_mock = mock.MagicMock(name="_read_command_line", side_effect=["person", "\n"])
+ monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock)
- readline.clear_history()
- readline.add_history("orate hi person")
- orig_rl_history_length = readline.get_current_history_length()
+ # run_cmd calls onecmd_plus_hooks which triggers history addition
+ run_cmd(multiline_app, "orate hi")
- line = "orate hi"
- readline.add_history(line)
- multiline_app._complete_statement(line, orig_rl_history_length=orig_rl_history_length)
+ assert len(multiline_app.history) == 1
+ assert multiline_app.history.get(1).raw == "orate hi\nperson\n\n"
- # Since it matches the previous history item, nothing was added to readline history
- assert readline.get_current_history_length() == orig_rl_history_length
- assert readline.get_history_item(1) == "orate hi person"
+def test_multiline_history_with_quotes(multiline_app, monkeypatch) -> None:
+ # Test combined multiline command with quotes is added to history correctly
+ run_cmd(multiline_app, "history --clear")
-def test_multiline_history_does_not_match_prev_entry(multiline_app) -> None:
- # Test combined multiline command that does not match previous history entry
- m = mock.MagicMock(name='input', side_effect=['person', '\n'])
- builtins.input = m
+ read_command_mock = mock.MagicMock(name="_read_command_line", side_effect=[" and spaces ", ' "', " in", "quotes.", ";"])
+ monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock)
- readline.clear_history()
- readline.add_history("no match")
- orig_rl_history_length = readline.get_current_history_length()
+ line = 'orate Look, "There are newlines'
+ run_cmd(multiline_app, line)
- line = "orate hi"
- readline.add_history(line)
- multiline_app._complete_statement(line, orig_rl_history_length=orig_rl_history_length)
+ assert len(multiline_app.history) == 1
+ history_item = multiline_app.history.get(1)
+ history_lines = history_item.raw.splitlines()
+ assert history_lines[0] == 'orate Look, "There are newlines'
+ assert history_lines[1] == " and spaces "
+ assert history_lines[2] == ' "'
+ assert history_lines[3] == " in"
+ assert history_lines[4] == "quotes."
+ assert history_lines[5] == ";"
- # Since it doesn't match the previous history item, it was added to readline history
- assert readline.get_current_history_length() == orig_rl_history_length + 1
- assert readline.get_history_item(1) == "no match"
- assert readline.get_history_item(2) == "orate hi person"
+def test_multiline_complete_statement_eof(multiline_app, monkeypatch):
+ # Mock poutput to verify it's called
+ poutput_mock = mock.MagicMock(name="poutput")
+ monkeypatch.setattr(multiline_app, "poutput", poutput_mock)
-def test_multiline_history_with_quotes(multiline_app) -> None:
- # Test combined multiline command with quotes
- m = mock.MagicMock(name='input', side_effect=[' and spaces ', ' "', ' in', 'quotes.', ';'])
- builtins.input = m
+ read_raw_mock = mock.MagicMock(name="_read_raw_input", side_effect=EOFError)
+ monkeypatch.setattr("cmd2.Cmd._read_raw_input", read_raw_mock)
- readline.clear_history()
- orig_rl_history_length = readline.get_current_history_length()
+ command = "orate"
+ args = "hello world"
+ line = f"{command} {args}"
- line = 'orate Look, "There are newlines'
- readline.add_history(line)
- multiline_app._complete_statement(line, orig_rl_history_length=orig_rl_history_length)
+ # This should call _read_command_line, get 'eof', set nextline to '\n',
+ # and then parse the line with the newline terminator.
+ statement = multiline_app._complete_statement(line)
- # Since spaces and newlines in quotes are preserved, this history entry spans multiple lines.
- assert readline.get_current_history_length() == orig_rl_history_length + 1
+ assert statement.command == command
+ assert statement.args == args
+ assert statement.terminator == "\n"
- history_lines = readline.get_history_item(1).splitlines()
- assert history_lines[0] == 'orate Look, "There are newlines'
- assert history_lines[1] == ' and spaces '
- assert history_lines[2] == ' " in quotes.;'
+ # Verify that poutput('\n') was called
+ poutput_mock.assert_called_once_with("\n")
class CommandResultApp(cmd2.Cmd):
@@ -1925,7 +2010,7 @@ def do_affirmative_no_data(self, arg) -> None:
self.last_result = cmd2.CommandResult(arg)
def do_negative_no_data(self, arg) -> None:
- self.last_result = cmd2.CommandResult('', arg)
+ self.last_result = cmd2.CommandResult("", arg)
@pytest.fixture
@@ -1934,177 +2019,205 @@ def commandresult_app():
def test_commandresult_truthy(commandresult_app) -> None:
- arg = 'foo'
- run_cmd(commandresult_app, f'affirmative {arg}')
+ arg = "foo"
+ run_cmd(commandresult_app, f"affirmative {arg}")
assert commandresult_app.last_result
assert commandresult_app.last_result == cmd2.CommandResult(arg, data=True)
- run_cmd(commandresult_app, f'affirmative_no_data {arg}')
+ run_cmd(commandresult_app, f"affirmative_no_data {arg}")
assert commandresult_app.last_result
assert commandresult_app.last_result == cmd2.CommandResult(arg)
def test_commandresult_falsy(commandresult_app) -> None:
- arg = 'bar'
- run_cmd(commandresult_app, f'negative {arg}')
+ arg = "bar"
+ run_cmd(commandresult_app, f"negative {arg}")
assert not commandresult_app.last_result
assert commandresult_app.last_result == cmd2.CommandResult(arg, data=False)
- run_cmd(commandresult_app, f'negative_no_data {arg}')
+ run_cmd(commandresult_app, f"negative_no_data {arg}")
assert not commandresult_app.last_result
- assert commandresult_app.last_result == cmd2.CommandResult('', arg)
+ assert commandresult_app.last_result == cmd2.CommandResult("", arg)
-@pytest.mark.skipif(sys.platform.startswith('win'), reason="Test is problematic on GitHub Actions Windows runners")
+@pytest.mark.skipif(sys.platform.startswith("win"), reason="Test is problematic on GitHub Actions Windows runners")
def test_is_text_file_bad_input(base_app) -> None:
# Test with a non-existent file
with pytest.raises(FileNotFoundError):
- utils.is_text_file('does_not_exist.txt')
+ utils.is_text_file("does_not_exist.txt")
# Test with a directory
with pytest.raises(IsADirectoryError):
- utils.is_text_file('.')
+ utils.is_text_file(".")
-def test_eof(base_app) -> None:
- # Only thing to verify is that it returns True
- assert base_app.do_eof('')
- assert base_app.last_result is True
+def test__eof(base_app) -> None:
+ base_app.do_quit = mock.MagicMock(return_value=True)
+ assert base_app.do__eof("")
+ base_app.do_quit.assert_called_once_with("")
def test_quit(base_app) -> None:
- # Only thing to verify is that it returns True
- assert base_app.do_quit('')
+ assert base_app.do_quit("")
assert base_app.last_result is True
def test_echo(capsys) -> None:
app = cmd2.Cmd()
app.echo = True
- commands = ['help history']
+ commands = ["help history"]
app.runcmds_plus_hooks(commands)
out, _err = capsys.readouterr()
- assert out.startswith(f'{app.prompt}{commands[0]}\nUsage: history')
+ assert out.startswith(f"{app.prompt}{commands[0]}\nUsage: history")
-def test_read_input_rawinput_true(capsys, monkeypatch) -> None:
- prompt_str = 'the_prompt'
- input_str = 'some input'
-
- app = cmd2.Cmd()
- app.use_rawinput = True
-
- # Mock out input() to return input_str
- monkeypatch.setattr("builtins.input", lambda *args: input_str)
-
- # isatty is True
- with mock.patch('sys.stdin.isatty', mock.MagicMock(name='isatty', return_value=True)):
- line = app.read_input(prompt_str)
- assert line == input_str
-
- # Run custom history code
- readline.add_history('old_history')
- custom_history = ['cmd1', 'cmd2']
- line = app.read_input(prompt_str, history=custom_history, completion_mode=cmd2.CompletionMode.NONE)
- assert line == input_str
- readline.clear_history()
-
- # Run all completion modes
- line = app.read_input(prompt_str, completion_mode=cmd2.CompletionMode.NONE)
- assert line == input_str
-
- line = app.read_input(prompt_str, completion_mode=cmd2.CompletionMode.COMMANDS)
- assert line == input_str
-
- # custom choices
- custom_choices = ['choice1', 'choice2']
- line = app.read_input(prompt_str, completion_mode=cmd2.CompletionMode.CUSTOM, choices=custom_choices)
- assert line == input_str
-
- # custom choices_provider
- line = app.read_input(
- prompt_str, completion_mode=cmd2.CompletionMode.CUSTOM, choices_provider=cmd2.Cmd.get_all_commands
+@pytest.mark.skipif(
+ sys.platform.startswith("win"),
+ reason="Don't have a real Windows console with how we are currently running tests in GitHub Actions",
+)
+def test_read_raw_input_tty(base_app: cmd2.Cmd) -> None:
+ with create_pipe_input() as pipe_input:
+ base_app.main_session = PromptSession(
+ input=pipe_input,
+ output=DummyOutput(),
+ history=base_app.main_session.history,
+ completer=base_app.main_session.completer,
)
- assert line == input_str
-
- # custom completer
- line = app.read_input(prompt_str, completion_mode=cmd2.CompletionMode.CUSTOM, completer=cmd2.Cmd.path_complete)
- assert line == input_str
-
- # custom parser
- line = app.read_input(prompt_str, completion_mode=cmd2.CompletionMode.CUSTOM, parser=cmd2.Cmd2ArgumentParser())
- assert line == input_str
-
- # isatty is False
- with mock.patch('sys.stdin.isatty', mock.MagicMock(name='isatty', return_value=False)):
- # echo True
- app.echo = True
- line = app.read_input(prompt_str)
- out, _err = capsys.readouterr()
- assert line == input_str
- assert out == f"{prompt_str}{input_str}\n"
-
- # echo False
- app.echo = False
- line = app.read_input(prompt_str)
- out, _err = capsys.readouterr()
- assert line == input_str
- assert not out
+ pipe_input.send_text("foo\n")
+ result = base_app._read_raw_input("prompt> ", base_app.main_session)
+ assert result == "foo"
-def test_read_input_rawinput_false(capsys, monkeypatch) -> None:
- prompt_str = 'the_prompt'
- input_str = 'some input'
- def make_app(isatty: bool, empty_input: bool = False):
- """Make a cmd2 app with a custom stdin"""
- app_input_str = '' if empty_input else input_str
+def test_read_raw_input_interactive_pipe(capsys) -> None:
+ prompt = "prompt> "
+ app = cmd2.Cmd(stdin=io.StringIO("input from pipe\n"))
+ app.interactive_pipe = True
+ result = app._read_raw_input(prompt, app.main_session)
+ assert result == "input from pipe"
- fakein = io.StringIO(f'{app_input_str}')
- fakein.isatty = mock.MagicMock(name='isatty', return_value=isatty)
+ # In interactive mode, _read_raw_input() prints the prompt.
+ captured = capsys.readouterr()
+ assert captured.out == prompt
- new_app = cmd2.Cmd(stdin=fakein)
- new_app.use_rawinput = False
- return new_app
- # isatty True
- app = make_app(isatty=True)
- line = app.read_input(prompt_str)
- out, _err = capsys.readouterr()
- assert line == input_str
- assert out == prompt_str
+def test_read_raw_input_non_interactive_pipe_echo_off(capsys) -> None:
+ prompt = "prompt> "
+ app = cmd2.Cmd(stdin=io.StringIO("input from pipe\n"))
+ app.interactive_pipe = False
+ app.echo = False
+ result = app._read_raw_input(prompt, app.main_session)
+ assert result == "input from pipe"
+
+ # When not echoing in non-interactive mode, _read_raw_input() prints nothing.
+ captured = capsys.readouterr()
+ assert not captured.out
- # isatty True, empty input
- app = make_app(isatty=True, empty_input=True)
- line = app.read_input(prompt_str)
- out, _err = capsys.readouterr()
- assert line == 'eof'
- assert out == prompt_str
- # isatty is False, echo is True
- app = make_app(isatty=False)
+def test_read_raw_input_non_interactive_pipe_echo_on(capsys) -> None:
+ prompt = "prompt> "
+ app = cmd2.Cmd(stdin=io.StringIO("input from pipe\n"))
+ app.interactive_pipe = False
app.echo = True
- line = app.read_input(prompt_str)
- out, _err = capsys.readouterr()
- assert line == input_str
- assert out == f"{prompt_str}{input_str}\n"
+ result = app._read_raw_input(prompt, app.main_session)
+ assert result == "input from pipe"
- # isatty is False, echo is False
- app = make_app(isatty=False)
- app.echo = False
- line = app.read_input(prompt_str)
- out, _err = capsys.readouterr()
- assert line == input_str
- assert not out
+ # When echoing in non-interactive mode, _read_raw_input() prints the prompt and input text.
+ captured = capsys.readouterr()
+ assert f"{prompt}input from pipe\n" == captured.out
- # isatty is False, empty input
- app = make_app(isatty=False, empty_input=True)
- line = app.read_input(prompt_str)
- out, _err = capsys.readouterr()
- assert line == 'eof'
- assert not out
+
+def test_read_raw_input_eof() -> None:
+ app = cmd2.Cmd(stdin=io.StringIO(""))
+ with pytest.raises(EOFError):
+ app._read_raw_input("prompt> ", app.main_session)
+
+
+def test_resolve_completer_none(base_app: cmd2.Cmd) -> None:
+ completer = base_app._resolve_completer()
+ assert isinstance(completer, DummyCompleter)
+
+
+def test_resolve_completer_with_choices(base_app: cmd2.Cmd) -> None:
+ from cmd2.pt_utils import Cmd2Completer
+
+ choices = ["apple", "banana", "cherry"]
+ completer = base_app._resolve_completer(choices=choices)
+ assert isinstance(completer, Cmd2Completer)
+
+ # Verify contents
+ settings = completer.custom_settings
+ assert settings is not None
+
+ action = settings.parser._actions[-1]
+ assert action.choices == choices
+ assert not settings.preserve_quotes
+
+
+def test_resolve_completer_with_choices_provider(base_app: cmd2.Cmd) -> None:
+ from cmd2.pt_utils import Cmd2Completer
+
+ mock_provider = mock.MagicMock()
+ completer = base_app._resolve_completer(choices_provider=mock_provider)
+ assert isinstance(completer, Cmd2Completer)
+
+ # Verify contents
+ settings = completer.custom_settings
+ assert settings is not None
+
+ action = settings.parser._actions[-1]
+ assert action.get_choices_provider() == mock_provider
+ assert not settings.preserve_quotes
+
+
+def test_resolve_completer_with_completer(base_app: cmd2.Cmd) -> None:
+ """Verify that providing choices creates a Cmd2Completer with a generated parser."""
+ from cmd2.pt_utils import Cmd2Completer
+
+ mock_completer = mock.MagicMock()
+ completer = base_app._resolve_completer(completer=mock_completer)
+ assert isinstance(completer, Cmd2Completer)
+
+ # Verify contents
+ settings = completer.custom_settings
+ assert settings is not None
+
+ action = settings.parser._actions[-1]
+ assert action.get_completer() == mock_completer
+ assert not settings.preserve_quotes
+
+
+def test_resolve_completer_with_parser(base_app: cmd2.Cmd) -> None:
+ from cmd2.pt_utils import Cmd2Completer
+
+ mock_parser = mock.MagicMock()
+ completer = base_app._resolve_completer(parser=mock_parser)
+ assert isinstance(completer, Cmd2Completer)
+
+ # Verify contents
+ settings = completer.custom_settings
+ assert settings is not None
+
+ assert settings.parser == mock_parser
+ assert not settings.preserve_quotes
+
+
+def test_resolve_completer_with_bad_input(base_app: cmd2.Cmd) -> None:
+ mock_provider = mock.MagicMock()
+ mock_completer = mock.MagicMock()
+ mock_parser = mock.MagicMock()
+
+ with pytest.raises(ValueError) as excinfo: # noqa: PT011
+ base_app._resolve_completer(
+ choices=[],
+ choices_provider=mock_provider,
+ completer=mock_completer,
+ parser=mock_parser,
+ )
+
+ assert "None of the following parameters can be used alongside a parser" in str(excinfo.value)
def test_custom_stdout() -> None:
@@ -2115,26 +2228,169 @@ def test_custom_stdout() -> None:
my_app = cmd2.Cmd(stdout=custom_output)
# Simulate a command
- my_app.onecmd('help')
+ my_app.onecmd("help")
# Retrieve the output from the custom_output buffer
captured_output = custom_output.getvalue()
- assert 'history' in captured_output
+ assert "history" in captured_output
def test_read_command_line_eof(base_app, monkeypatch) -> None:
- read_input_mock = mock.MagicMock(name='read_input', side_effect=EOFError)
- monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
+ """Test that _read_command_line passes up EOFErrors."""
+ read_raw_mock = mock.MagicMock(name="_read_raw_input", side_effect=EOFError)
+ monkeypatch.setattr("cmd2.Cmd._read_raw_input", read_raw_mock)
+
+ with pytest.raises(EOFError):
+ base_app._read_command_line("Prompt> ")
+
+
+def test_read_input_eof(base_app, monkeypatch) -> None:
+ """Test that read_input passes up EOFErrors."""
+ read_raw_mock = mock.MagicMock(name="_read_raw_input", side_effect=EOFError)
+ monkeypatch.setattr("cmd2.Cmd._read_raw_input", read_raw_mock)
+
+ with pytest.raises(EOFError):
+ base_app.read_input("Prompt> ")
+
+
+def test_read_secret(base_app, monkeypatch):
+ """Test read_secret passes is_password=True to _read_raw_input."""
+ with mock.patch.object(base_app, "_read_raw_input") as mock_reader:
+ mock_reader.return_value = "my_secret"
+
+ secret = base_app.read_secret("Secret: ")
+
+ assert secret == "my_secret"
+ # Verify it called _read_raw_input with is_password=True
+ args, kwargs = mock_reader.call_args
+ assert args[0] == "Secret: "
+ assert kwargs["is_password"] is True
+
+
+def test_read_secret_eof(base_app, monkeypatch):
+ """Test that read_secret passes up EOFErrors."""
+ read_raw_mock = mock.MagicMock(name="_read_raw_input", side_effect=EOFError)
+ monkeypatch.setattr("cmd2.Cmd._read_raw_input", read_raw_mock)
+
+ with pytest.raises(EOFError):
+ base_app.read_secret("Secret: ")
+
+
+def test_read_input_passes_all_arguments_to_resolver(base_app):
+ mock_choices = ["choice1", "choice2"]
+ mock_provider = mock.MagicMock(name="provider")
+ mock_completer = mock.MagicMock(name="completer")
+ mock_parser = mock.MagicMock(name="parser")
+
+ with (
+ mock.patch.object(base_app, "_resolve_completer") as mock_resolver,
+ mock.patch.object(base_app, "_read_raw_input") as mock_reader,
+ ):
+ mock_resolver.return_value = mock.MagicMock()
+ mock_reader.return_value = mock.MagicMock()
+
+ base_app.read_input(
+ prompt="Enter command: ",
+ history=["prev_cmd"],
+ preserve_quotes=True,
+ choices=mock_choices,
+ choices_provider=mock_provider,
+ completer=mock_completer,
+ parser=mock_parser,
+ )
+
+ mock_resolver.assert_called_once_with(
+ preserve_quotes=True,
+ choices=mock_choices,
+ choices_provider=mock_provider,
+ completer=mock_completer,
+ parser=mock_parser,
+ )
+
+
+def test_read_input_history_is_passed_to_session(base_app, monkeypatch, mocker):
+ mock_session_cls = mocker.patch("cmd2.cmd2.PromptSession")
+ mock_history_cls = mocker.patch("cmd2.cmd2.InMemoryHistory")
+ read_raw_mock = mocker.MagicMock(name="_read_raw_input", return_value="command")
+ monkeypatch.setattr("cmd2.Cmd._read_raw_input", read_raw_mock)
+
+ # Test with custom history first
+ my_history_list = ["help", "help alias", "help help"]
+ base_app.read_input(history=my_history_list)
+ mock_history_cls.assert_called_once_with(my_history_list)
+
+ called_kwargs = mock_session_cls.call_args.kwargs
+ assert called_kwargs["history"] == mock_history_cls.return_value
+
+ # Test with no history
+ mock_history_cls.reset_mock()
+ mock_session_cls.reset_mock()
+ my_history_list = ["help", "help alias", "help help"]
+ base_app.read_input(history=None)
+ mock_history_cls.assert_called_once_with()
+
+ called_kwargs = mock_session_cls.call_args.kwargs
+ assert called_kwargs["history"] == mock_history_cls.return_value
- line = base_app._read_command_line("Prompt> ")
- assert line == 'eof'
+
+def test_read_raw_input_session_usage_and_restore(base_app, mocker):
+ mock_session = mocker.MagicMock(name="temp_session")
+ base_app.main_session = mocker.MagicMock(name="main_session")
+
+ # Make sure we look like a terminal
+ mocker.patch.object(base_app, "_is_tty_session", return_value=True)
+
+ command_text = "help alias"
+
+ def check_and_return_input(*args, **kwargs):
+ # Check if the active session was the one we passed in
+ assert base_app.active_session == mock_session
+ return command_text
+
+ mock_session.prompt.side_effect = check_and_return_input
+
+ # Mock patch_stdout to prevent it from attempting to access the Windows
+ # console buffer in a Windows test environment.
+ with mock.patch("cmd2.cmd2.patch_stdout"):
+ result = base_app._read_raw_input("prompt> ", mock_session)
+
+ assert result == command_text
+
+ # Check if session.prompt() was called
+ mock_session.prompt.assert_called_once()
+
+ # Verify that active session was restored
+ assert base_app.active_session == base_app.main_session
+
+
+def test_read_raw_input_restores_on_error(base_app, mocker):
+ mock_session = mocker.MagicMock()
+ base_app.main_session = mocker.MagicMock(name="main_session")
+
+ # Make sure we look like a terminal
+ mocker.patch.object(base_app, "_is_tty_session", return_value=True)
+
+ def check_and_raise(*args, **kwargs):
+ # Check if the active session was the one we passed in
+ assert base_app.active_session == mock_session
+ raise KeyboardInterrupt
+
+ mock_session.prompt.side_effect = check_and_raise
+
+ # Mock patch_stdout to prevent it from attempting to access the Windows
+ # console buffer in a Windows test environment.
+ with mock.patch("cmd2.cmd2.patch_stdout"), pytest.raises(KeyboardInterrupt):
+ base_app._read_raw_input("prompt> ", mock_session)
+
+ # Even though an error occurred, the finally block restored active session
+ assert base_app.active_session == base_app.main_session
def test_poutput_string(outsim_app) -> None:
- msg = 'This is a test'
+ msg = "This is a test"
outsim_app.poutput(msg)
out = outsim_app.stdout.getvalue()
- expected = msg + '\n'
+ expected = msg + "\n"
assert out == expected
@@ -2142,15 +2398,15 @@ def test_poutput_zero(outsim_app) -> None:
msg = 0
outsim_app.poutput(msg)
out = outsim_app.stdout.getvalue()
- expected = str(msg) + '\n'
+ expected = str(msg) + "\n"
assert out == expected
def test_poutput_empty_string(outsim_app) -> None:
- msg = ''
+ msg = ""
outsim_app.poutput(msg)
out = outsim_app.stdout.getvalue()
- expected = '\n'
+ expected = "\n"
assert out == expected
@@ -2158,14 +2414,14 @@ def test_poutput_none(outsim_app) -> None:
msg = None
outsim_app.poutput(msg)
out = outsim_app.stdout.getvalue()
- expected = 'None\n'
+ expected = "None\n"
assert out == expected
@with_ansi_style(ru.AllowStyle.ALWAYS)
@pytest.mark.parametrize(
# Test a Rich Text and a string.
- ('styled_msg', 'expected'),
+ ("styled_msg", "expected"),
[
(Text("A Text object", style="cyan"), "\x1b[36mA Text object\x1b[0m\n"),
(su.stylize("A str object", style="blue"), "\x1b[34mA str object\x1b[0m\n"),
@@ -2180,7 +2436,7 @@ def test_poutput_ansi_always(styled_msg, expected, outsim_app) -> None:
@with_ansi_style(ru.AllowStyle.NEVER)
@pytest.mark.parametrize(
# Test a Rich Text and a string.
- ('styled_msg', 'expected'),
+ ("styled_msg", "expected"),
[
(Text("A Text object", style="cyan"), "A Text object\n"),
(su.stylize("A str object", style="blue"), "A str object\n"),
@@ -2195,12 +2451,12 @@ def test_poutput_ansi_never(styled_msg, expected, outsim_app) -> None:
@with_ansi_style(ru.AllowStyle.TERMINAL)
def test_poutput_ansi_terminal(outsim_app) -> None:
"""Test that AllowStyle.TERMINAL strips style when redirecting."""
- msg = 'testing...'
+ msg = "testing..."
colored_msg = Text(msg, style="cyan")
outsim_app._redirecting = True
outsim_app.poutput(colored_msg)
out = outsim_app.stdout.getvalue()
- expected = msg + '\n'
+ expected = msg + "\n"
assert out == expected
@@ -2227,20 +2483,20 @@ def test_poutput_emoji(outsim_app):
@with_ansi_style(ru.AllowStyle.ALWAYS)
def test_poutput_justify_and_width(outsim_app):
- rich_print_kwargs = RichPrintKwargs(justify="right", width=10)
+ rich_print_kwargs = {"width": 10}
# Use a styled-string when justifying to check if its display width is correct.
outsim_app.poutput(
su.stylize("Hello", style="blue"),
+ justify="right",
rich_print_kwargs=rich_print_kwargs,
)
out = outsim_app.stdout.getvalue()
assert out == " \x1b[34mHello\x1b[0m\n"
-@with_ansi_style(ru.AllowStyle.ALWAYS)
-def test_poutput_no_wrap_and_overflow(outsim_app):
- rich_print_kwargs = RichPrintKwargs(no_wrap=True, overflow="ellipsis", width=10)
+def test_rich_print_kwargs(outsim_app):
+ rich_print_kwargs = {"no_wrap": True, "overflow": "ellipsis", "width": 10}
outsim_app.poutput(
"This is longer than width.",
@@ -2254,35 +2510,153 @@ def test_poutput_no_wrap_and_overflow(outsim_app):
@with_ansi_style(ru.AllowStyle.ALWAYS)
def test_poutput_pretty_print(outsim_app):
"""Test that cmd2 passes objects through so they can be pretty-printed when highlighting is enabled."""
- dictionary = {1: 'hello', 2: 'person', 3: 'who', 4: 'codes'}
+ dictionary = {1: "hello", 2: "person", 3: "who", 4: "codes"}
outsim_app.poutput(dictionary, highlight=True)
out = outsim_app.stdout.getvalue()
assert out.startswith("\x1b[1m{\x1b[0m\x1b[1;36m1\x1b[0m: \x1b[32m'hello'\x1b[0m")
-@with_ansi_style(ru.AllowStyle.ALWAYS)
-def test_poutput_all_keyword_args(outsim_app):
- """Test that all fields in RichPrintKwargs are recognized by Rich's Console.print()."""
- rich_print_kwargs = RichPrintKwargs(
- justify="center",
- overflow="ellipsis",
- no_wrap=True,
- width=40,
- height=50,
- crop=False,
- new_line_start=True,
+@pytest.mark.parametrize(
+ "stream",
+ ["stdout", "stderr"],
+)
+@pytest.mark.parametrize(
+ ("emoji", "markup", "highlight"),
+ [
+ (True, True, True),
+ (False, False, False),
+ (True, False, True),
+ ],
+)
+def test_get_core_print_console_caching(base_app: cmd2.Cmd, stream: str, emoji: bool, markup: bool, highlight: bool) -> None:
+ """Test that printing consoles are cached and reused when settings match."""
+ file = sys.stderr if stream == "stderr" else base_app.stdout
+
+ # Initial creation
+ console1 = base_app._get_core_print_console(
+ file=file,
+ emoji=emoji,
+ markup=markup,
+ highlight=highlight,
)
- outsim_app.poutput(
- "My string",
- rich_print_kwargs=rich_print_kwargs,
+ # Verify it's in the cache
+ cached = getattr(base_app._console_cache, stream)
+ assert cached is console1
+
+ # Identical request should return the same object
+ console2 = base_app._get_core_print_console(
+ file=file,
+ emoji=emoji,
+ markup=markup,
+ highlight=highlight,
)
+ assert console2 is console1
- # Verify that something printed which means Console.print() didn't
- # raise a TypeError for an unexpected keyword argument.
- out = outsim_app.stdout.getvalue()
- assert "My string" in out
+
+@pytest.mark.parametrize(
+ "stream",
+ ["stdout", "stderr"],
+)
+def test_get_core_print_console_invalidation(base_app: cmd2.Cmd, stream: str) -> None:
+ """Test that changing settings, theme, or ALLOW_STYLE invalidates the cache."""
+ file = sys.stderr if stream == "stderr" else base_app.stdout
+
+ # Initial creation
+ console1 = base_app._get_core_print_console(
+ file=file,
+ emoji=True,
+ markup=True,
+ highlight=True,
+ )
+
+ # Changing emoji should create a new console
+ console2 = base_app._get_core_print_console(
+ file=file,
+ emoji=False,
+ markup=True,
+ highlight=True,
+ )
+ assert console2 is not console1
+ assert getattr(base_app._console_cache, stream) is console2
+
+ # Changing markup should create a new console
+ console3 = base_app._get_core_print_console(
+ file=file,
+ emoji=False,
+ markup=False,
+ highlight=True,
+ )
+ assert console3 is not console2
+ assert getattr(base_app._console_cache, stream) is console3
+
+ # Changing highlight should create a new console
+ console4 = base_app._get_core_print_console(
+ file=file,
+ emoji=False,
+ markup=False,
+ highlight=False,
+ )
+ assert console4 is not console3
+ assert getattr(base_app._console_cache, stream) is console4
+
+ # Changing ALLOW_STYLE should create a new console
+ orig_allow_style = ru.ALLOW_STYLE
+ try:
+ ru.ALLOW_STYLE = ru.AllowStyle.ALWAYS if orig_allow_style != ru.AllowStyle.ALWAYS else ru.AllowStyle.NEVER
+ console5 = base_app._get_core_print_console(
+ file=file,
+ emoji=False,
+ markup=False,
+ highlight=False,
+ )
+ assert console5 is not console4
+ assert getattr(base_app._console_cache, stream) is console5
+ finally:
+ ru.ALLOW_STYLE = orig_allow_style
+
+ # Changing the theme should create a new console
+ from rich.theme import Theme
+
+ old_theme = ru.get_theme()
+ try:
+ ru._APP_THEME = Theme()
+ console6 = base_app._get_core_print_console(
+ file=file,
+ emoji=False,
+ markup=False,
+ highlight=False,
+ )
+ assert console6 is not console5
+ assert getattr(base_app._console_cache, stream) is console6
+ finally:
+ ru._APP_THEME = old_theme
+
+
+def test_get_core_print_console_non_cached(base_app: cmd2.Cmd) -> None:
+ """Test that arbitrary file objects are not cached."""
+ file = io.StringIO()
+
+ console1 = base_app._get_core_print_console(
+ file=file,
+ emoji=True,
+ markup=True,
+ highlight=True,
+ )
+
+ # Cache for stdout/stderr should still be None (assuming they haven't been touched yet)
+ assert base_app._console_cache.stdout is None
+ assert base_app._console_cache.stderr is None
+
+ # A second request for the same file should still create a new object
+ console2 = base_app._get_core_print_console(
+ file=file,
+ emoji=True,
+ markup=True,
+ highlight=True,
+ )
+ assert console2 is not console1
def test_broken_pipe_error(outsim_app, monkeypatch, capsys):
@@ -2302,7 +2676,7 @@ def test_broken_pipe_error(outsim_app, monkeypatch, capsys):
invalid_command_name = [
'""', # Blank name
constants.COMMENT_CHAR,
- '!no_shortcut',
+ "!no_shortcut",
'">"',
'"no>pe"',
'"no spaces"',
@@ -2312,88 +2686,149 @@ def test_broken_pipe_error(outsim_app, monkeypatch, capsys):
]
-def test_get_alias_completion_items(base_app) -> None:
- run_cmd(base_app, 'alias create fake run_pyscript')
- run_cmd(base_app, 'alias create ls !ls -hal')
+def test_get_alias_choices(base_app: cmd2.Cmd) -> None:
+ run_cmd(base_app, "alias create fake run_pyscript")
+ run_cmd(base_app, "alias create ls !ls -hal")
+
+ choices = base_app._get_alias_choices()
+
+ aliases = base_app.aliases
+ assert len(choices) == len(aliases)
+
+ for cur_choice in choices:
+ assert cur_choice.text in aliases
+ assert cur_choice.display_meta == aliases[cur_choice.text]
+ assert cur_choice.table_data == (aliases[cur_choice.text],)
+
+
+def test_get_macro_choices(base_app: cmd2.Cmd) -> None:
+ run_cmd(base_app, "macro create foo !echo foo")
+ run_cmd(base_app, "macro create bar !echo bar")
+
+ choices = base_app._get_macro_choices()
+
+ macros = base_app.macros
+ assert len(choices) == len(macros)
+
+ for cur_choice in choices:
+ assert cur_choice.text in macros
+ assert cur_choice.display_meta == macros[cur_choice.text].value
+ assert cur_choice.table_data == (macros[cur_choice.text].value,)
+
+
+def test_get_commands_aliases_and_macros_choices(base_app: cmd2.Cmd) -> None:
+ # Add an alias and a macro
+ run_cmd(base_app, "alias create fake_alias help")
+ run_cmd(base_app, "macro create fake_macro !echo macro")
+
+ # Add a command without a docstring
+ import types
+
+ def do_no_doc(self, arg):
+ pass
+
+ base_app.do_no_doc = types.MethodType(do_no_doc, base_app)
- results = base_app._get_alias_completion_items()
- assert len(results) == len(base_app.aliases)
+ choices = base_app._get_commands_aliases_and_macros_choices()
- for cur_res in results:
- assert cur_res in base_app.aliases
- # Strip trailing spaces from table output
- assert cur_res.descriptive_data[0].rstrip() == base_app.aliases[cur_res]
+ # All visible commands + our new command + alias + macro
+ expected_count = len(base_app.get_visible_commands()) + len(base_app.aliases) + len(base_app.macros)
+ assert len(choices) == expected_count
+ # Verify alias
+ alias_item = next((item for item in choices if item == "fake_alias"), None)
+ assert alias_item is not None
+ assert alias_item.display_meta == "Alias for: help"
-def test_get_macro_completion_items(base_app) -> None:
- run_cmd(base_app, 'macro create foo !echo foo')
- run_cmd(base_app, 'macro create bar !echo bar')
+ # Verify macro
+ macro_item = next((item for item in choices if item == "fake_macro"), None)
+ assert macro_item is not None
+ assert macro_item.display_meta == "Macro: !echo macro"
- results = base_app._get_macro_completion_items()
- assert len(results) == len(base_app.macros)
+ # Verify command with docstring (help)
+ help_item = next((item for item in choices if item == "help"), None)
+ assert help_item is not None
+ # First line of help docstring
+ assert "List available commands" in help_item.display_meta
- for cur_res in results:
- assert cur_res in base_app.macros
- # Strip trailing spaces from table output
- assert cur_res.descriptive_data[0].rstrip() == base_app.macros[cur_res].value
+ # Verify command without docstring
+ no_doc_item = next((item for item in choices if item == "no_doc"), None)
+ assert no_doc_item is not None
+ assert no_doc_item.display_meta == ""
-def test_get_settable_completion_items(base_app) -> None:
- results = base_app._get_settable_completion_items()
- assert len(results) == len(base_app.settables)
+def test_get_settable_choices(base_app: cmd2.Cmd) -> None:
+ choices = base_app._get_settable_choices()
+ assert len(choices) == len(base_app.settables)
- for cur_res in results:
- cur_settable = base_app.settables.get(cur_res)
+ for cur_choice in choices:
+ cur_settable = base_app.settables.get(cur_choice.text)
assert cur_settable is not None
- # These CompletionItem descriptions are a two column table (Settable Value and Settable Description)
- # First check if the description text starts with the value
+ # Convert fields so we can compare them
str_value = str(cur_settable.value)
- assert cur_res.descriptive_data[0].startswith(str_value)
- # The second column is likely to have wrapped long text. So we will just examine the
- # first couple characters to look for the Settable's description.
- assert cur_settable.description[0:10] in cur_res.descriptive_data[1]
+ choice_value = cur_choice.table_data[0]
+ if isinstance(choice_value, Text):
+ choice_value = ru.rich_text_to_string(choice_value)
+
+ choice_description = cur_choice.table_data[1]
+ if isinstance(choice_description, Text):
+ choice_description = ru.rich_text_to_string(choice_description)
+
+ assert str_value in cur_choice.display_meta
+ assert choice_value == str_value
+ assert choice_description == cur_settable.description
def test_alias_no_subcommand(base_app) -> None:
- _out, err = run_cmd(base_app, 'alias')
+ _out, err = run_cmd(base_app, "alias")
assert "Usage: alias [-h]" in err[0]
assert "Error: the following arguments are required: SUBCOMMAND" in err[1]
def test_alias_create(base_app) -> None:
# Create the alias
- out, err = run_cmd(base_app, 'alias create fake run_pyscript')
+ out, err = run_cmd(base_app, "alias create fake run_pyscript")
assert out == normalize("Alias 'fake' created")
assert base_app.last_result is True
# Use the alias
- out, err = run_cmd(base_app, 'fake')
+ out, err = run_cmd(base_app, "fake")
assert "the following arguments are required: script_path" in err[1]
# See a list of aliases
- out, err = run_cmd(base_app, 'alias list')
- assert out == normalize('alias create fake run_pyscript')
+ out, err = run_cmd(base_app, "alias list")
+ assert out == normalize("alias create fake run_pyscript")
assert len(base_app.last_result) == len(base_app.aliases)
- assert base_app.last_result['fake'] == "run_pyscript"
+ assert base_app.last_result["fake"] == "run_pyscript"
# Look up the new alias
- out, err = run_cmd(base_app, 'alias list fake')
- assert out == normalize('alias create fake run_pyscript')
+ out, err = run_cmd(base_app, "alias list fake")
+ assert out == normalize("alias create fake run_pyscript")
assert len(base_app.last_result) == 1
- assert base_app.last_result['fake'] == "run_pyscript"
+ assert base_app.last_result["fake"] == "run_pyscript"
# Overwrite alias
- out, err = run_cmd(base_app, 'alias create fake help')
+ out, err = run_cmd(base_app, "alias create fake help")
assert out == normalize("Alias 'fake' overwritten")
assert base_app.last_result is True
# Look up the updated alias
- out, err = run_cmd(base_app, 'alias list fake')
- assert out == normalize('alias create fake help')
+ out, err = run_cmd(base_app, "alias list fake")
+ assert out == normalize("alias create fake help")
assert len(base_app.last_result) == 1
- assert base_app.last_result['fake'] == "help"
+ assert base_app.last_result["fake"] == "help"
+
+
+def test_nested_alias_usage(base_app) -> None:
+ run_cmd(base_app, "alias create nested help")
+ run_cmd(base_app, "alias create wrapper nested")
+ nested_out = run_cmd(base_app, "nested")
+ wrapper_out = run_cmd(base_app, "wrapper")
+ help_out = run_cmd(base_app, "help")
+
+ assert nested_out == wrapper_out == help_out
def test_alias_create_with_quoted_tokens(base_app) -> None:
@@ -2407,84 +2842,84 @@ def test_alias_create_with_quoted_tokens(base_app) -> None:
assert out == normalize("Alias 'fake' created")
# Look up the new alias and verify all quotes are preserved
- out, _err = run_cmd(base_app, 'alias list fake')
+ out, _err = run_cmd(base_app, "alias list fake")
assert out == normalize(create_command)
assert len(base_app.last_result) == 1
assert base_app.last_result[alias_name] == alias_command
-@pytest.mark.parametrize('alias_name', invalid_command_name)
+@pytest.mark.parametrize("alias_name", invalid_command_name)
def test_alias_create_invalid_name(base_app, alias_name, capsys) -> None:
- _out, err = run_cmd(base_app, f'alias create {alias_name} help')
+ _out, err = run_cmd(base_app, f"alias create {alias_name} help")
assert "Invalid alias name" in err[0]
assert base_app.last_result is False
def test_alias_create_with_command_name(base_app) -> None:
- _out, err = run_cmd(base_app, 'alias create help stuff')
+ _out, err = run_cmd(base_app, "alias create help stuff")
assert "Alias cannot have the same name as a command" in err[0]
assert base_app.last_result is False
def test_alias_create_with_macro_name(base_app) -> None:
macro = "my_macro"
- run_cmd(base_app, f'macro create {macro} help')
- _out, err = run_cmd(base_app, f'alias create {macro} help')
+ run_cmd(base_app, f"macro create {macro} help")
+ _out, err = run_cmd(base_app, f"alias create {macro} help")
assert "Alias cannot have the same name as a macro" in err[0]
assert base_app.last_result is False
def test_alias_that_resolves_into_comment(base_app) -> None:
# Create the alias
- out, err = run_cmd(base_app, 'alias create fake ' + constants.COMMENT_CHAR + ' blah blah')
+ out, err = run_cmd(base_app, "alias create fake " + constants.COMMENT_CHAR + " blah blah")
assert out == normalize("Alias 'fake' created")
# Use the alias
- out, err = run_cmd(base_app, 'fake')
+ out, err = run_cmd(base_app, "fake")
assert not out
assert not err
def test_alias_list_invalid_alias(base_app) -> None:
# Look up invalid alias
- _out, err = run_cmd(base_app, 'alias list invalid')
+ _out, err = run_cmd(base_app, "alias list invalid")
assert "Alias 'invalid' not found" in err[0]
assert base_app.last_result == {}
def test_alias_delete(base_app) -> None:
# Create an alias
- run_cmd(base_app, 'alias create fake run_pyscript')
+ run_cmd(base_app, "alias create fake run_pyscript")
# Delete the alias
- out, _err = run_cmd(base_app, 'alias delete fake')
+ out, _err = run_cmd(base_app, "alias delete fake")
assert out == normalize("Alias 'fake' deleted")
assert base_app.last_result is True
def test_alias_delete_all(base_app) -> None:
- out, _err = run_cmd(base_app, 'alias delete --all')
+ out, _err = run_cmd(base_app, "alias delete --all")
assert out == normalize("All aliases deleted")
assert base_app.last_result is True
def test_alias_delete_non_existing(base_app) -> None:
- _out, err = run_cmd(base_app, 'alias delete fake')
+ _out, err = run_cmd(base_app, "alias delete fake")
assert "Alias 'fake' does not exist" in err[0]
assert base_app.last_result is True
def test_alias_delete_no_name(base_app) -> None:
- _out, err = run_cmd(base_app, 'alias delete')
+ _out, err = run_cmd(base_app, "alias delete")
assert "Either --all or alias name(s)" in err[0]
assert base_app.last_result is False
def test_multiple_aliases(base_app) -> None:
- alias1 = 'h1'
- alias2 = 'h2'
- run_cmd(base_app, f'alias create {alias1} help')
- run_cmd(base_app, f'alias create {alias2} help -v')
+ alias1 = "h1"
+ alias2 = "h2"
+ run_cmd(base_app, f"alias create {alias1} help")
+ run_cmd(base_app, f"alias create {alias2} help -v")
out, _err = run_cmd(base_app, alias1)
verify_help_text(base_app, out)
@@ -2493,43 +2928,43 @@ def test_multiple_aliases(base_app) -> None:
def test_macro_no_subcommand(base_app) -> None:
- _out, err = run_cmd(base_app, 'macro')
+ _out, err = run_cmd(base_app, "macro")
assert "Usage: macro [-h]" in err[0]
assert "Error: the following arguments are required: SUBCOMMAND" in err[1]
def test_macro_create(base_app) -> None:
# Create the macro
- out, err = run_cmd(base_app, 'macro create fake run_pyscript')
+ out, err = run_cmd(base_app, "macro create fake run_pyscript")
assert out == normalize("Macro 'fake' created")
assert base_app.last_result is True
# Use the macro
- out, err = run_cmd(base_app, 'fake')
+ out, err = run_cmd(base_app, "fake")
assert "the following arguments are required: script_path" in err[1]
# See a list of macros
- out, err = run_cmd(base_app, 'macro list')
- assert out == normalize('macro create fake run_pyscript')
+ out, err = run_cmd(base_app, "macro list")
+ assert out == normalize("macro create fake run_pyscript")
assert len(base_app.last_result) == len(base_app.macros)
- assert base_app.last_result['fake'] == "run_pyscript"
+ assert base_app.last_result["fake"] == "run_pyscript"
# Look up the new macro
- out, err = run_cmd(base_app, 'macro list fake')
- assert out == normalize('macro create fake run_pyscript')
+ out, err = run_cmd(base_app, "macro list fake")
+ assert out == normalize("macro create fake run_pyscript")
assert len(base_app.last_result) == 1
- assert base_app.last_result['fake'] == "run_pyscript"
+ assert base_app.last_result["fake"] == "run_pyscript"
# Overwrite macro
- out, err = run_cmd(base_app, 'macro create fake help')
+ out, err = run_cmd(base_app, "macro create fake help")
assert out == normalize("Macro 'fake' overwritten")
assert base_app.last_result is True
# Look up the updated macro
- out, err = run_cmd(base_app, 'macro list fake')
- assert out == normalize('macro create fake help')
+ out, err = run_cmd(base_app, "macro list fake")
+ assert out == normalize("macro create fake help")
assert len(base_app.last_result) == 1
- assert base_app.last_result['fake'] == "help"
+ assert base_app.last_result["fake"] == "help"
def test_macro_create_with_quoted_tokens(base_app) -> None:
@@ -2543,154 +2978,167 @@ def test_macro_create_with_quoted_tokens(base_app) -> None:
assert out == normalize("Macro 'fake' created")
# Look up the new macro and verify all quotes are preserved
- out, _err = run_cmd(base_app, 'macro list fake')
+ out, _err = run_cmd(base_app, "macro list fake")
assert out == normalize(create_command)
assert len(base_app.last_result) == 1
assert base_app.last_result[macro_name] == macro_command
-@pytest.mark.parametrize('macro_name', invalid_command_name)
+@pytest.mark.parametrize("macro_name", invalid_command_name)
def test_macro_create_invalid_name(base_app, macro_name) -> None:
- _out, err = run_cmd(base_app, f'macro create {macro_name} help')
+ _out, err = run_cmd(base_app, f"macro create {macro_name} help")
assert "Invalid macro name" in err[0]
assert base_app.last_result is False
def test_macro_create_with_command_name(base_app) -> None:
- _out, err = run_cmd(base_app, 'macro create help stuff')
+ _out, err = run_cmd(base_app, "macro create help stuff")
assert "Macro cannot have the same name as a command" in err[0]
assert base_app.last_result is False
def test_macro_create_with_alias_name(base_app) -> None:
macro = "my_macro"
- run_cmd(base_app, f'alias create {macro} help')
- _out, err = run_cmd(base_app, f'macro create {macro} help')
+ run_cmd(base_app, f"alias create {macro} help")
+ _out, err = run_cmd(base_app, f"macro create {macro} help")
assert "Macro cannot have the same name as an alias" in err[0]
assert base_app.last_result is False
def test_macro_create_with_args(base_app) -> None:
# Create the macro
- out, _err = run_cmd(base_app, 'macro create fake {1} {2}')
+ out, _err = run_cmd(base_app, "macro create fake {1} {2}")
assert out == normalize("Macro 'fake' created")
# Run the macro
- out, _err = run_cmd(base_app, 'fake help -v')
+ out, _err = run_cmd(base_app, "fake help -v")
verify_help_text(base_app, out)
def test_macro_create_with_escaped_args(base_app) -> None:
# Create the macro
- out, err = run_cmd(base_app, 'macro create fake help {{1}}')
+ out, err = run_cmd(base_app, "macro create fake help {{1}}")
assert out == normalize("Macro 'fake' created")
# Run the macro
- out, err = run_cmd(base_app, 'fake')
- assert err[0].startswith('No help on {1}')
+ out, err = run_cmd(base_app, "fake")
+ assert err[0].startswith("No help on {1}")
def test_macro_usage_with_missing_args(base_app) -> None:
# Create the macro
- out, err = run_cmd(base_app, 'macro create fake help {1} {2}')
+ out, err = run_cmd(base_app, "macro create fake help {1} {2}")
assert out == normalize("Macro 'fake' created")
# Run the macro
- out, err = run_cmd(base_app, 'fake arg1')
+ out, err = run_cmd(base_app, "fake arg1")
assert "expects at least 2 arguments" in err[0]
-def test_macro_usage_with_exta_args(base_app) -> None:
+def test_macro_usage_with_extra_args(base_app) -> None:
# Create the macro
- out, _err = run_cmd(base_app, 'macro create fake help {1}')
+ out, _err = run_cmd(base_app, "macro create fake help {1}")
assert out == normalize("Macro 'fake' created")
# Run the macro
- out, _err = run_cmd(base_app, 'fake alias create')
+ out, _err = run_cmd(base_app, "fake alias create")
assert "Usage: alias create" in out[0]
+def test_nested_macro_usage(base_app) -> None:
+ run_cmd(base_app, "macro create nested help")
+ run_cmd(base_app, "macro create wrapper nested {1}")
+ nested_out = run_cmd(base_app, "nested")
+ help_out = run_cmd(base_app, "help")
+ assert nested_out == help_out
+
+ wrapper_out = run_cmd(base_app, "wrapper alias")
+ help_alias_out = run_cmd(base_app, "help alias")
+
+ assert wrapper_out == help_alias_out
+
+
def test_macro_create_with_missing_arg_nums(base_app) -> None:
# Create the macro
- _out, err = run_cmd(base_app, 'macro create fake help {1} {3}')
+ _out, err = run_cmd(base_app, "macro create fake help {1} {3}")
assert "Not all numbers between 1 and 3" in err[0]
assert base_app.last_result is False
def test_macro_create_with_invalid_arg_num(base_app) -> None:
# Create the macro
- _out, err = run_cmd(base_app, 'macro create fake help {1} {-1} {0}')
+ _out, err = run_cmd(base_app, "macro create fake help {1} {-1} {0}")
assert "Argument numbers must be greater than 0" in err[0]
assert base_app.last_result is False
def test_macro_create_with_unicode_numbered_arg(base_app) -> None:
# Create the macro expecting 1 argument
- out, err = run_cmd(base_app, 'macro create fake help {\N{ARABIC-INDIC DIGIT ONE}}')
+ out, err = run_cmd(base_app, "macro create fake help {\N{ARABIC-INDIC DIGIT ONE}}")
assert out == normalize("Macro 'fake' created")
# Run the macro
- out, err = run_cmd(base_app, 'fake')
+ out, err = run_cmd(base_app, "fake")
assert "expects at least 1 argument" in err[0]
def test_macro_create_with_missing_unicode_arg_nums(base_app) -> None:
- _out, err = run_cmd(base_app, 'macro create fake help {1} {\N{ARABIC-INDIC DIGIT THREE}}')
+ _out, err = run_cmd(base_app, "macro create fake help {1} {\N{ARABIC-INDIC DIGIT THREE}}")
assert "Not all numbers between 1 and 3" in err[0]
assert base_app.last_result is False
def test_macro_that_resolves_into_comment(base_app) -> None:
# Create the macro
- out, err = run_cmd(base_app, 'macro create fake {1} blah blah')
+ out, err = run_cmd(base_app, "macro create fake {1} blah blah")
assert out == normalize("Macro 'fake' created")
# Use the macro
- out, err = run_cmd(base_app, 'fake ' + constants.COMMENT_CHAR)
+ out, err = run_cmd(base_app, "fake " + constants.COMMENT_CHAR)
assert not out
assert not err
def test_macro_list_invalid_macro(base_app) -> None:
# Look up invalid macro
- _out, err = run_cmd(base_app, 'macro list invalid')
+ _out, err = run_cmd(base_app, "macro list invalid")
assert "Macro 'invalid' not found" in err[0]
assert base_app.last_result == {}
def test_macro_delete(base_app) -> None:
# Create an macro
- run_cmd(base_app, 'macro create fake run_pyscript')
+ run_cmd(base_app, "macro create fake run_pyscript")
# Delete the macro
- out, _err = run_cmd(base_app, 'macro delete fake')
+ out, _err = run_cmd(base_app, "macro delete fake")
assert out == normalize("Macro 'fake' deleted")
assert base_app.last_result is True
def test_macro_delete_all(base_app) -> None:
- out, _err = run_cmd(base_app, 'macro delete --all')
+ out, _err = run_cmd(base_app, "macro delete --all")
assert out == normalize("All macros deleted")
assert base_app.last_result is True
def test_macro_delete_non_existing(base_app) -> None:
- _out, err = run_cmd(base_app, 'macro delete fake')
+ _out, err = run_cmd(base_app, "macro delete fake")
assert "Macro 'fake' does not exist" in err[0]
assert base_app.last_result is True
def test_macro_delete_no_name(base_app) -> None:
- _out, err = run_cmd(base_app, 'macro delete')
+ _out, err = run_cmd(base_app, "macro delete")
assert "Either --all or macro name(s)" in err[0]
assert base_app.last_result is False
def test_multiple_macros(base_app) -> None:
- macro1 = 'h1'
- macro2 = 'h2'
- run_cmd(base_app, f'macro create {macro1} help')
- run_cmd(base_app, f'macro create {macro2} help -v')
+ macro1 = "h1"
+ macro2 = "h2"
+ run_cmd(base_app, f"macro create {macro1} help")
+ run_cmd(base_app, f"macro create {macro2} help -v")
out, _err = run_cmd(base_app, macro1)
verify_help_text(base_app, out)
@@ -2707,16 +3155,66 @@ def test_nonexistent_macro(base_app) -> None:
exception = None
try:
- base_app._resolve_macro(StatementParser().parse('fake'))
+ base_app._resolve_macro(StatementParser().parse("fake"))
except KeyError as e:
exception = e
assert exception is not None
+@pytest.mark.parametrize(
+ # The line of text and whether to continue prompting to finish a multiline command.
+ ("line", "should_continue"),
+ [
+ # Empty lines
+ ("", False),
+ (" ", False),
+ # Single-line commands
+ ("help", False),
+ ("help alias", False),
+ # Multi-line commands
+ ("orate", True),
+ ("orate;", False),
+ ("orate\n", False),
+ ("orate\narg", True),
+ ("orate\narg;", False),
+ ("orate\narg\n", False),
+ # Single-line macros
+ ("single_mac", False), # macro resolution error returns False (no arg passed)
+ ("single_mac arg", False),
+ # Multi-line macros
+ ("multi_mac", False), # macro resolution error returns False (no arg passed)
+ ("multi_mac arg", True),
+ ("multi_mac arg;", False),
+ ("multi_mac arg\n", False),
+ ("multi_mac\narg", True),
+ ("multi_mac\narg;", False),
+ ("multi_mac\narg\n", False),
+ # Nested multi-line macros
+ ("wrapper_mac", False), # macro resolution error returns False (no args passed)
+ ("wrapper_mac arg", False), # macro resolution error returns False (not enough args passed)
+ ("wrapper_mac arg arg2", True),
+ ("wrapper_mac arg\narg2;", False),
+ ],
+)
+def test_should_continue_multiline(multiline_app: MultilineApp, line: str, should_continue: bool) -> None:
+ mock_buffer = mock.MagicMock()
+ mock_buffer.text = line
+
+ mock_app = mock.MagicMock()
+ mock_app.current_buffer = mock_buffer
+
+ run_cmd(multiline_app, "macro create single_mac help {1}")
+ run_cmd(multiline_app, "macro create multi_mac orate {1}")
+ run_cmd(multiline_app, "macro create wrapper_mac multi_mac {1} {2}")
+
+ with mock.patch("cmd2.cmd2.get_app", return_value=mock_app):
+ assert multiline_app._should_continue_multiline() is should_continue
+
+
@with_ansi_style(ru.AllowStyle.ALWAYS)
def test_perror_style(base_app, capsys) -> None:
- msg = 'testing...'
+ msg = "testing..."
base_app.perror(msg)
_out, err = capsys.readouterr()
assert err == "\x1b[91mtesting...\x1b[0m\n"
@@ -2724,16 +3222,37 @@ def test_perror_style(base_app, capsys) -> None:
@with_ansi_style(ru.AllowStyle.ALWAYS)
def test_perror_no_style(base_app, capsys) -> None:
- msg = 'testing...'
- end = '\n'
+ msg = "testing..."
+ end = "\n"
base_app.perror(msg, style=None)
_out, err = capsys.readouterr()
assert err == msg + end
+@with_ansi_style(ru.AllowStyle.ALWAYS)
+def test_psuccess(outsim_app) -> None:
+ msg = "testing..."
+ end = "\n"
+ outsim_app.psuccess(msg)
+
+ expected = su.stylize(msg + end, style=Cmd2Style.SUCCESS)
+ assert outsim_app.stdout.getvalue() == expected
+
+
+@with_ansi_style(ru.AllowStyle.ALWAYS)
+def test_pwarning(base_app, capsys) -> None:
+ msg = "testing..."
+ end = "\n"
+ base_app.pwarning(msg)
+
+ expected = su.stylize(msg + end, style=Cmd2Style.WARNING)
+ _out, err = capsys.readouterr()
+ assert err == expected
+
+
@with_ansi_style(ru.AllowStyle.ALWAYS)
def test_pexcept_style(base_app, capsys) -> None:
- msg = Exception('testing...')
+ msg = Exception("testing...")
base_app.pexcept(msg)
_out, err = capsys.readouterr()
@@ -2743,14 +3262,14 @@ def test_pexcept_style(base_app, capsys) -> None:
@with_ansi_style(ru.AllowStyle.NEVER)
def test_pexcept_no_style(base_app, capsys) -> None:
- msg = Exception('testing...')
+ msg = Exception("testing...")
base_app.pexcept(msg)
_out, err = capsys.readouterr()
assert err.startswith("Exception: testing...")
-@pytest.mark.parametrize('chop', [True, False])
+@pytest.mark.parametrize("chop", [True, False])
def test_ppaged_with_pager(outsim_app, monkeypatch, chop) -> None:
"""Force ppaged() to run the pager by mocking an actual terminal state."""
@@ -2763,11 +3282,11 @@ def test_ppaged_with_pager(outsim_app, monkeypatch, chop) -> None:
stdout_mock.isatty.return_value = True
monkeypatch.setattr(outsim_app, "stdout", stdout_mock)
- if not sys.platform.startswith('win') and os.environ.get("TERM") is None:
- monkeypatch.setenv('TERM', 'simulated')
+ if not sys.platform.startswith("win") and os.environ.get("TERM") is None:
+ monkeypatch.setenv("TERM", "simulated")
# This will force ppaged to call Popen to run a pager
- popen_mock = mock.MagicMock(name='Popen')
+ popen_mock = mock.MagicMock(name="Popen")
monkeypatch.setattr("subprocess.Popen", popen_mock)
outsim_app.ppaged("Test", chop=chop)
@@ -2779,19 +3298,237 @@ def test_ppaged_with_pager(outsim_app, monkeypatch, chop) -> None:
def test_ppaged_no_pager(outsim_app) -> None:
"""Since we're not in a fully-functional terminal, ppaged() will just call poutput()."""
- msg = 'testing...'
- end = '\n'
+ msg = "testing..."
+ end = "\n"
outsim_app.ppaged(msg)
out = outsim_app.stdout.getvalue()
assert out == msg + end
-# we override cmd.parseline() so we always get consistent
+@pytest.mark.skipif(sys.platform.startswith("win"), reason="termios is not available on Windows")
+@pytest.mark.parametrize("has_tcsetpgrp", [True, False])
+def test_ppaged_terminal_restoration(outsim_app, monkeypatch, has_tcsetpgrp) -> None:
+ """Test terminal restoration in ppaged() after pager exits."""
+ # Make it look like we're in a terminal
+ stdin_mock = mock.MagicMock()
+ stdin_mock.isatty.return_value = True
+ stdin_mock.fileno.return_value = 0
+ monkeypatch.setattr(outsim_app, "stdin", stdin_mock)
+
+ stdout_mock = mock.MagicMock()
+ stdout_mock.isatty.return_value = True
+ monkeypatch.setattr(outsim_app, "stdout", stdout_mock)
+
+ if not sys.platform.startswith("win") and os.environ.get("TERM") is None:
+ monkeypatch.setenv("TERM", "simulated")
+
+ # Mock termios and signal since they are imported within the method
+ termios_mock = mock.MagicMock()
+ # The error attribute needs to be the actual exception for isinstance checks
+ import termios
+
+ termios_mock.error = termios.error
+ monkeypatch.setitem(sys.modules, "termios", termios_mock)
+
+ signal_mock = mock.MagicMock()
+ monkeypatch.setitem(sys.modules, "signal", signal_mock)
+
+ # Mock os.tcsetpgrp and os.getpgrp
+ if has_tcsetpgrp:
+ monkeypatch.setattr(os, "tcsetpgrp", mock.Mock(), raising=False)
+ monkeypatch.setattr(os, "getpgrp", mock.Mock(return_value=123), raising=False)
+ else:
+ monkeypatch.delattr(os, "tcsetpgrp", raising=False)
+
+ # Mock subprocess.Popen
+ popen_mock = mock.MagicMock(name="Popen")
+ monkeypatch.setattr("subprocess.Popen", popen_mock)
+
+ # Set initial termios settings so the logic will run
+ dummy_settings = ["dummy settings"]
+ outsim_app._initial_termios_settings = dummy_settings
+
+ # Call ppaged
+ outsim_app.ppaged("Test")
+
+ # Verify restoration logic
+ if has_tcsetpgrp:
+ os.tcsetpgrp.assert_called_once_with(0, 123)
+ signal_mock.signal.assert_any_call(signal_mock.SIGTTOU, signal_mock.SIG_IGN)
+
+ termios_mock.tcsetattr.assert_called_once_with(0, termios_mock.TCSANOW, dummy_settings)
+
+
+@pytest.mark.skipif(sys.platform.startswith("win"), reason="termios is not available on Windows")
+def test_ppaged_terminal_restoration_exceptions(outsim_app, monkeypatch) -> None:
+ """Test that terminal restoration in ppaged() handles exceptions gracefully."""
+ # Make it look like we're in a terminal
+ stdin_mock = mock.MagicMock()
+ stdin_mock.isatty.return_value = True
+ stdin_mock.fileno.return_value = 0
+ monkeypatch.setattr(outsim_app, "stdin", stdin_mock)
+
+ stdout_mock = mock.MagicMock()
+ stdout_mock.isatty.return_value = True
+ monkeypatch.setattr(outsim_app, "stdout", stdout_mock)
+
+ if not sys.platform.startswith("win") and os.environ.get("TERM") is None:
+ monkeypatch.setenv("TERM", "simulated")
+
+ # Mock termios and make it raise an error
+ termios_mock = mock.MagicMock()
+ import termios
+
+ termios_mock.error = termios.error
+ termios_mock.tcsetattr.side_effect = termios.error("Restoration failed")
+ monkeypatch.setitem(sys.modules, "termios", termios_mock)
+
+ monkeypatch.setitem(sys.modules, "signal", mock.MagicMock())
+
+ # Mock os.tcsetpgrp and os.getpgrp to prevent OSError before tcsetattr
+ monkeypatch.setattr(os, "tcsetpgrp", mock.Mock(), raising=False)
+ monkeypatch.setattr(os, "getpgrp", mock.Mock(return_value=123), raising=False)
+
+ # Mock subprocess.Popen
+ popen_mock = mock.MagicMock(name="Popen")
+ monkeypatch.setattr("subprocess.Popen", popen_mock)
+
+ # Set initial termios settings
+ outsim_app._initial_termios_settings = ["dummy settings"]
+
+ # Call ppaged - should not raise exception
+ outsim_app.ppaged("Test")
+
+ # Verify tcsetattr was attempted
+ assert termios_mock.tcsetattr.called
+
+
+@pytest.mark.skipif(sys.platform.startswith("win"), reason="termios is not available on Windows")
+def test_ppaged_terminal_restoration_no_settings(outsim_app, monkeypatch) -> None:
+ """Test that terminal restoration in ppaged() is skipped if no settings are saved."""
+ # Make it look like we're in a terminal
+ stdin_mock = mock.MagicMock()
+ stdin_mock.isatty.return_value = True
+ stdin_mock.fileno.return_value = 0
+ monkeypatch.setattr(outsim_app, "stdin", stdin_mock)
+
+ stdout_mock = mock.MagicMock()
+ stdout_mock.isatty.return_value = True
+ monkeypatch.setattr(outsim_app, "stdout", stdout_mock)
+
+ if not sys.platform.startswith("win") and os.environ.get("TERM") is None:
+ monkeypatch.setenv("TERM", "simulated")
+
+ # Mock termios
+ termios_mock = mock.MagicMock()
+ monkeypatch.setitem(sys.modules, "termios", termios_mock)
+
+ # Mock subprocess.Popen
+ popen_mock = mock.MagicMock(name="Popen")
+ monkeypatch.setattr("subprocess.Popen", popen_mock)
+
+ # Ensure initial termios settings is None
+ outsim_app._initial_termios_settings = None
+
+ # Call ppaged
+ outsim_app.ppaged("Test")
+
+ # Verify tcsetattr was NOT called
+ assert not termios_mock.tcsetattr.called
+
+
+@pytest.mark.skipif(sys.platform.startswith("win"), reason="termios is not available on Windows")
+def test_ppaged_terminal_restoration_oserror(outsim_app, monkeypatch) -> None:
+ """Test that terminal restoration in ppaged() handles OSError gracefully."""
+ # Make it look like we're in a terminal
+ stdin_mock = mock.MagicMock()
+ stdin_mock.isatty.return_value = True
+ stdin_mock.fileno.return_value = 0
+ monkeypatch.setattr(outsim_app, "stdin", stdin_mock)
+
+ stdout_mock = mock.MagicMock()
+ stdout_mock.isatty.return_value = True
+ monkeypatch.setattr(outsim_app, "stdout", stdout_mock)
+
+ if not sys.platform.startswith("win") and os.environ.get("TERM") is None:
+ monkeypatch.setenv("TERM", "simulated")
+
+ # Mock signal
+ monkeypatch.setitem(sys.modules, "signal", mock.MagicMock())
+
+ # Mock os.tcsetpgrp to raise OSError
+ monkeypatch.setattr(os, "tcsetpgrp", mock.Mock(side_effect=OSError("Permission denied")), raising=False)
+ monkeypatch.setattr(os, "getpgrp", mock.Mock(return_value=123), raising=False)
+
+ # Mock termios
+ termios_mock = mock.MagicMock()
+ import termios
+
+ termios_mock.error = termios.error
+ monkeypatch.setitem(sys.modules, "termios", termios_mock)
+
+ # Mock subprocess.Popen
+ popen_mock = mock.MagicMock(name="Popen")
+ monkeypatch.setattr("subprocess.Popen", popen_mock)
+
+ # Set initial termios settings
+ outsim_app._initial_termios_settings = ["dummy settings"]
+
+ # Call ppaged - should not raise exception
+ outsim_app.ppaged("Test")
+
+ # Verify tcsetpgrp was attempted and OSError was caught
+ assert os.tcsetpgrp.called
+ # tcsetattr should have been skipped due to OSError being raised before it
+ assert not termios_mock.tcsetattr.called
+
+
+def test_ppretty(base_app: cmd2.Cmd) -> None:
+ # Mock the Pretty class and the print_to() method
+ with mock.patch("cmd2.cmd2.Pretty") as mock_pretty, mock.patch.object(cmd2.Cmd, "print_to") as mock_print_to:
+ # Set up the mock return value for Pretty
+ mock_pretty_obj = mock.Mock()
+ mock_pretty.return_value = mock_pretty_obj
+
+ test_obj = {"key": "value"}
+
+ # Call ppretty() with some custom arguments
+ base_app.ppretty(
+ test_obj,
+ indent_size=2,
+ max_depth=5,
+ expand_all=True,
+ end="\n\n",
+ )
+
+ # Verify Pretty was instantiated with the correct arguments
+ mock_pretty.assert_called_once_with(
+ test_obj,
+ indent_size=2,
+ indent_guides=True,
+ max_length=None,
+ max_string=None,
+ max_depth=5,
+ expand_all=True,
+ overflow="ignore",
+ )
+
+ # Verify print_to() was called with the mock pretty object and soft_wrap=True
+ # It should default to self.stdout when no file is provided
+ mock_print_to.assert_called_once_with(
+ base_app.stdout,
+ mock_pretty_obj,
+ soft_wrap=True,
+ end="\n\n",
+ )
+
+
+# we override cmd.parseline() so we always get consistent
# command parsing by parent methods we don't override
# don't need to test all the parsing logic here, because
# parseline just calls StatementParser.parse_command_only()
def test_parseline_empty(base_app) -> None:
- statement = ''
+ statement = ""
command, args, line = base_app.parseline(statement)
assert not command
assert not args
@@ -2801,7 +3538,7 @@ def test_parseline_empty(base_app) -> None:
def test_parseline_quoted(base_app) -> None:
statement = " command with 'partially completed quotes "
command, args, line = base_app.parseline(statement)
- assert command == 'command'
+ assert command == "command"
assert args == "with 'partially completed quotes "
assert line == statement.lstrip()
@@ -2819,7 +3556,7 @@ def test_onecmd_raw_str_quit(outsim_app) -> None:
stop = outsim_app.onecmd(line)
out = outsim_app.stdout.getvalue()
assert stop
- assert out == ''
+ assert out == ""
def test_onecmd_add_to_history(outsim_app) -> None:
@@ -2843,21 +3580,21 @@ def test_get_all_commands(base_app) -> None:
# Verify that the base app has the expected commands
commands = base_app.get_all_commands()
expected_commands = [
- '_relative_run_script',
- 'alias',
- 'edit',
- 'eof',
- 'help',
- 'history',
- 'ipy',
- 'macro',
- 'py',
- 'quit',
- 'run_pyscript',
- 'run_script',
- 'set',
- 'shell',
- 'shortcuts',
+ "_eof",
+ "_relative_run_script",
+ "alias",
+ "edit",
+ "help",
+ "history",
+ "ipy",
+ "macro",
+ "py",
+ "quit",
+ "run_pyscript",
+ "run_script",
+ "set",
+ "shell",
+ "shortcuts",
]
assert commands == expected_commands
@@ -2881,10 +3618,10 @@ def help_my_cmd(self, args) -> None:
pass
app = TestApp()
- assert 'my_cmd' in app.get_help_topics()
+ assert "my_cmd" in app.get_help_topics()
- app.hidden_commands.append('my_cmd')
- assert 'my_cmd' not in app.get_help_topics()
+ app.hidden_commands.append("my_cmd")
+ assert "my_cmd" not in app.get_help_topics()
class ReplWithExitCode(cmd2.Cmd):
@@ -2914,7 +3651,7 @@ def do_exit(self, arg_list) -> bool:
def postloop(self) -> None:
"""Hook method executed once when the cmdloop() method is about to return."""
- self.poutput(f'exiting with code: {self.exit_code}')
+ self.poutput(f"exiting with code: {self.exit_code}")
@pytest.fixture
@@ -2924,15 +3661,13 @@ def exit_code_repl():
return app
-def test_exit_code_default(exit_code_repl) -> None:
+def test_exit_code_default(exit_code_repl, monkeypatch) -> None:
app = exit_code_repl
- app.use_rawinput = True
- # Mock out the input call so we don't actually wait for a user's response on stdin
- m = mock.MagicMock(name='input', return_value='exit')
- builtins.input = m
+ read_command_mock = mock.MagicMock(name="_read_command_line", return_value="exit")
+ monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock)
- expected = 'exiting with code: 0\n'
+ expected = "exiting with code: 0\n"
# Run the command loop
app.cmdloop()
@@ -2940,15 +3675,13 @@ def test_exit_code_default(exit_code_repl) -> None:
assert out == expected
-def test_exit_code_nonzero(exit_code_repl) -> None:
+def test_exit_code_nonzero(exit_code_repl, monkeypatch) -> None:
app = exit_code_repl
- app.use_rawinput = True
- # Mock out the input call so we don't actually wait for a user's response on stdin
- m = mock.MagicMock(name='input', return_value='exit 23')
- builtins.input = m
+ read_input_mock = mock.MagicMock(name="_read_command_line", return_value="exit 23")
+ monkeypatch.setattr("cmd2.Cmd._read_command_line", read_input_mock)
- expected = 'exiting with code: 23\n'
+ expected = "exiting with code: 23\n"
# Run the command loop
app.cmdloop()
@@ -2966,259 +3699,282 @@ def do_echo(self, args) -> None:
def do_echo_error(self, args) -> None:
self.poutput(args, style=Cmd2Style.ERROR)
- # perror uses colors by default
self.perror(args)
@with_ansi_style(ru.AllowStyle.ALWAYS)
def test_ansi_pouterr_always_tty(mocker, capsys) -> None:
app = AnsiApp()
- mocker.patch.object(app.stdout, 'isatty', return_value=True)
- mocker.patch.object(sys.stderr, 'isatty', return_value=True)
+ mocker.patch.object(app.stdout, "isatty", return_value=True)
+ mocker.patch.object(sys.stderr, "isatty", return_value=True)
+
+ expected_plain = "oopsie\n"
+ expected_styled = su.stylize("oopsie\n", Cmd2Style.ERROR)
- app.onecmd_plus_hooks('echo_error oopsie')
+ app.onecmd_plus_hooks("echo_error oopsie")
out, err = capsys.readouterr()
- # if colors are on, the output should have some ANSI style sequences in it
- assert len(out) > len('oopsie\n')
- assert 'oopsie' in out
- assert len(err) > len('oopsie\n')
- assert 'oopsie' in err
-
- # but this one shouldn't
- app.onecmd_plus_hooks('echo oopsie')
+ assert out == expected_styled
+ assert err == expected_styled
+
+ app.onecmd_plus_hooks("echo oopsie")
out, err = capsys.readouterr()
- assert out == 'oopsie\n'
- # errors always have colors
- assert len(err) > len('oopsie\n')
- assert 'oopsie' in err
+ assert out == expected_plain
+ assert err == expected_styled
@with_ansi_style(ru.AllowStyle.ALWAYS)
def test_ansi_pouterr_always_notty(mocker, capsys) -> None:
app = AnsiApp()
- mocker.patch.object(app.stdout, 'isatty', return_value=False)
- mocker.patch.object(sys.stderr, 'isatty', return_value=False)
+ mocker.patch.object(app.stdout, "isatty", return_value=False)
+ mocker.patch.object(sys.stderr, "isatty", return_value=False)
+
+ expected_plain = "oopsie\n"
+ expected_styled = su.stylize("oopsie\n", Cmd2Style.ERROR)
- app.onecmd_plus_hooks('echo_error oopsie')
+ app.onecmd_plus_hooks("echo_error oopsie")
out, err = capsys.readouterr()
- # if colors are on, the output should have some ANSI style sequences in it
- assert len(out) > len('oopsie\n')
- assert 'oopsie' in out
- assert len(err) > len('oopsie\n')
- assert 'oopsie' in err
-
- # but this one shouldn't
- app.onecmd_plus_hooks('echo oopsie')
+ assert out == expected_styled
+ assert err == expected_styled
+
+ app.onecmd_plus_hooks("echo oopsie")
out, err = capsys.readouterr()
- assert out == 'oopsie\n'
- # errors always have colors
- assert len(err) > len('oopsie\n')
- assert 'oopsie' in err
+ assert out == expected_plain
+ assert err == expected_styled
@with_ansi_style(ru.AllowStyle.TERMINAL)
def test_ansi_terminal_tty(mocker, capsys) -> None:
app = AnsiApp()
- mocker.patch.object(app.stdout, 'isatty', return_value=True)
- mocker.patch.object(sys.stderr, 'isatty', return_value=True)
+ mocker.patch.object(app.stdout, "isatty", return_value=True)
+ mocker.patch.object(sys.stderr, "isatty", return_value=True)
+
+ expected_plain = "oopsie\n"
+ expected_styled = su.stylize("oopsie\n", Cmd2Style.ERROR)
- app.onecmd_plus_hooks('echo_error oopsie')
- # if colors are on, the output should have some ANSI style sequences in it
+ app.onecmd_plus_hooks("echo_error oopsie")
out, err = capsys.readouterr()
- assert len(out) > len('oopsie\n')
- assert 'oopsie' in out
- assert len(err) > len('oopsie\n')
- assert 'oopsie' in err
+ assert out == expected_styled
+ assert err == expected_styled
- # but this one shouldn't
- app.onecmd_plus_hooks('echo oopsie')
+ app.onecmd_plus_hooks("echo oopsie")
out, err = capsys.readouterr()
- assert out == 'oopsie\n'
- assert len(err) > len('oopsie\n')
- assert 'oopsie' in err
+ assert out == expected_plain
+ assert err == expected_styled
@with_ansi_style(ru.AllowStyle.TERMINAL)
def test_ansi_terminal_notty(mocker, capsys) -> None:
app = AnsiApp()
- mocker.patch.object(app.stdout, 'isatty', return_value=False)
- mocker.patch.object(sys.stderr, 'isatty', return_value=False)
+ mocker.patch.object(app.stdout, "isatty", return_value=False)
+ mocker.patch.object(sys.stderr, "isatty", return_value=False)
- app.onecmd_plus_hooks('echo_error oopsie')
+ app.onecmd_plus_hooks("echo_error oopsie")
out, err = capsys.readouterr()
- assert out == err == 'oopsie\n'
+ assert out == err == "oopsie\n"
- app.onecmd_plus_hooks('echo oopsie')
+ app.onecmd_plus_hooks("echo oopsie")
out, err = capsys.readouterr()
- assert out == err == 'oopsie\n'
+ assert out == err == "oopsie\n"
@with_ansi_style(ru.AllowStyle.NEVER)
def test_ansi_never_tty(mocker, capsys) -> None:
app = AnsiApp()
- mocker.patch.object(app.stdout, 'isatty', return_value=True)
- mocker.patch.object(sys.stderr, 'isatty', return_value=True)
+ mocker.patch.object(app.stdout, "isatty", return_value=True)
+ mocker.patch.object(sys.stderr, "isatty", return_value=True)
- app.onecmd_plus_hooks('echo_error oopsie')
+ app.onecmd_plus_hooks("echo_error oopsie")
out, err = capsys.readouterr()
- assert out == err == 'oopsie\n'
+ assert out == err == "oopsie\n"
- app.onecmd_plus_hooks('echo oopsie')
+ app.onecmd_plus_hooks("echo oopsie")
out, err = capsys.readouterr()
- assert out == err == 'oopsie\n'
+ assert out == err == "oopsie\n"
@with_ansi_style(ru.AllowStyle.NEVER)
def test_ansi_never_notty(mocker, capsys) -> None:
app = AnsiApp()
- mocker.patch.object(app.stdout, 'isatty', return_value=False)
- mocker.patch.object(sys.stderr, 'isatty', return_value=False)
+ mocker.patch.object(app.stdout, "isatty", return_value=False)
+ mocker.patch.object(sys.stderr, "isatty", return_value=False)
- app.onecmd_plus_hooks('echo_error oopsie')
+ app.onecmd_plus_hooks("echo_error oopsie")
out, err = capsys.readouterr()
- assert out == err == 'oopsie\n'
+ assert out == err == "oopsie\n"
- app.onecmd_plus_hooks('echo oopsie')
+ app.onecmd_plus_hooks("echo oopsie")
out, err = capsys.readouterr()
- assert out == err == 'oopsie\n'
+ assert out == err == "oopsie\n"
class DisableCommandsApp(cmd2.Cmd):
"""Class for disabling commands"""
+ DEFAULT_CATEGORY = "DisabledApp Commands"
category_name = "Test Category"
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
@cmd2.with_category(category_name)
- def do_has_helper_funcs(self, arg) -> None:
- self.poutput("The real has_helper_funcs")
+ def do_has_helper_func(self, arg) -> None:
+ self.poutput("The real has_helper_func")
- def help_has_helper_funcs(self) -> None:
- self.poutput('Help for has_helper_funcs')
+ def help_has_helper_func(self) -> None:
+ self.poutput("Help for has_helper_func")
- def complete_has_helper_funcs(self, *args):
- return ['result']
+ def complete_has_helper_func(self, *args) -> Completions:
+ return Completions.from_values(["result"])
@cmd2.with_category(category_name)
- def do_has_no_helper_funcs(self, arg) -> None:
- """Help for has_no_helper_funcs"""
- self.poutput("The real has_no_helper_funcs")
+ def do_has_no_helper_func(self, arg) -> None:
+ """Help for has_no_helper_func"""
+ self.poutput("The real has_no_helper_func")
+ def do_is_not_decorated(self, arg) -> None:
+ """This will be in the DEFAULT_CATEGORY."""
+ self.poutput("The real is_not_decorated")
-class DisableCommandSet(CommandSet):
+ @cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
+ def do_argparse_command(self, args) -> None:
+ """Help for argparse_command"""
+
+
+class DisableCommandSet(CommandSet[cmd2.Cmd]):
"""Test registering a command which is in a disabled category"""
category_name = "CommandSet Test Category"
+ DEFAULT_CATEGORY = "DisableCommandSet Commands"
@cmd2.with_category(category_name)
def do_new_command(self, arg) -> None:
- self._cmd.poutput("CommandSet function is enabled")
+ self._cmd.poutput("The real new_command")
+
+ def do_cs_is_not_decorated(self, arg) -> None:
+ """This will be in the DEFAULT_CATEGORY."""
+ self._cmd.poutput("The real cs_is_not_decorated")
@pytest.fixture
-def disable_commands_app():
+def disable_commands_app() -> DisableCommandsApp:
return DisableCommandsApp()
-def test_disable_and_enable_category(disable_commands_app) -> None:
+def test_disable_and_enable_category(disable_commands_app: DisableCommandsApp) -> None:
##########################################################################
# Disable the category
##########################################################################
- message_to_print = 'These commands are currently disabled'
+ message_to_print = "These commands are currently disabled"
+
+ # Disable commands which are decorated with a category
disable_commands_app.disable_category(disable_commands_app.category_name, message_to_print)
+ # Disable commands in the default category
+ disable_commands_app.disable_category(disable_commands_app.DEFAULT_CATEGORY, message_to_print)
+
# Make sure all the commands and help on those commands displays the message
- out, err = run_cmd(disable_commands_app, 'has_helper_funcs')
+ out, err = run_cmd(disable_commands_app, "has_helper_func")
+ assert err[0].startswith(message_to_print)
+
+ out, err = run_cmd(disable_commands_app, "help has_helper_func")
+ assert err[0].startswith(message_to_print)
+
+ out, err = run_cmd(disable_commands_app, "has_no_helper_func")
assert err[0].startswith(message_to_print)
- out, err = run_cmd(disable_commands_app, 'help has_helper_funcs')
+ out, err = run_cmd(disable_commands_app, "help has_no_helper_func")
assert err[0].startswith(message_to_print)
- out, err = run_cmd(disable_commands_app, 'has_no_helper_funcs')
+ out, err = run_cmd(disable_commands_app, "is_not_decorated")
assert err[0].startswith(message_to_print)
- out, err = run_cmd(disable_commands_app, 'help has_no_helper_funcs')
+ out, err = run_cmd(disable_commands_app, "help is_not_decorated")
assert err[0].startswith(message_to_print)
# Make sure neither function completes
- text = ''
- line = f'has_helper_funcs {text}'
+ text = ""
+ line = f"has_helper_func {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, disable_commands_app)
- assert first_match is None
+ completions = disable_commands_app.complete(text, line, begidx, endidx)
+ assert not completions
- text = ''
- line = f'has_no_helper_funcs {text}'
+ text = ""
+ line = f"has_no_helper_func {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, disable_commands_app)
- assert first_match is None
+ completions = disable_commands_app.complete(text, line, begidx, endidx)
+ assert not completions
# Make sure both commands are invisible
visible_commands = disable_commands_app.get_visible_commands()
- assert 'has_helper_funcs' not in visible_commands
- assert 'has_no_helper_funcs' not in visible_commands
+ assert "has_helper_func" not in visible_commands
+ assert "has_no_helper_func" not in visible_commands
# Make sure get_help_topics() filters out disabled commands
help_topics = disable_commands_app.get_help_topics()
- assert 'has_helper_funcs' not in help_topics
+ assert "has_helper_func" not in help_topics
##########################################################################
# Enable the category
##########################################################################
+ # Enable commands which are decorated with a category
disable_commands_app.enable_category(disable_commands_app.category_name)
+ # Enable commands in the default category
+ disable_commands_app.enable_category(disable_commands_app.DEFAULT_CATEGORY)
+
# Make sure all the commands and help on those commands are restored
- out, err = run_cmd(disable_commands_app, 'has_helper_funcs')
- assert out[0] == "The real has_helper_funcs"
+ out, err = run_cmd(disable_commands_app, "has_helper_func")
+ assert out[0] == "The real has_helper_func"
+
+ out, err = run_cmd(disable_commands_app, "help has_helper_func")
+ assert out[0] == "Help for has_helper_func"
- out, err = run_cmd(disable_commands_app, 'help has_helper_funcs')
- assert out[0] == "Help for has_helper_funcs"
+ out, err = run_cmd(disable_commands_app, "has_no_helper_func")
+ assert out[0] == "The real has_no_helper_func"
- out, err = run_cmd(disable_commands_app, 'has_no_helper_funcs')
- assert out[0] == "The real has_no_helper_funcs"
+ out, err = run_cmd(disable_commands_app, "help has_no_helper_func")
+ assert out[0] == "Help for has_no_helper_func"
- out, err = run_cmd(disable_commands_app, 'help has_no_helper_funcs')
- assert out[0] == "Help for has_no_helper_funcs"
+ out, err = run_cmd(disable_commands_app, "is_not_decorated")
+ assert out[0] == "The real is_not_decorated"
- # has_helper_funcs should complete now
- text = ''
- line = f'has_helper_funcs {text}'
+ # has_helper_func should complete now
+ text = ""
+ line = f"has_helper_func {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, disable_commands_app)
- assert first_match is not None
- assert disable_commands_app.completion_matches == ['result ']
+ completions = disable_commands_app.complete(text, line, begidx, endidx)
+ assert completions[0].text == "result"
- # has_no_helper_funcs had no completer originally, so there should be no results
- text = ''
- line = f'has_no_helper_funcs {text}'
+ # has_no_helper_func had no completer originally, so there should be no results
+ text = ""
+ line = f"has_no_helper_func {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, disable_commands_app)
- assert first_match is None
+ completions = disable_commands_app.complete(text, line, begidx, endidx)
+ assert not completions
- # Make sure both commands are visible
+ # Make sure all commands are visible
visible_commands = disable_commands_app.get_visible_commands()
- assert 'has_helper_funcs' in visible_commands
- assert 'has_no_helper_funcs' in visible_commands
+ assert "has_helper_func" in visible_commands
+ assert "has_no_helper_func" in visible_commands
+ assert "is_not_decorated" in visible_commands
# Make sure get_help_topics() contains our help function
help_topics = disable_commands_app.get_help_topics()
- assert 'has_helper_funcs' in help_topics
+ assert "has_helper_func" in help_topics
def test_enable_enabled_command(disable_commands_app) -> None:
# Test enabling a command that is not disabled
saved_len = len(disable_commands_app.disabled_commands)
- disable_commands_app.enable_command('has_helper_funcs')
+ disable_commands_app.enable_command("has_helper_func")
# The number of disabled commands should not have changed
assert saved_len == len(disable_commands_app.disabled_commands)
@@ -3226,13 +3982,13 @@ def test_enable_enabled_command(disable_commands_app) -> None:
def test_disable_fake_command(disable_commands_app) -> None:
with pytest.raises(AttributeError):
- disable_commands_app.disable_command('fake', 'fake message')
+ disable_commands_app.disable_command("fake", "fake message")
def test_disable_command_twice(disable_commands_app) -> None:
saved_len = len(disable_commands_app.disabled_commands)
- message_to_print = 'These commands are currently disabled'
- disable_commands_app.disable_command('has_helper_funcs', message_to_print)
+ message_to_print = "These commands are currently disabled"
+ disable_commands_app.disable_command("has_helper_func", message_to_print)
# The number of disabled commands should have increased one
new_len = len(disable_commands_app.disabled_commands)
@@ -3240,51 +3996,138 @@ def test_disable_command_twice(disable_commands_app) -> None:
saved_len = new_len
# Disable again and the length should not change
- disable_commands_app.disable_command('has_helper_funcs', message_to_print)
+ disable_commands_app.disable_command("has_helper_func", message_to_print)
new_len = len(disable_commands_app.disabled_commands)
assert saved_len == new_len
def test_disabled_command_not_in_history(disable_commands_app) -> None:
- message_to_print = 'These commands are currently disabled'
- disable_commands_app.disable_command('has_helper_funcs', message_to_print)
+ message_to_print = "These commands are currently disabled"
+ disable_commands_app.disable_command("has_helper_func", message_to_print)
saved_len = len(disable_commands_app.history)
- run_cmd(disable_commands_app, 'has_helper_funcs')
+ run_cmd(disable_commands_app, "has_helper_func")
assert saved_len == len(disable_commands_app.history)
-def test_disabled_message_command_name(disable_commands_app) -> None:
- message_to_print = f'{COMMAND_NAME} is currently disabled'
- disable_commands_app.disable_command('has_helper_funcs', message_to_print)
+def test_get_parser_while_disabled(disable_commands_app: DisableCommandsApp) -> None:
+ """Test that command_parsers can find a disabled command's parser."""
+ # Get parser before disabling
+ parser_before = disable_commands_app.command_parsers.get(disable_commands_app.do_argparse_command)
+ assert parser_before is not None
+
+ # Disable command
+ disable_commands_app.disable_command("argparse_command", "Disabled")
+
+ # Get parser after disabling
+ parser_after = disable_commands_app.command_parsers.get(disable_commands_app.do_argparse_command)
+ assert parser_after is not None
+ assert parser_after is parser_before
+
+
+def test_metadata_preservation_while_disabled(disable_commands_app: DisableCommandsApp) -> None:
+ orig_cmd_func = disable_commands_app.do_has_helper_func
+ orig_help = disable_commands_app.help_has_helper_func
+ orig_complete = disable_commands_app.complete_has_helper_func
+
+ disable_commands_app.disable_command("has_helper_func", "Disabled")
+
+ # Names and qualnames should be preserved
+ assert disable_commands_app.do_has_helper_func.__name__ == orig_cmd_func.__name__
+ assert disable_commands_app.do_has_helper_func.__qualname__ == orig_cmd_func.__qualname__
- _out, err = run_cmd(disable_commands_app, 'has_helper_funcs')
- assert err[0].startswith('has_helper_funcs is currently disabled')
+ assert disable_commands_app.help_has_helper_func.__name__ == orig_help.__name__
+ assert disable_commands_app.help_has_helper_func.__qualname__ == orig_help.__qualname__
+
+ assert disable_commands_app.complete_has_helper_func.__name__ == orig_complete.__name__
+ assert disable_commands_app.complete_has_helper_func.__qualname__ == orig_complete.__qualname__
+
+ # Docstrings should be preserved
+ assert disable_commands_app.do_has_helper_func.__doc__ == orig_cmd_func.__doc__
+ assert disable_commands_app.help_has_helper_func.__doc__ == orig_help.__doc__
+ assert disable_commands_app.complete_has_helper_func.__doc__ == orig_complete.__doc__
+
+
+def test_disabled_completer_returns_empty(disable_commands_app: DisableCommandsApp) -> None:
+ disable_commands_app.disable_command("has_helper_func", "Disabled")
+ completions = disable_commands_app.complete_has_helper_func("", "has_helper_func ", 16, 16)
+ assert len(completions) == 0
+
+
+def test_disabled_message_command_name(disable_commands_app: DisableCommandsApp) -> None:
+ message_to_print = f"{COMMAND_NAME} is currently disabled"
+ disable_commands_app.disable_command("has_helper_func", message_to_print)
+
+ _out, err = run_cmd(disable_commands_app, "has_helper_func")
+ assert err[0].startswith("has_helper_func is currently disabled")
+
+
+def test_help_argparse_command_while_disabled(disable_commands_app: DisableCommandsApp) -> None:
+ message_to_print = "This command is disabled"
+ disable_commands_app.disable_command("argparse_command", message_to_print)
+
+ # help should show the disabled message
+ _out, err = run_cmd(disable_commands_app, "help argparse_command")
+ assert err[0].startswith(message_to_print)
+
+ # Re-enabling should restore the real help
+ disable_commands_app.enable_command("argparse_command")
+ out, _err = run_cmd(disable_commands_app, "help argparse_command")
+ assert "Usage: argparse_command" in out[0]
+
+
+def test_help_disabled_no_help_func(base_app: cmd2.Cmd) -> None:
+ from cmd2.cmd2 import DisabledCommand
+
+ # Intentionally bypass disable_command() to test the fallback in do_help()
+ command = "quit"
+ command_func = cast(BoundCommandFunc, base_app.get_command_func(command))
+ base_app.disabled_commands[command] = DisabledCommand(command_func=command_func, help_func=None, completer_func=None)
+
+ _out, err = run_cmd(base_app, f"help {command}")
+ assert err[0].startswith(f"{command} is currently disabled.")
def test_register_command_in_enabled_category(disable_commands_app) -> None:
+ # Enable commands which are decorated with a category
disable_commands_app.enable_category(DisableCommandSet.category_name)
+
+ # Enable commands in the default category
+ disable_commands_app.enable_category(DisableCommandSet.DEFAULT_CATEGORY)
+
cs = DisableCommandSet()
disable_commands_app.register_command_set(cs)
- out, _err = run_cmd(disable_commands_app, 'new_command')
- assert out[0] == "CommandSet function is enabled"
+ out, _err = run_cmd(disable_commands_app, "new_command")
+ assert out[0] == "The real new_command"
+
+ out, _err = run_cmd(disable_commands_app, "cs_is_not_decorated")
+ assert out[0] == "The real cs_is_not_decorated"
def test_register_command_in_disabled_category(disable_commands_app) -> None:
message_to_print = "CommandSet function is disabled"
+
+ # Disable commands which are decorated with a category
disable_commands_app.disable_category(DisableCommandSet.category_name, message_to_print)
+
+ # Disable commands in the default category
+ disable_commands_app.disable_category(DisableCommandSet.DEFAULT_CATEGORY, message_to_print)
+
cs = DisableCommandSet()
disable_commands_app.register_command_set(cs)
- _out, err = run_cmd(disable_commands_app, 'new_command')
+ _out, err = run_cmd(disable_commands_app, "new_command")
+ assert err[0] == message_to_print
+
+ _out, err = run_cmd(disable_commands_app, "cs_is_not_decorated")
assert err[0] == message_to_print
def test_enable_enabled_category(disable_commands_app) -> None:
# Test enabling a category that is not disabled
saved_len = len(disable_commands_app.disabled_categories)
- disable_commands_app.enable_category('Test Category')
+ disable_commands_app.enable_category("Test Category")
# The number of disabled categories should not have changed
assert saved_len == len(disable_commands_app.disabled_categories)
@@ -3292,8 +4135,8 @@ def test_enable_enabled_category(disable_commands_app) -> None:
def test_disable_category_twice(disable_commands_app) -> None:
saved_len = len(disable_commands_app.disabled_categories)
- message_to_print = 'These commands are currently disabled'
- disable_commands_app.disable_category('Test Category', message_to_print)
+ message_to_print = "These commands are currently disabled"
+ disable_commands_app.disable_category("Test Category", message_to_print)
# The number of disabled categories should have increased one
new_len = len(disable_commands_app.disabled_categories)
@@ -3301,18 +4144,18 @@ def test_disable_category_twice(disable_commands_app) -> None:
saved_len = new_len
# Disable again and the length should not change
- disable_commands_app.disable_category('Test Category', message_to_print)
+ disable_commands_app.disable_category("Test Category", message_to_print)
new_len = len(disable_commands_app.disabled_categories)
assert saved_len == new_len
-@pytest.mark.parametrize('silence_startup_script', [True, False])
+@pytest.mark.parametrize("silence_startup_script", [True, False])
def test_startup_script(request, capsys, silence_startup_script) -> None:
test_dir = os.path.dirname(request.module.__file__)
- startup_script = os.path.join(test_dir, '.cmd2rc')
+ startup_script = os.path.join(test_dir, ".cmd2rc")
app = cmd2.Cmd(allow_cli_args=False, startup_script=startup_script, silence_startup_script=silence_startup_script)
assert len(app._startup_commands) == 1
- app._startup_commands.append('quit')
+ app._startup_commands.append("quit")
app.cmdloop()
out, _err = capsys.readouterr()
@@ -3321,17 +4164,17 @@ def test_startup_script(request, capsys, silence_startup_script) -> None:
else:
assert out
- out, _err = run_cmd(app, 'alias list')
+ out, _err = run_cmd(app, "alias list")
assert len(out) > 1
- assert 'alias create ls' in out[0]
+ assert "alias create ls" in out[0]
-@pytest.mark.parametrize('startup_script', odd_file_names)
+@pytest.mark.parametrize("startup_script", odd_file_names)
def test_startup_script_with_odd_file_names(startup_script) -> None:
"""Test file names with various patterns"""
# Mock os.path.exists to trick cmd2 into adding this script to its startup commands
saved_exists = os.path.exists
- os.path.exists = mock.MagicMock(name='exists', return_value=True)
+ os.path.exists = mock.MagicMock(name="exists", return_value=True)
app = cmd2.Cmd(allow_cli_args=False, startup_script=startup_script)
assert len(app._startup_commands) == 1
@@ -3341,19 +4184,13 @@ def test_startup_script_with_odd_file_names(startup_script) -> None:
os.path.exists = saved_exists
-def test_transcripts_at_init() -> None:
- transcript_files = ['foo', 'bar']
- app = cmd2.Cmd(allow_cli_args=False, transcript_files=transcript_files)
- assert app._transcript_files == transcript_files
-
-
def test_command_parser_retrieval(outsim_app: cmd2.Cmd) -> None:
# Pass something that isn't a method
not_a_method = "just a string"
- assert outsim_app._command_parsers.get(not_a_method) is None
+ assert outsim_app.command_parsers.get(not_a_method) is None
# Pass a non-command method
- assert outsim_app._command_parsers.get(outsim_app.__init__) is None
+ assert outsim_app.command_parsers.get(outsim_app.__init__) is None
def test_command_synonym_parser() -> None:
@@ -3363,8 +4200,421 @@ class SynonymApp(cmd2.cmd2.Cmd):
app = SynonymApp()
- synonym_parser = app._command_parsers.get(app.do_synonym)
- help_parser = app._command_parsers.get(app.do_help)
+ synonym_parser = app.command_parsers.get(app.do_synonym)
+ help_parser = app.command_parsers.get(app.do_help)
assert synonym_parser is not None
assert synonym_parser is help_parser
+
+
+def test_custom_completekey_ctrl_k():
+ from prompt_toolkit.keys import Keys
+
+ # Test setting a custom completekey to + K
+ # In prompt_toolkit, this is 'c-k'
+ app = cmd2.Cmd(completekey="c-k")
+
+ assert app.main_session.key_bindings is not None
+
+ # Check that we have a binding for c-k (Keys.ControlK)
+ found = False
+ for binding in app.main_session.key_bindings.bindings:
+ # binding.keys is a tuple of keys
+ if binding.keys == (Keys.ControlK,):
+ found = True
+ break
+
+ assert found, "Could not find binding for 'c-k' (Keys.ControlK) in session key bindings"
+
+
+def test_completekey_empty_string() -> None:
+ # Test that an empty string for completekey defaults to DEFAULT_COMPLETEKEY
+ with mock.patch("cmd2.Cmd._create_main_session", autospec=True) as create_session_mock:
+ create_session_mock.return_value = mock.MagicMock(spec=PromptSession)
+ app = cmd2.Cmd(completekey="")
+
+ # Verify it was called with DEFAULT_COMPLETEKEY
+ # auto_suggest is the second arg and it defaults to True
+ create_session_mock.assert_called_once_with(app, True, app.DEFAULT_COMPLETEKEY)
+
+
+def test_create_main_session_exception(monkeypatch):
+
+ # Mock PromptSession to raise ValueError on first call, then succeed
+ valid_session_mock = mock.MagicMock(spec=PromptSession)
+ mock_session = mock.MagicMock(side_effect=[ValueError, valid_session_mock])
+ monkeypatch.setattr("cmd2.cmd2.PromptSession", mock_session)
+
+ # Mock isatty to ensure we enter the try block
+ with (
+ mock.patch("sys.stdin.isatty", return_value=True),
+ mock.patch("sys.stdout.isatty", return_value=True),
+ ):
+ cmd2.Cmd()
+
+ # Check that fallback to DummyInput/Output happened
+ assert mock_session.call_count == 2
+
+ # Check args of second call
+ call_args = mock_session.call_args_list[1]
+ kwargs = call_args[1]
+ assert isinstance(kwargs["input"], DummyInput)
+ assert isinstance(kwargs["output"], DummyOutput)
+
+
+@pytest.mark.skipif(
+ not sys.platform.startswith("win"),
+ reason="This tests how app.pager is set when running on Windows.",
+)
+def test_pager_on_windows(monkeypatch):
+ app = cmd2.Cmd()
+ assert app.pager == "more"
+ assert app.pager_chop == "more"
+
+
+@pytest.mark.skipif(
+ not sys.platform.startswith("win"),
+ reason="This tests how Cmd._complete_users() behaves on Windows.",
+)
+def test_path_complete_users_windows(monkeypatch, base_app):
+ # Mock os.path.expanduser and isdir
+ monkeypatch.setattr("os.path.expanduser", lambda p: "/home/user" if p == "~user" else p)
+ monkeypatch.setattr("os.path.isdir", lambda p: p == "/home/user")
+
+ matches = base_app.path_complete("~user", "cmd ~user", 0, 9)
+ # Should contain ~user/ (or ~user\ depending on sep)
+ # Since we didn't mock os.path.sep, it will use system separator.
+ expected = "~user" + os.path.sep
+ assert expected in matches
+
+
+def test_get_bottom_toolbar(base_app, monkeypatch):
+ # Test default (disabled)
+ assert base_app.get_bottom_toolbar() is None
+
+ # Test enabled
+ base_app.bottom_toolbar = True
+ monkeypatch.setattr(sys, "argv", ["myapp.py"])
+ toolbar = base_app.get_bottom_toolbar()
+ assert isinstance(toolbar, list)
+ assert toolbar[0] == ("ansigreen", "myapp.py")
+ assert toolbar[2][0] == "ansicyan"
+
+
+def test_get_rprompt(base_app):
+ # Test default
+ assert base_app.get_rprompt() is None
+
+ # Test overridden
+ from prompt_toolkit.formatted_text import FormattedText
+
+ expected_text = "rprompt text"
+ base_app.get_rprompt = lambda: expected_text
+ assert base_app.get_rprompt() == expected_text
+
+ expected_formatted = FormattedText([("class:status", "OK")])
+ base_app.get_rprompt = lambda: expected_formatted
+ assert base_app.get_rprompt() == expected_formatted
+
+
+def test_multiline_complete_statement_keyboard_interrupt(multiline_app, monkeypatch):
+ # Mock _read_command_line to raise KeyboardInterrupt
+ read_command_mock = mock.MagicMock(name="_read_command_line", side_effect=KeyboardInterrupt)
+ monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock)
+
+ # Mock poutput to verify ^C is printed
+ poutput_mock = mock.MagicMock(name="poutput")
+ monkeypatch.setattr(multiline_app, "poutput", poutput_mock)
+
+ with pytest.raises(exceptions.EmptyStatement):
+ multiline_app._complete_statement("orate incomplete")
+
+ poutput_mock.assert_called_with("^C")
+
+
+def test_create_main_session_no_console_error(monkeypatch):
+ from cmd2.cmd2 import NoConsoleScreenBufferError
+
+ # Mock PromptSession to raise NoConsoleScreenBufferError on first call, then succeed
+ valid_session_mock = mock.MagicMock(spec=PromptSession)
+ mock_session = mock.MagicMock(side_effect=[NoConsoleScreenBufferError, valid_session_mock])
+ monkeypatch.setattr("cmd2.cmd2.PromptSession", mock_session)
+
+ # Mock isatty to ensure we enter the try block
+ with (
+ mock.patch("sys.stdin.isatty", return_value=True),
+ mock.patch("sys.stdout.isatty", return_value=True),
+ ):
+ cmd2.Cmd()
+
+ # Check that fallback to DummyInput/Output happened
+ assert mock_session.call_count == 2
+
+ # Check args of second call
+ call_args = mock_session.call_args_list[1]
+ kwargs = call_args[1]
+ assert isinstance(kwargs["input"], DummyInput)
+ assert isinstance(kwargs["output"], DummyOutput)
+
+
+def test_create_main_session_with_custom_tty() -> None:
+ # Create a mock stdin with says it's a TTY
+ custom_stdin = mock.MagicMock(spec=io.TextIOWrapper)
+ custom_stdin.isatty.return_value = True
+ assert custom_stdin is not sys.stdin
+
+ # Create a mock stdout with says it's a TTY
+ custom_stdout = mock.MagicMock(spec=io.TextIOWrapper)
+ custom_stdout.isatty.return_value = True
+ assert custom_stdout is not sys.stdout
+
+ # Check if the streams were wrapped
+ with (
+ mock.patch("cmd2.cmd2.create_input") as mock_create_input,
+ mock.patch("cmd2.cmd2.create_output") as mock_create_output,
+ ):
+ app = cmd2.Cmd()
+ app.stdin = custom_stdin
+ app.stdout = custom_stdout
+ app._create_main_session(auto_suggest=True, completekey=app.DEFAULT_COMPLETEKEY)
+
+ mock_create_input.assert_called_once_with(stdin=custom_stdin)
+ mock_create_output.assert_called_once_with(stdout=custom_stdout)
+
+
+def test_create_main_session_stdin_non_tty() -> None:
+ # Set up a mock for a non-TTY stdin stream
+ mock_stdin = mock.MagicMock(spec=io.TextIOWrapper)
+ mock_stdin.isatty.return_value = False
+
+ app = cmd2.Cmd(stdin=mock_stdin)
+ assert isinstance(app.main_session.input, DummyInput)
+ assert isinstance(app.main_session.output, DummyOutput)
+
+
+def test_create_main_session_stdout_non_tty() -> None:
+ # Set up a mock for a non-TTY stdout stream
+ mock_stdout = mock.MagicMock(spec=io.TextIOWrapper)
+ mock_stdout.isatty.return_value = False
+
+ app = cmd2.Cmd(stdout=mock_stdout)
+ assert isinstance(app.main_session.input, DummyInput)
+ assert isinstance(app.main_session.output, DummyOutput)
+
+
+def test_no_console_screen_buffer_error_dummy():
+ from cmd2.cmd2 import NoConsoleScreenBufferError
+
+ # Check that it behaves like a normal exception
+ err = NoConsoleScreenBufferError()
+ assert isinstance(err, Exception)
+
+
+def test_read_command_line_dynamic_prompt(base_app: cmd2.Cmd) -> None:
+ """Test that _read_command_line uses a dynamic prompt when provided prompt matches app.prompt"""
+
+ # Mock patch_stdout to prevent it from attempting to access the Windows
+ # console buffer in a Windows test environment.
+ with mock.patch("cmd2.cmd2.patch_stdout"):
+ # Set input to something other than DummyInput so _read_raw_input()
+ # will go down the TTY route.
+ mock_session = mock.MagicMock()
+ mock_session.input = mock.MagicMock()
+ base_app.main_session = mock_session
+ base_app._read_command_line(base_app.prompt)
+
+ # Check that mock_prompt was called with a callable for the prompt
+ args, _ = mock_session.prompt.call_args
+ prompt_arg = args[0]
+ assert callable(prompt_arg)
+
+ # Verify the callable returns the expected ANSI formatted prompt
+ from prompt_toolkit.formatted_text import ANSI
+
+ result = prompt_arg()
+ assert isinstance(result, ANSI)
+ assert result.value == ANSI(base_app.prompt).value
+
+
+def test_read_input_history_isolation(base_app: cmd2.Cmd) -> None:
+ local_history = ["secret_command", "another_command"]
+
+ # Mock _read_raw_input to prevent actual blocking
+ # We want to inspect the session object passed to it
+ with mock.patch.object(base_app, "_read_raw_input") as mock_raw:
+ mock_raw.return_value = "user_input"
+
+ base_app.read_input("prompt> ", history=local_history)
+
+ # Inspect the session used in the call
+ args, _ = mock_raw.call_args
+ passed_session = args[1]
+
+ # Verify the session's history contains our list
+ loaded_history = list(passed_session.history.load_history_strings())
+ assert "secret_command" in loaded_history
+ assert "another_command" in loaded_history
+
+ # Verify the main app session was not touched
+ # This is the crucial check for isolation
+ main_history = base_app.main_session.history.get_strings()
+ assert "secret_command" not in main_history
+
+
+@pytest.mark.skipif(
+ sys.platform.startswith("win"),
+ reason="Don't have a real Windows console with how we are currently running tests in GitHub Actions",
+)
+def test_pre_prompt_running_loop(base_app):
+ # Test that pre_prompt runs with a running event loop.
+ import asyncio
+
+ # Set up pipe input to feed data to prompt_toolkit
+ with create_pipe_input() as pipe_input:
+ # Create a new session with our pipe input because the input property is read-only
+ base_app.main_session = PromptSession(
+ input=pipe_input,
+ output=DummyOutput(),
+ history=base_app.main_session.history,
+ completer=base_app.main_session.completer,
+ )
+
+ loop_check = {"running": False}
+
+ def my_pre_prompt():
+ try:
+ asyncio.get_running_loop()
+ loop_check["running"] = True
+ except RuntimeError:
+ loop_check["running"] = False
+
+ base_app.pre_prompt = my_pre_prompt
+
+ # Feed input to exit prompt immediately
+ pipe_input.send_text("foo\n")
+
+ # Ensure self.session.prompt is used
+ base_app._read_command_line("prompt> ")
+
+ assert loop_check["running"]
+
+
+def test_get_bottom_toolbar_narrow_terminal(base_app, monkeypatch):
+ """Test get_bottom_toolbar when terminal is too narrow for calculated padding"""
+ import shutil
+
+ base_app.bottom_toolbar = True
+ monkeypatch.setattr(sys, "argv", ["myapp.py"])
+
+ # Mock shutil.get_terminal_size to return a very small width (e.g. 5)
+ # Calculated padding_size = 5 - len('myapp.py') - len(now) - 1
+ # Since len(now) is ~29, this will definitely be < 1
+ monkeypatch.setattr(shutil, "get_terminal_size", lambda: os.terminal_size((5, 20)))
+
+ toolbar = base_app.get_bottom_toolbar()
+ assert isinstance(toolbar, list)
+
+ # The padding (index 1) should be exactly 1 space
+ assert toolbar[1] == ("", " ")
+
+
+def test_auto_suggest_true():
+ """Test that auto_suggest=True initializes AutoSuggestFromHistory."""
+ app = cmd2.Cmd(auto_suggest=True)
+ assert isinstance(app.main_session.auto_suggest, AutoSuggestFromHistory)
+
+
+def test_auto_suggest_false():
+ """Test that auto_suggest=False does not initialize AutoSuggestFromHistory."""
+ app = cmd2.Cmd(auto_suggest=False)
+ assert app.main_session.auto_suggest is None
+
+
+def test_auto_suggest_default():
+ """Test that auto_suggest defaults to True."""
+ app = cmd2.Cmd()
+ assert isinstance(app.main_session.auto_suggest, AutoSuggestFromHistory)
+
+
+def test_subcommand_attachment() -> None:
+ import argparse
+
+ class SubcmdApp(cmd2.Cmd):
+ def __init__(self) -> None:
+ super().__init__()
+
+ root_parser = cmd2.Cmd2ArgumentParser()
+ root_parser.add_subparsers()
+
+ @cmd2.with_argparser(root_parser)
+ def do_root(self, _args: argparse.Namespace) -> None:
+ pass
+
+ app = SubcmdApp()
+
+ # Verify root exists and uses argparse
+ root_parser = app.command_parsers.get(app.do_root)
+ assert root_parser is not None
+
+ # Attach child to root
+ child_parser = cmd2.Cmd2ArgumentParser(prog="child")
+ child_parser.add_subparsers()
+ app.attach_subcommand("root", "child", child_parser, help="child help")
+
+ # Verify child was attached
+ root_subparsers_action = root_parser.get_subparsers_action()
+ assert "child" in root_subparsers_action._name_parser_map
+ assert root_subparsers_action._name_parser_map["child"] is child_parser
+
+ # Attach grandchild to child
+ grandchild_parser = cmd2.Cmd2ArgumentParser(prog="grandchild")
+ app.attach_subcommand("root child", "grandchild", grandchild_parser)
+
+ # Verify grandchild was attached
+ child_subparsers_action = child_parser.get_subparsers_action()
+ assert "grandchild" in child_subparsers_action._name_parser_map
+
+ # Detach grandchild
+ detached_grandchild = app.detach_subcommand("root child", "grandchild")
+ assert detached_grandchild is grandchild_parser
+ assert "grandchild" not in child_subparsers_action._name_parser_map
+
+ # Detach child
+ detached_child = app.detach_subcommand("root", "child")
+ assert detached_child is child_parser
+ assert "child" not in root_subparsers_action._name_parser_map
+
+
+def test_subcommand_attachment_errors() -> None:
+ class SubcmdErrorApp(cmd2.Cmd):
+ def __init__(self) -> None:
+ super().__init__()
+
+ test_parser = cmd2.Cmd2ArgumentParser()
+ test_parser.add_subparsers(required=True)
+
+ @cmd2.with_argparser(test_parser)
+ def do_test(self, _statement: cmd2.Statement) -> None:
+ pass
+
+ def do_no_argparse(self, _statement: cmd2.Statement) -> None:
+ pass
+
+ app = SubcmdErrorApp()
+
+ # Test empty command
+ with pytest.raises(ValueError, match="Command path cannot be empty"):
+ app.attach_subcommand("", "sub", cmd2.Cmd2ArgumentParser())
+
+ # Test non-existent command
+ with pytest.raises(ValueError, match="Root command 'fake' does not exist"):
+ app.attach_subcommand("fake", "sub", cmd2.Cmd2ArgumentParser())
+
+ # Test command that doesn't use argparse
+ with pytest.raises(ValueError, match="Command 'no_argparse' does not use argparse"):
+ app.attach_subcommand("no_argparse", "sub", cmd2.Cmd2ArgumentParser())
+
+ # Test duplicate subcommand
+ app.attach_subcommand("test", "sub", cmd2.Cmd2ArgumentParser())
+ with pytest.raises(ValueError, match="Subcommand 'sub' already exists for 'test'"):
+ app.attach_subcommand("test", "sub", cmd2.Cmd2ArgumentParser())
diff --git a/tests/test_commandset.py b/tests/test_commandset.py
index 63df00080..72d31e52e 100644
--- a/tests/test_commandset.py
+++ b/tests/test_commandset.py
@@ -7,6 +7,7 @@
import cmd2
from cmd2 import (
+ Completions,
Settable,
)
from cmd2.exceptions import (
@@ -15,7 +16,6 @@
from .conftest import (
WithCommandSets,
- complete_tester,
normalize,
run_cmd,
)
@@ -25,8 +25,9 @@ class CommandSetBase(cmd2.CommandSet):
pass
-@cmd2.with_default_category('Fruits')
class CommandSetA(CommandSetBase):
+ DEFAULT_CATEGORY = "Fruits"
+
def on_register(self, cmd) -> None:
super().on_register(cmd)
print("in on_register now")
@@ -44,102 +45,108 @@ def on_unregistered(self) -> None:
print("in on_unregistered now")
def do_apple(self, statement: cmd2.Statement) -> None:
- self._cmd.poutput('Apple!')
+ """Apple Command"""
+ self._cmd.poutput("Apple!")
def do_banana(self, statement: cmd2.Statement) -> None:
"""Banana Command"""
- self._cmd.poutput('Banana!!')
+ self._cmd.poutput("Banana!!")
cranberry_parser = cmd2.Cmd2ArgumentParser()
- cranberry_parser.add_argument('arg1', choices=['lemonade', 'juice', 'sauce'])
+ cranberry_parser.add_argument("arg1", choices=["lemonade", "juice", "sauce"])
@cmd2.with_argparser(cranberry_parser, with_unknown_args=True)
def do_cranberry(self, ns: argparse.Namespace, unknown: list[str]) -> None:
- self._cmd.poutput(f'Cranberry {ns.arg1}!!')
+ """Cranberry Command"""
+ self._cmd.poutput(f"Cranberry {ns.arg1}!!")
if unknown and len(unknown):
- self._cmd.poutput('Unknown: ' + ', '.join(['{}'] * len(unknown)).format(*unknown))
- self._cmd.last_result = {'arg1': ns.arg1, 'unknown': unknown}
+ self._cmd.poutput("Unknown: " + ", ".join(["{}"] * len(unknown)).format(*unknown))
+ self._cmd.last_result = {"arg1": ns.arg1, "unknown": unknown}
def help_cranberry(self) -> None:
- self._cmd.stdout.write('This command does diddly squat...\n')
+ self._cmd.stdout.write("This command does diddly squat...\n")
@cmd2.with_argument_list
- @cmd2.with_category('Also Alone')
+ @cmd2.with_category("Also Alone")
def do_durian(self, args: list[str]) -> None:
"""Durian Command"""
- self._cmd.poutput(f'{len(args)} Arguments: ')
- self._cmd.poutput(', '.join(['{}'] * len(args)).format(*args))
- self._cmd.last_result = {'args': args}
+ self._cmd.poutput(f"{len(args)} Arguments: ")
+ self._cmd.poutput(", ".join(["{}"] * len(args)).format(*args))
+ self._cmd.last_result = {"args": args}
def complete_durian(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
- return self._cmd.basic_complete(text, line, begidx, endidx, ['stinks', 'smells', 'disgusting'])
+ return self._cmd.basic_complete(text, line, begidx, endidx, ["stinks", "smells", "disgusting"])
elderberry_parser = cmd2.Cmd2ArgumentParser()
- elderberry_parser.add_argument('arg1')
+ elderberry_parser.add_argument("arg1")
- @cmd2.with_category('Alone')
+ @cmd2.with_category("Alone")
@cmd2.with_argparser(elderberry_parser)
def do_elderberry(self, ns: argparse.Namespace) -> None:
- self._cmd.poutput(f'Elderberry {ns.arg1}!!')
- self._cmd.last_result = {'arg1': ns.arg1}
+ """Elderberry Command"""
+ self._cmd.poutput(f"Elderberry {ns.arg1}!!")
+ self._cmd.last_result = {"arg1": ns.arg1}
# Test that CommandSet with as_subcommand_to decorator successfully loads
# during `cmd2.Cmd.__init__()`.
main_parser = cmd2.Cmd2ArgumentParser(description="Main Command")
- main_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND', required=True)
+ main_parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND", required=True)
- @cmd2.with_category('Alone')
+ @cmd2.with_category("Alone")
@cmd2.with_argparser(main_parser)
def do_main(self, args: argparse.Namespace) -> None:
# Call handler for whatever subcommand was selected
- handler = args.cmd2_handler.get()
- handler(args)
+ args.cmd2_subcmd_handler(args)
# main -> sub
subcmd_parser = cmd2.Cmd2ArgumentParser(description="Sub Command")
- @cmd2.as_subcommand_to('main', 'sub', subcmd_parser, help="sub command")
+ # Include aliases to cover the alias check in cmd2's check_parser_uninstallable().
+ @cmd2.as_subcommand_to("main", "sub", subcmd_parser, help="sub command", aliases=["sub_alias"])
def subcmd_func(self, args: argparse.Namespace) -> None:
self._cmd.poutput("Subcommand Ran")
-@cmd2.with_default_category('Command Set B')
class CommandSetB(CommandSetBase):
+ DEFAULT_CATEGORY = "Command Set B"
+
def __init__(self, arg1) -> None:
super().__init__()
self._arg1 = arg1
def do_aardvark(self, statement: cmd2.Statement) -> None:
- self._cmd.poutput('Aardvark!')
+ """Aardvark Command"""
+ self._cmd.poutput("Aardvark!")
def do_bat(self, statement: cmd2.Statement) -> None:
- """Banana Command"""
- self._cmd.poutput('Bat!!')
+ """Bat Command"""
+ self._cmd.poutput("Bat!!")
def do_crocodile(self, statement: cmd2.Statement) -> None:
- self._cmd.poutput('Crocodile!!')
+ """Crocodile Command"""
+ self._cmd.poutput("Crocodile!!")
def test_autoload_commands(autoload_command_sets_app) -> None:
# verifies that, when autoload is enabled, CommandSets and registered functions all show up
- cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = autoload_command_sets_app._build_command_info()
+ cmds_cats, _help_topics = autoload_command_sets_app._build_command_info()
- assert 'Alone' in cmds_cats
- assert 'elderberry' in cmds_cats['Alone']
- assert 'main' in cmds_cats['Alone']
+ assert "Alone" in cmds_cats
+ assert "elderberry" in cmds_cats["Alone"]
+ assert "main" in cmds_cats["Alone"]
# Test subcommand was autoloaded
- result = autoload_command_sets_app.app_cmd('main sub')
- assert 'Subcommand Ran' in result.stdout
+ result = autoload_command_sets_app.app_cmd("main sub")
+ assert "Subcommand Ran" in result.stdout
- assert 'Also Alone' in cmds_cats
- assert 'durian' in cmds_cats['Also Alone']
+ assert "Also Alone" in cmds_cats
+ assert "durian" in cmds_cats["Also Alone"]
- assert 'Fruits' in cmds_cats
- assert 'cranberry' in cmds_cats['Fruits']
+ assert "Fruits" in cmds_cats
+ assert "cranberry" in cmds_cats["Fruits"]
- assert 'Command Set B' not in cmds_cats
+ assert "Command Set B" not in cmds_cats
def test_command_synonyms() -> None:
@@ -152,27 +159,27 @@ def __init__(self, arg1) -> None:
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser(description="Native Command"))
def do_builtin(self, _) -> None:
- pass
+ """Builtin Command"""
# Create a synonym to a command inside of this CommandSet
do_builtin_synonym = do_builtin
# Create a synonym to a command outside of this CommandSet with subcommands.
# This will best test the synonym check in cmd2.Cmd._check_uninstallable() when
- # we unresgister this CommandSet.
+ # we unregister this CommandSet.
do_alias_synonym = cmd2.Cmd.do_alias
cs = SynonymCommandSet("foo")
app = WithCommandSets(command_sets=[cs])
# Make sure the synonyms have the same parser as what they alias
- builtin_parser = app._command_parsers.get(app.do_builtin)
- builtin_synonym_parser = app._command_parsers.get(app.do_builtin_synonym)
+ builtin_parser = app.command_parsers.get(app.do_builtin)
+ builtin_synonym_parser = app.command_parsers.get(app.do_builtin_synonym)
assert builtin_parser is not None
assert builtin_parser is builtin_synonym_parser
- alias_parser = app._command_parsers.get(cmd2.Cmd.do_alias)
- alias_synonym_parser = app._command_parsers.get(app.do_alias_synonym)
+ alias_parser = app.command_parsers.get(cmd2.Cmd.do_alias)
+ alias_synonym_parser = app.command_parsers.get(app.do_alias_synonym)
assert alias_parser is not None
assert alias_parser is alias_synonym_parser
@@ -183,13 +190,13 @@ def do_builtin(self, _) -> None:
assert not hasattr(app, "do_alias_synonym")
# Make sure the alias command still exists, has the same parser, and works.
- assert alias_parser is app._command_parsers.get(cmd2.Cmd.do_alias)
- out, _err = run_cmd(app, 'alias --help')
+ assert alias_parser is app.command_parsers.get(cmd2.Cmd.do_alias)
+ out, _err = run_cmd(app, "alias --help")
assert normalize(alias_parser.format_help())[0] in out
def test_custom_construct_commandsets() -> None:
- command_set_b = CommandSetB('foo')
+ command_set_b = CommandSetB("foo")
# Verify that _cmd cannot be accessed until CommandSet is registered.
with pytest.raises(CommandSetRegistrationError) as excinfo:
@@ -199,11 +206,11 @@ def test_custom_construct_commandsets() -> None:
# Verifies that a custom initialized CommandSet loads correctly when passed into the constructor
app = WithCommandSets(command_sets=[command_set_b])
- cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = app._build_command_info()
- assert 'Command Set B' in cmds_cats
+ cmds_cats, _help_topics = app._build_command_info()
+ assert "Command Set B" in cmds_cats
# Verifies that the same CommandSet cannot be loaded twice
- command_set_2 = CommandSetB('bar')
+ command_set_2 = CommandSetB("bar")
with pytest.raises(CommandSetRegistrationError):
assert app.register_command_set(command_set_2)
@@ -218,11 +225,11 @@ def test_custom_construct_commandsets() -> None:
app2.register_command_set(command_set_b)
- assert hasattr(app2, 'do_apple')
- assert hasattr(app2, 'do_aardvark')
+ assert hasattr(app2, "do_apple")
+ assert hasattr(app2, "do_aardvark")
- assert app2.find_commandset_for_command('aardvark') is command_set_b
- assert app2.find_commandset_for_command('apple') is command_set_a
+ assert app2.find_commandset_for_command("aardvark") is command_set_b
+ assert app2.find_commandset_for_command("apple") is command_set_a
matches = app2.find_commandsets(CommandSetBase, subclass_match=True)
assert command_set_a in matches
@@ -234,42 +241,42 @@ def test_load_commands(manual_command_sets_app, capsys) -> None:
# now install a command set and verify the commands are now present
cmd_set = CommandSetA()
- assert manual_command_sets_app.find_commandset_for_command('elderberry') is None
+ assert manual_command_sets_app.find_commandset_for_command("elderberry") is None
assert not manual_command_sets_app.find_commandsets(CommandSetA)
manual_command_sets_app.register_command_set(cmd_set)
assert manual_command_sets_app.find_commandsets(CommandSetA)[0] is cmd_set
- assert manual_command_sets_app.find_commandset_for_command('elderberry') is cmd_set
+ assert manual_command_sets_app.find_commandset_for_command("elderberry") is cmd_set
- out = manual_command_sets_app.app_cmd('apple')
- assert 'Apple!' in out.stdout
+ out = manual_command_sets_app.app_cmd("apple")
+ assert "Apple!" in out.stdout
# Make sure registration callbacks ran
out, _err = capsys.readouterr()
assert "in on_register now" in out
assert "in on_registered now" in out
- cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info()
+ cmds_cats, _help_topics = manual_command_sets_app._build_command_info()
- assert 'Alone' in cmds_cats
- assert 'elderberry' in cmds_cats['Alone']
- assert 'main' in cmds_cats['Alone']
+ assert "Alone" in cmds_cats
+ assert "elderberry" in cmds_cats["Alone"]
+ assert "main" in cmds_cats["Alone"]
# Test subcommand was loaded
- result = manual_command_sets_app.app_cmd('main sub')
- assert 'Subcommand Ran' in result.stdout
+ result = manual_command_sets_app.app_cmd("main sub")
+ assert "Subcommand Ran" in result.stdout
- assert 'Fruits' in cmds_cats
- assert 'cranberry' in cmds_cats['Fruits']
+ assert "Fruits" in cmds_cats
+ assert "cranberry" in cmds_cats["Fruits"]
# uninstall the command set and verify it is now also no longer accessible
manual_command_sets_app.unregister_command_set(cmd_set)
- cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info()
+ cmds_cats, _help_topics = manual_command_sets_app._build_command_info()
- assert 'Alone' not in cmds_cats
- assert 'Fruits' not in cmds_cats
+ assert "Alone" not in cmds_cats
+ assert "Fruits" not in cmds_cats
# Make sure unregistration callbacks ran
out, _err = capsys.readouterr()
@@ -282,47 +289,47 @@ def test_load_commands(manual_command_sets_app, capsys) -> None:
# reinstall the command set and verify it is accessible
manual_command_sets_app.register_command_set(cmd_set)
- cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info()
+ cmds_cats, _help_topics = manual_command_sets_app._build_command_info()
- assert 'Alone' in cmds_cats
- assert 'elderberry' in cmds_cats['Alone']
- assert 'main' in cmds_cats['Alone']
+ assert "Alone" in cmds_cats
+ assert "elderberry" in cmds_cats["Alone"]
+ assert "main" in cmds_cats["Alone"]
# Test subcommand was loaded
- result = manual_command_sets_app.app_cmd('main sub')
- assert 'Subcommand Ran' in result.stdout
+ result = manual_command_sets_app.app_cmd("main sub")
+ assert "Subcommand Ran" in result.stdout
- assert 'Fruits' in cmds_cats
- assert 'cranberry' in cmds_cats['Fruits']
+ assert "Fruits" in cmds_cats
+ assert "cranberry" in cmds_cats["Fruits"]
def test_commandset_decorators(autoload_command_sets_app) -> None:
- result = autoload_command_sets_app.app_cmd('cranberry juice extra1 extra2')
+ result = autoload_command_sets_app.app_cmd("cranberry juice extra1 extra2")
assert result is not None
assert result.data is not None
- assert len(result.data['unknown']) == 2
- assert 'extra1' in result.data['unknown']
- assert 'extra2' in result.data['unknown']
- assert result.data['arg1'] == 'juice'
+ assert len(result.data["unknown"]) == 2
+ assert "extra1" in result.data["unknown"]
+ assert "extra2" in result.data["unknown"]
+ assert result.data["arg1"] == "juice"
assert not result.stderr
- result = autoload_command_sets_app.app_cmd('durian juice extra1 extra2')
- assert len(result.data['args']) == 3
- assert 'juice' in result.data['args']
- assert 'extra1' in result.data['args']
- assert 'extra2' in result.data['args']
+ result = autoload_command_sets_app.app_cmd("durian juice extra1 extra2")
+ assert len(result.data["args"]) == 3
+ assert "juice" in result.data["args"]
+ assert "extra1" in result.data["args"]
+ assert "extra2" in result.data["args"]
assert not result.stderr
- result = autoload_command_sets_app.app_cmd('durian')
- assert len(result.data['args']) == 0
+ result = autoload_command_sets_app.app_cmd("durian")
+ assert len(result.data["args"]) == 0
assert not result.stderr
- result = autoload_command_sets_app.app_cmd('elderberry')
- assert 'arguments are required' in result.stderr
+ result = autoload_command_sets_app.app_cmd("elderberry")
+ assert "arguments are required" in result.stderr
assert result.data is None
- result = autoload_command_sets_app.app_cmd('elderberry a b')
- assert 'unrecognized arguments' in result.stderr
+ result = autoload_command_sets_app.app_cmd("elderberry a b")
+ assert "unrecognized arguments" in result.stderr
assert result.data is None
@@ -330,22 +337,22 @@ def test_load_commandset_errors(manual_command_sets_app, capsys) -> None:
cmd_set = CommandSetA()
# create a conflicting command before installing CommandSet to verify rollback behavior
- manual_command_sets_app._install_command_function('do_durian', cmd_set.do_durian)
+ manual_command_sets_app._install_command_function("do_durian", cmd_set.do_durian)
with pytest.raises(CommandSetRegistrationError):
manual_command_sets_app.register_command_set(cmd_set)
# verify that the commands weren't installed
- cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info()
+ cmds_cats, _help_topics = manual_command_sets_app._build_command_info()
- assert 'Alone' not in cmds_cats
- assert 'Fruits' not in cmds_cats
+ assert "Alone" not in cmds_cats
+ assert "Fruits" not in cmds_cats
assert not manual_command_sets_app._installed_command_sets
- delattr(manual_command_sets_app, 'do_durian')
+ delattr(manual_command_sets_app, "do_durian")
# pre-create intentionally conflicting macro and alias names
- manual_command_sets_app.app_cmd('macro create apple run_pyscript')
- manual_command_sets_app.app_cmd('alias create banana run_pyscript')
+ manual_command_sets_app.app_cmd("macro create apple run_pyscript")
+ manual_command_sets_app.app_cmd("alias create banana run_pyscript")
# now install a command set and verify the commands are now present
manual_command_sets_app.register_command_set(cmd_set)
@@ -357,27 +364,27 @@ def test_load_commandset_errors(manual_command_sets_app, capsys) -> None:
# verify command functions which don't start with "do_" raise an exception
with pytest.raises(CommandSetRegistrationError):
- manual_command_sets_app._install_command_function('new_cmd', cmd_set.do_banana)
+ manual_command_sets_app._install_command_function("new_cmd", cmd_set.do_banana)
# verify methods which don't start with "do_" raise an exception
with pytest.raises(CommandSetRegistrationError):
- manual_command_sets_app._install_command_function('do_new_cmd', cmd_set.on_register)
+ manual_command_sets_app._install_command_function("do_new_cmd", cmd_set.on_register)
# verify duplicate commands are detected
with pytest.raises(CommandSetRegistrationError):
- manual_command_sets_app._install_command_function('do_banana', cmd_set.do_banana)
+ manual_command_sets_app._install_command_function("do_banana", cmd_set.do_banana)
# verify bad command names are detected
with pytest.raises(CommandSetRegistrationError):
- manual_command_sets_app._install_command_function('do_bad command', cmd_set.do_banana)
+ manual_command_sets_app._install_command_function("do_bad command", cmd_set.do_banana)
# verify error conflict with existing completer function
with pytest.raises(CommandSetRegistrationError):
- manual_command_sets_app._install_completer_function('durian', cmd_set.complete_durian)
+ manual_command_sets_app._install_completer_function("durian", cmd_set.complete_durian)
# verify error conflict with existing help function
with pytest.raises(CommandSetRegistrationError):
- manual_command_sets_app._install_help_function('cranberry', cmd_set.help_cranberry)
+ manual_command_sets_app._install_help_function("cranberry", cmd_set.help_cranberry)
class LoadableBase(cmd2.CommandSet):
@@ -387,7 +394,7 @@ def __init__(self, dummy) -> None:
self._cut_called = False
cut_parser = cmd2.Cmd2ArgumentParser()
- cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut')
+ cut_subparsers = cut_parser.add_subparsers(title="item", help="item to cut")
def namespace_provider(self) -> argparse.Namespace:
ns = argparse.Namespace()
@@ -397,47 +404,47 @@ def namespace_provider(self) -> argparse.Namespace:
@cmd2.with_argparser(cut_parser)
def do_cut(self, ns: argparse.Namespace) -> None:
"""Cut something"""
- handler = ns.cmd2_handler.get()
+ handler = ns.cmd2_subcmd_handler
if handler is not None:
# Call whatever subcommand function was selected
handler(ns)
self._cut_called = True
else:
# No subcommand was provided, so call help
- self._cmd.pwarning('This command does nothing without sub-parsers registered')
- self._cmd.do_help('cut')
+ self._cmd.pwarning("This command does nothing without sub-parsers registered")
+ self._cmd.do_help("cut")
stir_parser = cmd2.Cmd2ArgumentParser()
- stir_subparsers = stir_parser.add_subparsers(title='item', help='what to stir')
+ stir_subparsers = stir_parser.add_subparsers(title="item", help="what to stir")
@cmd2.with_argparser(stir_parser, ns_provider=namespace_provider)
def do_stir(self, ns: argparse.Namespace) -> None:
"""Stir something"""
if not ns.cut_called:
- self._cmd.poutput('Need to cut before stirring')
+ self._cmd.poutput("Need to cut before stirring")
return
- handler = ns.cmd2_handler.get()
+ handler = ns.cmd2_subcmd_handler
if handler is not None:
# Call whatever subcommand function was selected
handler(ns)
else:
# No subcommand was provided, so call help
- self._cmd.pwarning('This command does nothing without sub-parsers registered')
- self._cmd.do_help('stir')
+ self._cmd.pwarning("This command does nothing without sub-parsers registered")
+ self._cmd.do_help("stir")
stir_pasta_parser = cmd2.Cmd2ArgumentParser()
- stir_pasta_parser.add_argument('--option', '-o')
- stir_pasta_parser.add_subparsers(title='style', help='Stir style')
+ stir_pasta_parser.add_argument("--option", "-o")
+ stir_pasta_parser.add_subparsers(title="style", help="Stir style")
- @cmd2.as_subcommand_to('stir', 'pasta', stir_pasta_parser)
+ @cmd2.as_subcommand_to("stir", "pasta", stir_pasta_parser)
def stir_pasta(self, ns: argparse.Namespace) -> None:
- handler = ns.cmd2_handler.get()
+ handler = ns.cmd2_subcmd_handler
if handler is not None:
# Call whatever subcommand function was selected
handler(ns)
else:
- self._cmd.poutput('Stir pasta haphazardly')
+ self._cmd.poutput("Stir pasta haphazardly")
class LoadableBadBase(cmd2.CommandSet):
@@ -447,32 +454,34 @@ def __init__(self, dummy) -> None:
def do_cut(self, ns: argparse.Namespace) -> None:
"""Cut something"""
- handler = ns.cmd2_handler.get()
+ handler = ns.cmd2_subcmd_handler
if handler is not None:
# Call whatever subcommand function was selected
handler(ns)
else:
# No subcommand was provided, so call help
- self._cmd.poutput('This command does nothing without sub-parsers registered')
- self._cmd.do_help('cut')
+ self._cmd.poutput("This command does nothing without sub-parsers registered")
+ self._cmd.do_help("cut")
-@cmd2.with_default_category('Fruits')
class LoadableFruits(cmd2.CommandSet):
+ DEFAULT_CATEGORY = "Fruits"
+
def __init__(self, dummy) -> None:
super().__init__()
self._dummy = dummy # prevents autoload
def do_apple(self, _: cmd2.Statement) -> None:
- self._cmd.poutput('Apple')
+ """Apple Command"""
+ self._cmd.poutput("Apple")
banana_parser = cmd2.Cmd2ArgumentParser()
- banana_parser.add_argument('direction', choices=['discs', 'lengthwise'])
+ banana_parser.add_argument("direction", choices=["discs", "lengthwise"])
- @cmd2.as_subcommand_to('cut', 'banana', banana_parser, help='Cut banana', aliases=['bananer'])
+ @cmd2.as_subcommand_to("cut", "banana", banana_parser, help="Cut banana", aliases=["bananer"])
def cut_banana(self, ns: argparse.Namespace) -> None:
"""Cut banana"""
- self._cmd.poutput('cutting banana: ' + ns.direction)
+ self._cmd.poutput("cutting banana: " + ns.direction)
class LoadablePastaStir(cmd2.CommandSet):
@@ -481,31 +490,33 @@ def __init__(self, dummy) -> None:
self._dummy = dummy # prevents autoload
stir_pasta_vigor_parser = cmd2.Cmd2ArgumentParser()
- stir_pasta_vigor_parser.add_argument('frequency')
+ stir_pasta_vigor_parser.add_argument("frequency")
- @cmd2.as_subcommand_to('stir pasta', 'vigorously', stir_pasta_vigor_parser)
+ @cmd2.as_subcommand_to("stir pasta", "vigorously", stir_pasta_vigor_parser)
def stir_pasta_vigorously(self, ns: argparse.Namespace) -> None:
- self._cmd.poutput('stir the pasta vigorously')
+ self._cmd.poutput("stir the pasta vigorously")
-@cmd2.with_default_category('Vegetables')
class LoadableVegetables(cmd2.CommandSet):
+ DEFAULT_CATEGORY = "Vegetables"
+
def __init__(self, dummy) -> None:
super().__init__()
self._dummy = dummy # prevents autoload
def do_arugula(self, _: cmd2.Statement) -> None:
- self._cmd.poutput('Arugula')
+ """Arugula Command"""
+ self._cmd.poutput("Arugula")
- def complete_style_arg(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
- return ['quartered', 'diced']
+ def complete_style_arg(self, text: str, line: str, begidx: int, endidx: int) -> Completions:
+ return Completions.from_values(["quartered", "diced"])
bokchoy_parser = cmd2.Cmd2ArgumentParser()
- bokchoy_parser.add_argument('style', completer=complete_style_arg)
+ bokchoy_parser.add_argument("style", completer=complete_style_arg)
- @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser)
+ @cmd2.as_subcommand_to("cut", "bokchoy", bokchoy_parser)
def cut_bokchoy(self, ns: argparse.Namespace) -> None:
- self._cmd.poutput('Bok Choy: ' + ns.style)
+ self._cmd.poutput("Bok Choy: " + ns.style)
def test_subcommands(manual_command_sets_app) -> None:
@@ -523,10 +534,10 @@ def test_subcommands(manual_command_sets_app) -> None:
with pytest.raises(CommandSetRegistrationError):
manual_command_sets_app.register_command_set(fruit_cmds)
- # verify that the commands weren't installed
- cmds_cats, cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info()
- assert 'cut' in cmds_doc
- assert 'Fruits' not in cmds_cats
+ # verify that the Fruit commands weren't installed
+ cmds_cats, _help_topics = manual_command_sets_app._build_command_info()
+ assert "Fruits" not in cmds_cats
+ assert "cut" in manual_command_sets_app.get_all_commands()
# Now install the good base commands
manual_command_sets_app.unregister_command_set(badbase_cmds)
@@ -536,42 +547,40 @@ def test_subcommands(manual_command_sets_app) -> None:
with pytest.raises(CommandSetRegistrationError):
manual_command_sets_app._register_subcommands(fruit_cmds)
- cmd_result = manual_command_sets_app.app_cmd('cut')
- assert 'This command does nothing without sub-parsers registered' in cmd_result.stderr
+ cmd_result = manual_command_sets_app.app_cmd("cut")
+ assert "This command does nothing without sub-parsers registered" in cmd_result.stderr
# verify that command set install without problems
manual_command_sets_app.register_command_set(fruit_cmds)
manual_command_sets_app.register_command_set(veg_cmds)
- cmds_cats, cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info()
- assert 'Fruits' in cmds_cats
+ cmds_cats, _help_topics = manual_command_sets_app._build_command_info()
+ assert "Fruits" in cmds_cats
- text = ''
- line = f'cut {text}'
+ text = ""
+ line = f"cut {text}"
endidx = len(line)
begidx = endidx
- first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app)
+ completions = manual_command_sets_app.complete(text, line, begidx, endidx)
- assert first_match is not None
# check that the alias shows up correctly
- assert manual_command_sets_app.completion_matches == ['banana', 'bananer', 'bokchoy']
+ assert completions.to_strings() == Completions.from_values(["banana", "bananer", "bokchoy"]).to_strings()
- cmd_result = manual_command_sets_app.app_cmd('cut banana discs')
- assert 'cutting banana: discs' in cmd_result.stdout
+ cmd_result = manual_command_sets_app.app_cmd("cut banana discs")
+ assert "cutting banana: discs" in cmd_result.stdout
- text = ''
- line = f'cut bokchoy {text}'
+ text = ""
+ line = f"cut bokchoy {text}"
endidx = len(line)
begidx = endidx
- first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app)
+ completions = manual_command_sets_app.complete(text, line, begidx, endidx)
- assert first_match is not None
# verify that argparse completer in commandset functions correctly
- assert manual_command_sets_app.completion_matches == ['diced', 'quartered']
+ assert completions.to_strings() == Completions.from_values(["diced", "quartered"]).to_strings()
# verify that command set uninstalls without problems
manual_command_sets_app.unregister_command_set(fruit_cmds)
- cmds_cats, cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info()
- assert 'Fruits' not in cmds_cats
+ cmds_cats, _help_topics = manual_command_sets_app._build_command_info()
+ assert "Fruits" not in cmds_cats
# verify a double-unregister raises exception
with pytest.raises(CommandSetRegistrationError):
@@ -579,44 +588,42 @@ def test_subcommands(manual_command_sets_app) -> None:
manual_command_sets_app.unregister_command_set(veg_cmds)
# Disable command and verify subcommands still load and unload
- manual_command_sets_app.disable_command('cut', 'disabled for test')
+ manual_command_sets_app.disable_command("cut", "disabled for test")
# verify that command set install without problems
manual_command_sets_app.register_command_set(fruit_cmds)
manual_command_sets_app.register_command_set(veg_cmds)
- manual_command_sets_app.enable_command('cut')
+ manual_command_sets_app.enable_command("cut")
- cmds_cats, cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info()
- assert 'Fruits' in cmds_cats
+ cmds_cats, _help_topics = manual_command_sets_app._build_command_info()
+ assert "Fruits" in cmds_cats
- text = ''
- line = f'cut {text}'
+ text = ""
+ line = f"cut {text}"
endidx = len(line)
begidx = endidx
- first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app)
+ completions = manual_command_sets_app.complete(text, line, begidx, endidx)
- assert first_match is not None
# check that the alias shows up correctly
- assert manual_command_sets_app.completion_matches == ['banana', 'bananer', 'bokchoy']
+ assert completions.to_strings() == Completions.from_values(["banana", "bananer", "bokchoy"]).to_strings()
- text = ''
- line = f'cut bokchoy {text}'
+ text = ""
+ line = f"cut bokchoy {text}"
endidx = len(line)
begidx = endidx
- first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app)
+ completions = manual_command_sets_app.complete(text, line, begidx, endidx)
- assert first_match is not None
# verify that argparse completer in commandset functions correctly
- assert manual_command_sets_app.completion_matches == ['diced', 'quartered']
+ assert completions.to_strings() == Completions.from_values(["diced", "quartered"]).to_strings()
# disable again and verify can still uninstnall
- manual_command_sets_app.disable_command('cut', 'disabled for test')
+ manual_command_sets_app.disable_command("cut", "disabled for test")
# verify that command set uninstalls without problems
manual_command_sets_app.unregister_command_set(fruit_cmds)
- cmds_cats, cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info()
- assert 'Fruits' not in cmds_cats
+ cmds_cats, _help_topics = manual_command_sets_app._build_command_info()
+ assert "Fruits" not in cmds_cats
# verify a double-unregister raises exception
with pytest.raises(CommandSetRegistrationError):
@@ -634,31 +641,33 @@ def test_commandset_sigint(manual_command_sets_app) -> None:
# returns True that we've handled interrupting the command.
class SigintHandledCommandSet(cmd2.CommandSet):
def do_foo(self, _) -> None:
- self._cmd.poutput('in foo')
+ """Foo Command"""
+ self._cmd.poutput("in foo")
self._cmd.sigint_handler(signal.SIGINT, None)
- self._cmd.poutput('end of foo')
+ self._cmd.poutput("end of foo")
def sigint_handler(self) -> bool:
return True
cs1 = SigintHandledCommandSet()
manual_command_sets_app.register_command_set(cs1)
- out = manual_command_sets_app.app_cmd('foo')
- assert 'in foo' in out.stdout
- assert 'end of foo' in out.stdout
+ out = manual_command_sets_app.app_cmd("foo")
+ assert "in foo" in out.stdout
+ assert "end of foo" in out.stdout
# shows that the command is interrupted if we don't report we've handled the sigint
class SigintUnhandledCommandSet(cmd2.CommandSet):
def do_bar(self, _) -> None:
- self._cmd.poutput('in do bar')
+ """Bar Command"""
+ self._cmd.poutput("in do bar")
self._cmd.sigint_handler(signal.SIGINT, None)
- self._cmd.poutput('end of do bar')
+ self._cmd.poutput("end of do bar")
cs2 = SigintUnhandledCommandSet()
manual_command_sets_app.register_command_set(cs2)
- out = manual_command_sets_app.app_cmd('bar')
- assert 'in do bar' in out.stdout
- assert 'end of do bar' not in out.stdout
+ out = manual_command_sets_app.app_cmd("bar")
+ assert "in do bar" in out.stdout
+ assert "end of do bar" not in out.stdout
def test_nested_subcommands(manual_command_sets_app) -> None:
@@ -681,12 +690,12 @@ def __init__(self, dummy) -> None:
self._dummy = dummy # prevents autoload
stir_pasta_vigor_parser = cmd2.Cmd2ArgumentParser()
- stir_pasta_vigor_parser.add_argument('frequency')
+ stir_pasta_vigor_parser.add_argument("frequency")
# stir sauce doesn't exist anywhere, this should fail
- @cmd2.as_subcommand_to('stir sauce', 'vigorously', stir_pasta_vigor_parser)
+ @cmd2.as_subcommand_to("stir sauce", "vigorously", stir_pasta_vigor_parser)
def stir_pasta_vigorously(self, ns: argparse.Namespace) -> None:
- self._cmd.poutput('stir the pasta vigorously')
+ self._cmd.poutput("stir the pasta vigorously")
with pytest.raises(CommandSetRegistrationError):
manual_command_sets_app.register_command_set(BadNestedSubcommands(1))
@@ -696,14 +705,14 @@ def stir_pasta_vigorously(self, ns: argparse.Namespace) -> None:
# validates custom namespace provider works correctly. Stir command will fail until
# the cut command is called
- result = manual_command_sets_app.app_cmd('stir pasta vigorously everyminute')
- assert 'Need to cut before stirring' in result.stdout
+ result = manual_command_sets_app.app_cmd("stir pasta vigorously everyminute")
+ assert "Need to cut before stirring" in result.stdout
- result = manual_command_sets_app.app_cmd('cut banana discs')
- assert 'cutting banana: discs' in result.stdout
+ result = manual_command_sets_app.app_cmd("cut banana discs")
+ assert "cutting banana: discs" in result.stdout
- result = manual_command_sets_app.app_cmd('stir pasta vigorously everyminute')
- assert 'stir the pasta vigorously' in result.stdout
+ result = manual_command_sets_app.app_cmd("stir pasta vigorously everyminute")
+ assert "stir the pasta vigorously" in result.stdout
class AppWithSubCommands(cmd2.Cmd):
@@ -713,37 +722,37 @@ def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
cut_parser = cmd2.Cmd2ArgumentParser()
- cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut')
+ cut_subparsers = cut_parser.add_subparsers(title="item", help="item to cut")
@cmd2.with_argparser(cut_parser)
def do_cut(self, ns: argparse.Namespace) -> None:
"""Cut something"""
- handler = ns.cmd2_handler.get()
+ handler = ns.cmd2_subcmd_handler
if handler is not None:
# Call whatever subcommand function was selected
handler(ns)
else:
# No subcommand was provided, so call help
- self.poutput('This command does nothing without sub-parsers registered')
- self.do_help('cut')
+ self.poutput("This command does nothing without sub-parsers registered")
+ self.do_help("cut")
banana_parser = cmd2.Cmd2ArgumentParser()
- banana_parser.add_argument('direction', choices=['discs', 'lengthwise'])
+ banana_parser.add_argument("direction", choices=["discs", "lengthwise"])
- @cmd2.as_subcommand_to('cut', 'banana', banana_parser, help='Cut banana', aliases=['bananer'])
+ @cmd2.as_subcommand_to("cut", "banana", banana_parser, help="Cut banana", aliases=["bananer"])
def cut_banana(self, ns: argparse.Namespace) -> None:
"""Cut banana"""
- self.poutput('cutting banana: ' + ns.direction)
+ self.poutput("cutting banana: " + ns.direction)
- def complete_style_arg(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
- return ['quartered', 'diced']
+ def complete_style_arg(self, text: str, line: str, begidx: int, endidx: int) -> Completions:
+ return Completions.from_values(["quartered", "diced"])
bokchoy_parser = cmd2.Cmd2ArgumentParser()
- bokchoy_parser.add_argument('style', completer=complete_style_arg)
+ bokchoy_parser.add_argument("style", completer=complete_style_arg)
- @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser)
+ @cmd2.as_subcommand_to("cut", "bokchoy", bokchoy_parser)
def cut_bokchoy(self, _: argparse.Namespace) -> None:
- self.poutput('Bok Choy')
+ self.poutput("Bok Choy")
@pytest.fixture
@@ -752,44 +761,42 @@ def static_subcommands_app():
def test_static_subcommands(static_subcommands_app) -> None:
- cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = static_subcommands_app._build_command_info()
- assert 'Fruits' in cmds_cats
+ cmds_cats, _help_topics = static_subcommands_app._build_command_info()
+ assert "Fruits" in cmds_cats
- text = ''
- line = f'cut {text}'
+ text = ""
+ line = f"cut {text}"
endidx = len(line)
begidx = endidx
- first_match = complete_tester(text, line, begidx, endidx, static_subcommands_app)
+ completions = static_subcommands_app.complete(text, line, begidx, endidx)
- assert first_match is not None
# check that the alias shows up correctly
- assert static_subcommands_app.completion_matches == ['banana', 'bananer', 'bokchoy']
+ assert completions.to_strings() == Completions.from_values(["banana", "bananer", "bokchoy"]).to_strings()
- text = ''
- line = f'cut bokchoy {text}'
+ text = ""
+ line = f"cut bokchoy {text}"
endidx = len(line)
begidx = endidx
- first_match = complete_tester(text, line, begidx, endidx, static_subcommands_app)
+ completions = static_subcommands_app.complete(text, line, begidx, endidx)
- assert first_match is not None
# verify that argparse completer in commandset functions correctly
- assert static_subcommands_app.completion_matches == ['diced', 'quartered']
+ assert completions.to_strings() == Completions.from_values(["diced", "quartered"]).to_strings()
complete_states_expected_self = None
-@cmd2.with_default_category('With Completer')
class SupportFuncProvider(cmd2.CommandSet):
"""CommandSet which provides a support function (complete_states) to other CommandSets"""
- states = ('alabama', 'alaska', 'arizona', 'arkansas', 'california', 'colorado', 'connecticut', 'delaware')
+ DEFAULT_CATEGORY = "With Completer"
+ states = ("alabama", "alaska", "arizona", "arkansas", "california", "colorado", "connecticut", "delaware")
def __init__(self, dummy) -> None:
"""Dummy variable prevents this from being autoloaded in other tests"""
super().__init__()
- def complete_states(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
+ def complete_states(self, text: str, line: str, begidx: int, endidx: int) -> Completions:
assert self is complete_states_expected_self
return self._cmd.basic_complete(text, line, begidx, endidx, self.states)
@@ -798,22 +805,24 @@ class SupportFuncUserSubclass1(SupportFuncProvider):
"""A sub-class of SupportFuncProvider which uses its support function"""
parser = cmd2.Cmd2ArgumentParser()
- parser.add_argument('state', type=str, completer=SupportFuncProvider.complete_states)
+ parser.add_argument("state", type=str, completer=SupportFuncProvider.complete_states)
@cmd2.with_argparser(parser)
def do_user_sub1(self, ns: argparse.Namespace) -> None:
- self._cmd.poutput(f'something {ns.state}')
+ """User Sub1 Command"""
+ self._cmd.poutput(f"something {ns.state}")
class SupportFuncUserSubclass2(SupportFuncProvider):
"""A second sub-class of SupportFuncProvider which uses its support function"""
parser = cmd2.Cmd2ArgumentParser()
- parser.add_argument('state', type=str, completer=SupportFuncProvider.complete_states)
+ parser.add_argument("state", type=str, completer=SupportFuncProvider.complete_states)
@cmd2.with_argparser(parser)
def do_user_sub2(self, ns: argparse.Namespace) -> None:
- self._cmd.poutput(f'something {ns.state}')
+ """User sub2 Command"""
+ self._cmd.poutput(f"something {ns.state}")
class SupportFuncUserUnrelated(cmd2.CommandSet):
@@ -824,14 +833,15 @@ def __init__(self, dummy) -> None:
super().__init__()
parser = cmd2.Cmd2ArgumentParser()
- parser.add_argument('state', type=str, completer=SupportFuncProvider.complete_states)
+ parser.add_argument("state", type=str, completer=SupportFuncProvider.complete_states)
@cmd2.with_argparser(parser)
def do_user_unrelated(self, ns: argparse.Namespace) -> None:
- self._cmd.poutput(f'something {ns.state}')
+ """User Unrelated Command"""
+ self._cmd.poutput(f"something {ns.state}")
-def test_cross_commandset_completer(manual_command_sets_app, capsys) -> None:
+def test_cross_commandset_completer(manual_command_sets_app) -> None:
global complete_states_expected_self # noqa: PLW0603
# This tests the different ways to locate the matching CommandSet when completing an argparse argument.
# Exercises the 3 cases in cmd2.Cmd._resolve_func_self() which is called during argparse tab completion.
@@ -853,21 +863,18 @@ def test_cross_commandset_completer(manual_command_sets_app, capsys) -> None:
manual_command_sets_app.register_command_set(user_sub1)
manual_command_sets_app.register_command_set(user_sub2)
- text = ''
- line = f'user_sub1 {text}'
+ text = ""
+ line = f"user_sub1 {text}"
endidx = len(line)
begidx = endidx
complete_states_expected_self = user_sub1
- first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app)
+ completions = manual_command_sets_app.complete(text, line, begidx, endidx)
complete_states_expected_self = None
- assert first_match == 'alabama'
- assert manual_command_sets_app.completion_matches == list(SupportFuncProvider.states)
+ assert completions.to_strings() == Completions.from_values(SupportFuncProvider.states).to_strings()
- assert (
- getattr(manual_command_sets_app.cmd_func('user_sub1').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY)
- == 'With Completer'
- )
+ cmds_cats, _help_topics = manual_command_sets_app._build_command_info()
+ assert "user_sub1" in cmds_cats["With Completer"]
manual_command_sets_app.unregister_command_set(user_sub2)
manual_command_sets_app.unregister_command_set(user_sub1)
@@ -880,16 +887,15 @@ def test_cross_commandset_completer(manual_command_sets_app, capsys) -> None:
manual_command_sets_app.register_command_set(func_provider)
manual_command_sets_app.register_command_set(user_unrelated)
- text = ''
- line = f'user_unrelated {text}'
+ text = ""
+ line = f"user_unrelated {text}"
endidx = len(line)
begidx = endidx
complete_states_expected_self = func_provider
- first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app)
+ completions = manual_command_sets_app.complete(text, line, begidx, endidx)
complete_states_expected_self = None
- assert first_match == 'alabama'
- assert manual_command_sets_app.completion_matches == list(SupportFuncProvider.states)
+ assert completions.to_strings() == Completions.from_values(SupportFuncProvider.states).to_strings()
manual_command_sets_app.unregister_command_set(user_unrelated)
manual_command_sets_app.unregister_command_set(func_provider)
@@ -903,16 +909,15 @@ def test_cross_commandset_completer(manual_command_sets_app, capsys) -> None:
manual_command_sets_app.register_command_set(user_sub1)
manual_command_sets_app.register_command_set(user_unrelated)
- text = ''
- line = f'user_unrelated {text}'
+ text = ""
+ line = f"user_unrelated {text}"
endidx = len(line)
begidx = endidx
complete_states_expected_self = user_sub1
- first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app)
+ completions = manual_command_sets_app.complete(text, line, begidx, endidx)
complete_states_expected_self = None
- assert first_match == 'alabama'
- assert manual_command_sets_app.completion_matches == list(SupportFuncProvider.states)
+ assert completions.to_strings() == Completions.from_values(SupportFuncProvider.states).to_strings()
manual_command_sets_app.unregister_command_set(user_unrelated)
manual_command_sets_app.unregister_command_set(user_sub1)
@@ -925,16 +930,14 @@ def test_cross_commandset_completer(manual_command_sets_app, capsys) -> None:
manual_command_sets_app.register_command_set(user_unrelated)
- text = ''
- line = f'user_unrelated {text}'
+ text = ""
+ line = f"user_unrelated {text}"
endidx = len(line)
begidx = endidx
- first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app)
- out, _err = capsys.readouterr()
+ completions = manual_command_sets_app.complete(text, line, begidx, endidx)
- assert first_match is None
- assert manual_command_sets_app.completion_matches == []
- assert "Could not find CommandSet instance" in out
+ assert not completions
+ assert "Could not find CommandSet instance" in completions.error
manual_command_sets_app.unregister_command_set(user_unrelated)
@@ -948,16 +951,14 @@ def test_cross_commandset_completer(manual_command_sets_app, capsys) -> None:
manual_command_sets_app.register_command_set(user_sub2)
manual_command_sets_app.register_command_set(user_unrelated)
- text = ''
- line = f'user_unrelated {text}'
+ text = ""
+ line = f"user_unrelated {text}"
endidx = len(line)
begidx = endidx
- first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app)
- out, _err = capsys.readouterr()
+ completions = manual_command_sets_app.complete(text, line, begidx, endidx)
- assert first_match is None
- assert manual_command_sets_app.completion_matches == []
- assert "Could not find CommandSet instance" in out
+ assert not completions
+ assert "Could not find CommandSet instance" in completions.error
manual_command_sets_app.unregister_command_set(user_unrelated)
manual_command_sets_app.unregister_command_set(user_sub2)
@@ -970,10 +971,11 @@ def __init__(self, dummy) -> None:
super().__init__()
parser = cmd2.Cmd2ArgumentParser()
- parser.add_argument('path', nargs='+', help='paths', completer=cmd2.Cmd.path_complete)
+ parser.add_argument("path", nargs="+", help="paths", completer=cmd2.Cmd.path_complete)
@cmd2.with_argparser(parser)
def do_path(self, app: cmd2.Cmd, args) -> None:
+ """Path Command"""
app.poutput(args.path)
@@ -982,13 +984,13 @@ def test_path_complete(manual_command_sets_app) -> None:
manual_command_sets_app.register_command_set(test_set)
- text = ''
- line = f'path {text}'
+ text = ""
+ line = f"path {text}"
endidx = len(line)
begidx = endidx
- first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app)
+ completions = manual_command_sets_app.complete(text, line, begidx, endidx)
- assert first_match is not None
+ assert completions
def test_bad_subcommand() -> None:
@@ -999,19 +1001,19 @@ def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
cut_parser = cmd2.Cmd2ArgumentParser()
- cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut')
+ cut_subparsers = cut_parser.add_subparsers(title="item", help="item to cut")
@cmd2.with_argparser(cut_parser)
def do_cut(self, ns: argparse.Namespace) -> None:
"""Cut something"""
banana_parser = cmd2.Cmd2ArgumentParser()
- banana_parser.add_argument('direction', choices=['discs', 'lengthwise'])
+ banana_parser.add_argument("direction", choices=["discs", "lengthwise"])
- @cmd2.as_subcommand_to('cut', 'bad name', banana_parser, help='This should fail')
+ @cmd2.as_subcommand_to("cut", "bad name", banana_parser, help="This should fail")
def cut_banana(self, ns: argparse.Namespace) -> None:
"""Cut banana"""
- self.poutput('cutting banana: ' + ns.direction)
+ self.poutput("cutting banana: " + ns.direction)
with pytest.raises(CommandSetRegistrationError):
BadSubcommandApp()
@@ -1029,16 +1031,16 @@ def __init__(self) -> None:
super().__init__()
self._arbitrary = Arbitrary()
- self._settable_prefix = 'addon'
+ self._settable_prefix = "addon"
self.my_int = 11
self.add_settable(
Settable(
- 'arbitrary_value',
+ "arbitrary_value",
int,
- 'Some settable value',
+ "Some settable value",
settable_object=self._arbitrary,
- settable_attrib_name='some_value',
+ settable_attrib_name="some_value",
)
)
@@ -1048,16 +1050,16 @@ def __init__(self) -> None:
super().__init__()
self._arbitrary = Arbitrary()
- self._settable_prefix = ''
+ self._settable_prefix = ""
self.my_int = 11
self.add_settable(
Settable(
- 'another_value',
+ "another_value",
float,
- 'Some settable value',
+ "Some settable value",
settable_object=self._arbitrary,
- settable_attrib_name='some_value',
+ settable_attrib_name="some_value",
)
)
@@ -1067,16 +1069,16 @@ def __init__(self) -> None:
super().__init__()
self._arbitrary = Arbitrary()
- self._settable_prefix = 'some'
+ self._settable_prefix = "some"
self.my_int = 11
self.add_settable(
Settable(
- 'arbitrary_value',
+ "arbitrary_value",
int,
- 'Some settable value',
+ "Some settable value",
settable_object=self._arbitrary,
- settable_attrib_name='some_value',
+ settable_attrib_name="some_value",
)
)
@@ -1084,46 +1086,46 @@ def __init__(self) -> None:
cmdset = WithSettablesA()
arbitrary2 = Arbitrary()
app = cmd2.Cmd(command_sets=[cmdset], auto_load_commands=False)
- app.str_value = ''
- app.add_settable(Settable('always_prefix_settables', bool, 'Prefix settables', app))
- app._settables['str_value'] = Settable('str_value', str, 'String value', app)
+ app.str_value = ""
+ app.add_settable(Settable("always_prefix_settables", bool, "Prefix settables", app))
+ app._settables["str_value"] = Settable("str_value", str, "String value", app)
- assert 'arbitrary_value' in app.settables
- assert 'always_prefix_settables' in app.settables
- assert 'str_value' in app.settables
+ assert "arbitrary_value" in app.settables
+ assert "always_prefix_settables" in app.settables
+ assert "str_value" in app.settables
# verify the settable shows up
- out, err = run_cmd(app, 'set')
- any('arbitrary_value' in line and '5' in line for line in out)
+ out, err = run_cmd(app, "set")
+ any("arbitrary_value" in line and "5" in line for line in out)
- out, err = run_cmd(app, 'set arbitrary_value')
- any('arbitrary_value' in line and '5' in line for line in out)
+ out, err = run_cmd(app, "set arbitrary_value")
+ any("arbitrary_value" in line and "5" in line for line in out)
# change the value and verify the value changed
- out, err = run_cmd(app, 'set arbitrary_value 10')
+ out, err = run_cmd(app, "set arbitrary_value 10")
expected = """
arbitrary_value - was: 5
now: 10
"""
assert out == normalize(expected)
- out, err = run_cmd(app, 'set arbitrary_value')
- any('arbitrary_value' in line and '10' in line for line in out)
+ out, err = run_cmd(app, "set arbitrary_value")
+ any("arbitrary_value" in line and "10" in line for line in out)
# can't add to cmd2 now because commandset already has this settable
with pytest.raises(KeyError):
- app.add_settable(Settable('arbitrary_value', int, 'This should fail', app))
+ app.add_settable(Settable("arbitrary_value", int, "This should fail", app))
cmdset.add_settable(
- Settable('arbitrary_value', int, 'Replaced settable', settable_object=arbitrary2, settable_attrib_name='some_value')
+ Settable("arbitrary_value", int, "Replaced settable", settable_object=arbitrary2, settable_attrib_name="some_value")
)
# Can't add a settable to the commandset that already exists in cmd2
with pytest.raises(KeyError):
- cmdset.add_settable(Settable('always_prefix_settables', int, 'This should also fail', cmdset))
+ cmdset.add_settable(Settable("always_prefix_settables", int, "This should also fail", cmdset))
# Can't remove a settable from the CommandSet if it is elsewhere and not in the CommandSet
with pytest.raises(KeyError):
- cmdset.remove_settable('always_prefix_settables')
+ cmdset.remove_settable("always_prefix_settables")
# verify registering a commandset with duplicate settable names fails
cmdset_dupname = WithSettablesB()
@@ -1132,9 +1134,9 @@ def __init__(self) -> None:
# unregister the CommandSet and verify the settable is now gone
app.unregister_command_set(cmdset)
- out, err = run_cmd(app, 'set')
- assert 'arbitrary_value' not in out
- out, err = run_cmd(app, 'set arbitrary_value')
+ out, err = run_cmd(app, "set")
+ assert "arbitrary_value" not in out
+ out, err = run_cmd(app, "set arbitrary_value")
expected = """
Parameter 'arbitrary_value' not supported (type 'set' for list of parameters).
"""
@@ -1161,35 +1163,35 @@ def __init__(self) -> None:
app.register_command_set(cmdset)
# Verify the settable is back with the defined prefix.
- assert 'addon.arbitrary_value' in app.settables
+ assert "addon.arbitrary_value" in app.settables
# rename the prefix and verify that the prefix changes everywhere
- cmdset._settable_prefix = 'some'
- assert 'addon.arbitrary_value' not in app.settables
- assert 'some.arbitrary_value' in app.settables
+ cmdset._settable_prefix = "some"
+ assert "addon.arbitrary_value" not in app.settables
+ assert "some.arbitrary_value" in app.settables
- out, err = run_cmd(app, 'set')
- any('some.arbitrary_value' in line and '5' in line for line in out)
+ out, err = run_cmd(app, "set")
+ any("some.arbitrary_value" in line and "5" in line for line in out)
- out, err = run_cmd(app, 'set some.arbitrary_value')
- any('some.arbitrary_value' in line and '5' in line for line in out)
+ out, err = run_cmd(app, "set some.arbitrary_value")
+ any("some.arbitrary_value" in line and "5" in line for line in out)
# verify registering a commandset with duplicate prefix and settable names fails
with pytest.raises(CommandSetRegistrationError):
app.register_command_set(cmdset_dupname)
- cmdset_dupname.remove_settable('arbitrary_value')
+ cmdset_dupname.remove_settable("arbitrary_value")
app.register_command_set(cmdset_dupname)
with pytest.raises(KeyError):
cmdset_dupname.add_settable(
Settable(
- 'arbitrary_value',
+ "arbitrary_value",
int,
- 'Some settable value',
+ "Some settable value",
settable_object=cmdset_dupname._arbitrary,
- settable_attrib_name='some_value',
+ settable_attrib_name="some_value",
)
)
diff --git a/tests/test_completion.py b/tests/test_completion.py
index 1b4986f83..b23cb8a3c 100644
--- a/tests/test_completion.py
+++ b/tests/test_completion.py
@@ -1,9 +1,12 @@
-"""Unit/functional testing for readline tab completion functions in the cmd2.py module.
+"""Unit/functional testing for prompt-toolkit tab completion functions in the cmd2.py module.
-These are primarily tests related to readline completer functions which handle tab completion of cmd2/cmd commands,
+These are primarily tests related to prompt-toolkit completer functions which handle tab completion of cmd2/cmd commands,
file system paths, and shell commands.
"""
+import argparse
+import copy
+import dataclasses
import enum
import os
import sys
@@ -13,10 +16,14 @@
import pytest
import cmd2
-from cmd2 import utils
+from cmd2 import (
+ Choices,
+ CompletionItem,
+ Completions,
+ utils,
+)
from .conftest import (
- complete_tester,
normalize,
run_cmd,
)
@@ -27,56 +34,56 @@ class SubcommandsExample(cmd2.Cmd):
and the "sport" subcommand has tab completion enabled.
"""
- sport_item_strs = ('Bat', 'Basket', 'Basketball', 'Football', 'Space Ball')
+ sport_item_strs = ("Bat", "Basket", "Basketball", "Football", "Space Ball")
# create the top-level parser for the base command
base_parser = cmd2.Cmd2ArgumentParser()
- base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help')
+ base_subparsers = base_parser.add_subparsers(title="subcommands", help="subcommand help")
# create the parser for the "foo" subcommand
- parser_foo = base_subparsers.add_parser('foo', help='foo help')
- parser_foo.add_argument('-x', type=int, default=1, help='integer')
- parser_foo.add_argument('y', type=float, help='float')
- parser_foo.add_argument('input_file', type=str, help='Input File')
+ parser_foo = base_subparsers.add_parser("foo", help="foo help")
+ parser_foo.add_argument("-x", type=int, default=1, help="integer")
+ parser_foo.add_argument("y", type=float, help="float")
+ parser_foo.add_argument("input_file", type=str, help="Input File")
# create the parser for the "bar" subcommand
- parser_bar = base_subparsers.add_parser('bar', help='bar help')
+ parser_bar = base_subparsers.add_parser("bar", help="bar help")
- bar_subparsers = parser_bar.add_subparsers(title='layer3', help='help for 3rd layer of commands')
- parser_bar.add_argument('z', help='string')
+ bar_subparsers = parser_bar.add_subparsers(title="layer3", help="help for 3rd layer of commands")
+ parser_bar.add_argument("z", help="string")
- bar_subparsers.add_parser('apple', help='apple help')
- bar_subparsers.add_parser('artichoke', help='artichoke help')
- bar_subparsers.add_parser('cranberries', help='cranberries help')
+ bar_subparsers.add_parser("apple", help="apple help")
+ bar_subparsers.add_parser("artichoke", help="artichoke help")
+ bar_subparsers.add_parser("cranberries", help="cranberries help")
# create the parser for the "sport" subcommand
- parser_sport = base_subparsers.add_parser('sport', help='sport help')
- sport_arg = parser_sport.add_argument('sport', help='Enter name of a sport', choices=sport_item_strs)
+ parser_sport = base_subparsers.add_parser("sport", help="sport help")
+ sport_arg = parser_sport.add_argument("sport", help="Enter name of a sport", choices=sport_item_strs)
# create the top-level parser for the alternate command
# The alternate command doesn't provide its own help flag
base2_parser = cmd2.Cmd2ArgumentParser(add_help=False)
- base2_subparsers = base2_parser.add_subparsers(title='subcommands', help='subcommand help')
+ base2_subparsers = base2_parser.add_subparsers(title="subcommands", help="subcommand help")
# create the parser for the "foo" subcommand
- parser_foo2 = base2_subparsers.add_parser('foo', help='foo help')
- parser_foo2.add_argument('-x', type=int, default=1, help='integer')
- parser_foo2.add_argument('y', type=float, help='float')
- parser_foo2.add_argument('input_file', type=str, help='Input File')
+ parser_foo2 = base2_subparsers.add_parser("foo", help="foo help")
+ parser_foo2.add_argument("-x", type=int, default=1, help="integer")
+ parser_foo2.add_argument("y", type=float, help="float")
+ parser_foo2.add_argument("input_file", type=str, help="Input File")
# create the parser for the "bar" subcommand
- parser_bar2 = base2_subparsers.add_parser('bar', help='bar help')
+ parser_bar2 = base2_subparsers.add_parser("bar", help="bar help")
- bar2_subparsers = parser_bar2.add_subparsers(title='layer3', help='help for 3rd layer of commands')
- parser_bar2.add_argument('z', help='string')
+ bar2_subparsers = parser_bar2.add_subparsers(title="layer3", help="help for 3rd layer of commands")
+ parser_bar2.add_argument("z", help="string")
- bar2_subparsers.add_parser('apple', help='apple help')
- bar2_subparsers.add_parser('artichoke', help='artichoke help')
- bar2_subparsers.add_parser('cranberries', help='cranberries help')
+ bar2_subparsers.add_parser("apple", help="apple help")
+ bar2_subparsers.add_parser("artichoke", help="artichoke help")
+ bar2_subparsers.add_parser("cranberries", help="cranberries help")
# create the parser for the "sport" subcommand
- parser_sport2 = base2_subparsers.add_parser('sport', help='sport help')
- sport2_arg = parser_sport2.add_argument('sport', help='Enter name of a sport', choices=sport_item_strs)
+ parser_sport2 = base2_subparsers.add_parser("sport", help="sport help")
+ sport2_arg = parser_sport2.add_argument("sport", help="Enter name of a sport", choices=sport_item_strs)
def __init__(self) -> None:
super().__init__()
@@ -88,11 +95,11 @@ def base_foo(self, args) -> None:
def base_bar(self, args) -> None:
"""Bar subcommand of base command."""
- self.poutput(f'(({args.z}))')
+ self.poutput(f"(({args.z}))")
def base_sport(self, args) -> None:
"""Sport subcommand of base command."""
- self.poutput(f'Sport is {args.sport}')
+ self.poutput(f"Sport is {args.sport}")
# Set handler functions for the subcommands
parser_foo.set_defaults(func=base_foo)
@@ -102,65 +109,49 @@ def base_sport(self, args) -> None:
@cmd2.with_argparser(base_parser)
def do_base(self, args) -> None:
"""Base command help."""
- func = getattr(args, 'func', None)
+ func = getattr(args, "func", None)
if func is not None:
# Call whatever subcommand function was selected
func(self, args)
else:
# No subcommand was provided, so call help
- self.do_help('base')
+ self.do_help("base")
@cmd2.with_argparser(base2_parser)
def do_alternate(self, args) -> None:
"""Alternate command help."""
- func = getattr(args, 'func', None)
+ func = getattr(args, "func", None)
if func is not None:
# Call whatever subcommand function was selected
func(self, args)
else:
# No subcommand was provided, so call help
- self.do_help('alternate')
+ self.do_help("alternate")
# List of strings used with completion functions
-food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato', 'Cheese "Pizza"']
-sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball']
+food_item_strs = ["Pizza", "Ham", "Ham Sandwich", "Potato", 'Cheese "Pizza"']
+sport_item_strs = ["Bat", "Basket", "Basketball", "Football", "Space Ball"]
delimited_strs = [
- '/home/user/file.txt',
- '/home/user/file space.txt',
- '/home/user/prog.c',
- '/home/other user/maps',
- '/home/other user/tests',
+ "/home/user/file.txt",
+ "/home/user/file space.txt",
+ "/home/user/prog.c",
+ "/home/other user/maps",
+ "/home/other user/tests",
]
-# Dictionary used with flag based completion functions
-flag_dict = {
- # Tab complete food items after -f and --food flag in command line
- '-f': food_item_strs,
- '--food': food_item_strs,
- # Tab complete sport items after -s and --sport flag in command line
- '-s': sport_item_strs,
- '--sport': sport_item_strs,
-}
-
-# Dictionary used with index based completion functions
-index_dict = {
- 1: food_item_strs, # Tab complete food items at index 1 in command line
- 2: sport_item_strs, # Tab complete sport items at index 2 in command line
-}
-
class CompletionsExample(cmd2.Cmd):
"""Example cmd2 application used to exercise tab completion tests"""
def __init__(self) -> None:
- cmd2.Cmd.__init__(self, multiline_commands=['test_multiline'])
- self.foo = 'bar'
+ cmd2.Cmd.__init__(self, multiline_commands=["test_multiline"])
+ self.foo = "bar"
self.add_settable(
utils.Settable(
- 'foo',
+ "foo",
str,
- description="a settable param",
+ description="a test settable param",
settable_object=self,
completer=CompletionsExample.complete_foo_val,
)
@@ -169,20 +160,20 @@ def __init__(self) -> None:
def do_test_basic(self, args) -> None:
pass
- def complete_test_basic(self, text, line, begidx, endidx):
+ def complete_test_basic(self, text, line, begidx, endidx) -> Completions:
return self.basic_complete(text, line, begidx, endidx, food_item_strs)
def do_test_delimited(self, args) -> None:
pass
- def complete_test_delimited(self, text, line, begidx, endidx):
- return self.delimiter_complete(text, line, begidx, endidx, delimited_strs, '/')
+ def complete_test_delimited(self, text, line, begidx, endidx) -> Completions:
+ return self.delimiter_complete(text, line, begidx, endidx, delimited_strs, "/")
def do_test_sort_key(self, args) -> None:
pass
- def complete_test_sort_key(self, text, line, begidx, endidx):
- num_strs = ['2', '11', '1']
+ def complete_test_sort_key(self, text, line, begidx, endidx) -> Completions:
+ num_strs = ["file2", "file11", "file1"]
return self.basic_complete(text, line, begidx, endidx, num_strs)
def do_test_raise_exception(self, args) -> None:
@@ -194,24 +185,23 @@ def complete_test_raise_exception(self, text, line, begidx, endidx) -> NoReturn:
def do_test_multiline(self, args) -> None:
pass
- def complete_test_multiline(self, text, line, begidx, endidx):
+ def complete_test_multiline(self, text, line, begidx, endidx) -> Completions:
return self.basic_complete(text, line, begidx, endidx, sport_item_strs)
def do_test_no_completer(self, args) -> None:
"""Completing this should result in completedefault() being called"""
- def complete_foo_val(self, text, line, begidx, endidx, arg_tokens):
+ def complete_foo_val(self, text, line, begidx, endidx, arg_tokens) -> Completions:
"""Supports unit testing cmd2.Cmd2.complete_set_val to confirm it passes all tokens in the set command"""
- if 'param' in arg_tokens:
- return ["SUCCESS"]
- return ["FAIL"]
+ value = "SUCCESS" if "param" in arg_tokens else "FAIL"
+ return Completions.from_values([value])
- def completedefault(self, *ignored):
+ def completedefault(self, *ignored) -> Completions:
"""Method called to complete an input line when no command-specific
complete_*() method is available.
"""
- return ['default']
+ return Completions.from_values(["default"])
@pytest.fixture
@@ -219,178 +209,150 @@ def cmd2_app():
return CompletionsExample()
-def test_complete_command_single(cmd2_app) -> None:
- text = 'he'
+def test_command_completion(cmd2_app) -> None:
+ text = "run"
line = text
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert cmd2_app.completion_matches == ['help ']
+ expected = ["run_pyscript", "run_script"]
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
-def test_complete_empty_arg(cmd2_app) -> None:
- text = ''
- line = f'help {text}'
+def test_command_completion_nomatch(cmd2_app) -> None:
+ text = "fakecommand"
+ line = text
endidx = len(line)
begidx = endidx - len(text)
- expected = sorted(cmd2_app.get_visible_commands(), key=cmd2_app.default_sort_key)
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert not completions
- assert first_match is not None
- assert cmd2_app.completion_matches == expected
+ # ArgparseCompleter raises a _NoResultsError in this case
+ assert "Hint" in completions.error
def test_complete_bogus_command(cmd2_app) -> None:
- text = ''
- line = f'fizbuzz {text}'
+ text = ""
+ line = f"fizbuzz {text}"
endidx = len(line)
begidx = endidx - len(text)
- expected = ['default ']
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert cmd2_app.completion_matches == expected
+ expected = ["default"]
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
-def test_complete_exception(cmd2_app, capsys) -> None:
- text = ''
- line = f'test_raise_exception {text}'
+def test_complete_exception(cmd2_app) -> None:
+ text = ""
+ line = f"test_raise_exception {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- _out, err = capsys.readouterr()
+ completions = cmd2_app.complete(text, line, begidx, endidx)
- assert first_match is None
- assert "IndexError" in err
+ assert not completions
+ assert "IndexError" in completions.error
def test_complete_macro(base_app, request) -> None:
# Create the macro
- out, _err = run_cmd(base_app, 'macro create fake run_pyscript {1}')
+ out, _err = run_cmd(base_app, "macro create fake run_pyscript {1}")
assert out == normalize("Macro 'fake' created")
# Macros do path completion
test_dir = os.path.dirname(request.module.__file__)
- text = os.path.join(test_dir, 's')
- line = f'fake {text}'
+ text = os.path.join(test_dir, "s")
+ line = f"fake {text}"
endidx = len(line)
begidx = endidx - len(text)
- expected = [text + 'cript.py', text + 'cript.txt', text + 'cripts' + os.path.sep]
- first_match = complete_tester(text, line, begidx, endidx, base_app)
- assert first_match is not None
- assert base_app.completion_matches == expected
+ expected = [text + "cript.py", text + "cript.txt", text + "cripts" + os.path.sep]
+ completions = base_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
-def test_default_sort_key(cmd2_app) -> None:
- text = ''
- line = f'test_sort_key {text}'
+def test_default_str_sort_key(cmd2_app) -> None:
+ text = ""
+ line = f"test_sort_key {text}"
endidx = len(line)
begidx = endidx - len(text)
- # First do alphabetical sorting
- cmd2_app.default_sort_key = cmd2.Cmd.ALPHABETICAL_SORT_KEY
- expected = ['1', '11', '2']
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert cmd2_app.completion_matches == expected
+ saved_sort_key = utils.DEFAULT_STR_SORT_KEY
- # Now switch to natural sorting
- cmd2_app.default_sort_key = cmd2.Cmd.NATURAL_SORT_KEY
- expected = ['1', '2', '11']
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert cmd2_app.completion_matches == expected
-
-
-def test_cmd2_command_completion_multiple(cmd2_app) -> None:
- text = 'h'
- line = text
- endidx = len(line)
- begidx = endidx - len(text)
-
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert cmd2_app.completion_matches == ['help', 'history']
-
-
-def test_cmd2_command_completion_nomatch(cmd2_app) -> None:
- text = 'fakecommand'
- line = text
- endidx = len(line)
- begidx = endidx - len(text)
+ try:
+ # First do alphabetical sorting
+ utils.set_default_str_sort_key(utils.ALPHABETICAL_SORT_KEY)
+ expected = ["file1", "file11", "file2"]
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is None
- assert cmd2_app.completion_matches == []
+ # Now switch to natural sorting
+ utils.set_default_str_sort_key(utils.NATURAL_SORT_KEY)
+ expected = ["file1", "file2", "file11"]
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
+ finally:
+ utils.set_default_str_sort_key(saved_sort_key)
-def test_cmd2_help_completion_single(cmd2_app) -> None:
- text = 'he'
- line = f'help {text}'
+def test_help_completion(cmd2_app) -> None:
+ text = "h"
+ line = f"help {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ expected = ["help", "history"]
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
- # It is at end of line, so extra space is present
- assert first_match is not None
- assert cmd2_app.completion_matches == ['help ']
-
-def test_cmd2_help_completion_multiple(cmd2_app) -> None:
- text = 'h'
- line = f'help {text}'
+def test_help_completion_empty_arg(cmd2_app) -> None:
+ text = ""
+ line = f"help {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert cmd2_app.completion_matches == ['help', 'history']
+ expected = cmd2_app.get_visible_commands()
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
-def test_cmd2_help_completion_nomatch(cmd2_app) -> None:
- text = 'fakecommand'
- line = f'help {text}'
+def test_help_completion_nomatch(cmd2_app) -> None:
+ text = "fakecommand"
+ line = f"help {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is None
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert not completions
def test_set_allow_style_completion(cmd2_app) -> None:
"""Confirm that completing allow_style presents AllowStyle strings"""
- text = ''
- line = 'set allow_style'
+ text = ""
+ line = "set allow_style"
endidx = len(line)
begidx = endidx - len(text)
expected = [val.name.lower() for val in cmd2.rich_utils.AllowStyle]
-
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match
- assert cmd2_app.completion_matches == sorted(expected, key=cmd2_app.default_sort_key)
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
def test_set_bool_completion(cmd2_app) -> None:
"""Confirm that completing a boolean Settable presents true and false strings"""
- text = ''
- line = 'set debug'
+ text = ""
+ line = "set debug"
endidx = len(line)
begidx = endidx - len(text)
- expected = ['false', 'true']
-
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match
- assert cmd2_app.completion_matches == sorted(expected, key=cmd2_app.default_sort_key)
+ expected = ["false", "true"]
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
def test_shell_command_completion_shortcut(cmd2_app) -> None:
@@ -398,183 +360,150 @@ def test_shell_command_completion_shortcut(cmd2_app) -> None:
# isn't a space between ! and the shell command. Display matches won't
# begin with the !.
if sys.platform == "win32":
- text = '!calc'
- expected = ['!calc.exe ']
- expected_display = ['calc.exe']
+ text = "!calc"
+ expected_item = CompletionItem("!calc.exe", display="calc.exe")
else:
- text = '!egr'
- expected = ['!egrep ']
- expected_display = ['egrep']
+ text = "!egr"
+ expected_item = CompletionItem("!egrep", display="egrep")
+
+ expected_completions = Completions([expected_item])
line = text
endidx = len(line)
begidx = 0
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert cmd2_app.completion_matches == expected
- assert cmd2_app.display_matches == expected_display
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == expected_completions.to_strings()
+ assert [item.display for item in completions] == [item.display for item in expected_completions]
-def test_shell_command_completion_doesnt_match_wildcards(cmd2_app) -> None:
+def test_shell_command_completion_does_not_match_wildcards(cmd2_app) -> None:
if sys.platform == "win32":
- text = 'c*'
+ text = "c*"
else:
- text = 'e*'
+ text = "e*"
- line = f'shell {text}'
+ line = f"shell {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is None
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert not completions
-def test_shell_command_completion_multiple(cmd2_app) -> None:
+def test_shell_command_complete(cmd2_app) -> None:
if sys.platform == "win32":
- text = 'c'
- expected = 'calc.exe'
+ text = "c"
+ expected = "calc.exe"
else:
- text = 'l'
- expected = 'ls'
+ text = "l"
+ expected = "ls"
- line = f'shell {text}'
+ line = f"shell {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert expected in cmd2_app.completion_matches
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert expected in completions.to_strings()
def test_shell_command_completion_nomatch(cmd2_app) -> None:
- text = 'zzzz'
- line = f'shell {text}'
+ text = "zzzz"
+ line = f"shell {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is None
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert not completions
-def test_shell_command_completion_doesnt_complete_when_just_shell(cmd2_app) -> None:
- text = ''
- line = f'shell {text}'
+def test_shell_command_completion_does_not_complete_when_just_shell(cmd2_app) -> None:
+ text = ""
+ line = f"shell {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is None
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert not completions
def test_shell_command_completion_does_path_completion_when_after_command(cmd2_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
- text = os.path.join(test_dir, 'conftest')
- line = f'shell cat {text}'
+ text = os.path.join(test_dir, "conftest")
+ line = f"shell cat {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert cmd2_app.completion_matches == [text + '.py ']
+ expected = [text + ".py"]
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
def test_shell_command_complete_in_path(cmd2_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
- text = os.path.join(test_dir, 's')
- line = f'shell {text}'
+ text = os.path.join(test_dir, "s")
+ line = f"shell {text}"
endidx = len(line)
begidx = endidx - len(text)
# Since this will look for directories and executables in the given path,
# we expect to see the scripts dir among the results
- expected = os.path.join(test_dir, 'scripts' + os.path.sep)
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert expected in cmd2_app.completion_matches
+ expected = os.path.join(test_dir, "scripts" + os.path.sep)
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert expected in completions.to_strings()
-def test_path_completion_single_end(cmd2_app, request) -> None:
- test_dir = os.path.dirname(request.module.__file__)
-
- text = os.path.join(test_dir, 'conftest')
- line = f'shell cat {text}'
- endidx = len(line)
- begidx = endidx - len(text)
-
- assert cmd2_app.path_complete(text, line, begidx, endidx) == [text + '.py']
-
-
-def test_path_completion_multiple(cmd2_app, request) -> None:
+def test_path_completion_files_and_directories(cmd2_app, request) -> None:
+ """Test that directories include an ending slash and files do not."""
test_dir = os.path.dirname(request.module.__file__)
- text = os.path.join(test_dir, 's')
- line = f'shell cat {text}'
+ text = os.path.join(test_dir, "s")
+ line = f"shell cat {text}"
endidx = len(line)
begidx = endidx - len(text)
- matches = cmd2_app.path_complete(text, line, begidx, endidx)
- expected = [text + 'cript.py', text + 'cript.txt', text + 'cripts' + os.path.sep]
- assert matches == expected
+ expected = [text + "cript.py", text + "cript.txt", text + "cripts" + os.path.sep]
+ completions = cmd2_app.path_complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
def test_path_completion_nomatch(cmd2_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
- text = os.path.join(test_dir, 'fakepath')
- line = f'shell cat {text}'
+ text = os.path.join(test_dir, "fakepath")
+ line = f"shell cat {text}"
endidx = len(line)
begidx = endidx - len(text)
- assert cmd2_app.path_complete(text, line, begidx, endidx) == []
-
-
-def test_default_to_shell_completion(cmd2_app, request) -> None:
- cmd2_app.default_to_shell = True
- test_dir = os.path.dirname(request.module.__file__)
-
- text = os.path.join(test_dir, 'conftest')
-
- if sys.platform == "win32":
- command = 'calc.exe'
- else:
- command = 'egrep'
-
- # Make sure the command is on the testing system
- assert command in utils.get_exes_in_path(command)
- line = f'{command} {text}'
-
- endidx = len(line)
- begidx = endidx - len(text)
-
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert cmd2_app.completion_matches == [text + '.py ']
+ completions = cmd2_app.path_complete(text, line, begidx, endidx)
+ assert not completions
def test_path_completion_no_text(cmd2_app) -> None:
# Run path complete with no search text which should show what's in cwd
- text = ''
- line = f'shell ls {text}'
+ text = ""
+ line = f"shell ls {text}"
endidx = len(line)
begidx = endidx - len(text)
completions_no_text = cmd2_app.path_complete(text, line, begidx, endidx)
# Run path complete with path set to the CWD
text = os.getcwd() + os.path.sep
- line = f'shell ls {text}'
+ line = f"shell ls {text}"
endidx = len(line)
begidx = endidx - len(text)
+ completions_cwd = cmd2_app.path_complete(text, line, begidx, endidx)
- # We have to strip off the path from the beginning since the matches are entire paths
- completions_cwd = [match.replace(text, '', 1) for match in cmd2_app.path_complete(text, line, begidx, endidx)]
+ # To compare matches, strip off the CWD from the front of completions_cwd.
+ stripped_paths = [CompletionItem(value=item.text.replace(text, "", 1)) for item in completions_cwd]
+ completions_cwd = dataclasses.replace(completions_cwd, items=stripped_paths)
# Verify that the first test gave results for entries in the cwd
assert completions_no_text == completions_cwd
@@ -583,56 +512,60 @@ def test_path_completion_no_text(cmd2_app) -> None:
def test_path_completion_no_path(cmd2_app) -> None:
# Run path complete with search text that isn't preceded by a path. This should use CWD as the path.
- text = 'p'
- line = f'shell ls {text}'
+ text = "p"
+ line = f"shell ls {text}"
endidx = len(line)
begidx = endidx - len(text)
completions_no_text = cmd2_app.path_complete(text, line, begidx, endidx)
# Run path complete with path set to the CWD
text = os.getcwd() + os.path.sep + text
- line = f'shell ls {text}'
+ line = f"shell ls {text}"
endidx = len(line)
begidx = endidx - len(text)
+ completions_cwd = cmd2_app.path_complete(text, line, begidx, endidx)
- # We have to strip off the path from the beginning since the matches are entire paths (Leave the 's')
- completions_cwd = [match.replace(text[:-1], '', 1) for match in cmd2_app.path_complete(text, line, begidx, endidx)]
+ # To compare matches, strip off the CWD from the front of completions_cwd (leave the 's').
+ stripped_paths = [CompletionItem(value=item.text.replace(text[:-1], "", 1)) for item in completions_cwd]
+ completions_cwd = dataclasses.replace(completions_cwd, items=stripped_paths)
# Verify that the first test gave results for entries in the cwd
assert completions_no_text == completions_cwd
assert completions_cwd
-@pytest.mark.skipif(sys.platform == 'win32', reason="this only applies on systems where the root directory is a slash")
+@pytest.mark.skipif(sys.platform == "win32", reason="this only applies on systems where the root directory is a slash")
def test_path_completion_cwd_is_root_dir(cmd2_app) -> None:
# Change our CWD to root dir
cwd = os.getcwd()
- os.chdir(os.path.sep)
+ try:
+ os.chdir(os.path.sep)
- text = ''
- line = f'shell ls {text}'
- endidx = len(line)
- begidx = endidx - len(text)
- completions = cmd2_app.path_complete(text, line, begidx, endidx)
-
- # No match should start with a slash
- assert not any(match.startswith(os.path.sep) for match in completions)
+ text = ""
+ line = f"shell ls {text}"
+ endidx = len(line)
+ begidx = endidx - len(text)
+ completions = cmd2_app.path_complete(text, line, begidx, endidx)
- # Restore CWD
- os.chdir(cwd)
+ # No match should start with a slash
+ assert not any(item.text.startswith(os.path.sep) for item in completions)
+ finally:
+ # Restore CWD
+ os.chdir(cwd)
-def test_path_completion_doesnt_match_wildcards(cmd2_app, request) -> None:
+def test_path_completion_does_not_match_wildcards(cmd2_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
- text = os.path.join(test_dir, 'c*')
- line = f'shell cat {text}'
+ text = os.path.join(test_dir, "c*")
+ line = f"shell cat {text}"
endidx = len(line)
begidx = endidx - len(text)
# Currently path completion doesn't accept wildcards, so will always return empty results
- assert cmd2_app.path_complete(text, line, begidx, endidx) == []
+ completions = cmd2_app.path_complete(text, line, begidx, endidx)
+ assert not completions
def test_path_completion_complete_user(cmd2_app) -> None:
@@ -640,248 +573,125 @@ def test_path_completion_complete_user(cmd2_app) -> None:
user = getpass.getuser()
- text = f'~{user}'
- line = f'shell fake {text}'
+ text = f"~{user}"
+ line = f"shell fake {text}"
endidx = len(line)
begidx = endidx - len(text)
- completions = cmd2_app.path_complete(text, line, begidx, endidx)
expected = text + os.path.sep
- assert expected in completions
+ completions = cmd2_app.path_complete(text, line, begidx, endidx)
+ assert expected in completions.to_strings()
def test_path_completion_user_path_expansion(cmd2_app) -> None:
# Run path with a tilde and a slash
- if sys.platform.startswith('win'):
- cmd = 'dir'
+ if sys.platform.startswith("win"):
+ cmd = "dir"
else:
- cmd = 'ls'
+ cmd = "ls"
# Use a ~ which will be expanded into the user's home directory
- text = f'~{os.path.sep}'
- line = f'shell {cmd} {text}'
- endidx = len(line)
- begidx = endidx - len(text)
- completions_tilde_slash = [match.replace(text, '', 1) for match in cmd2_app.path_complete(text, line, begidx, endidx)]
-
- # Run path complete on the user's home directory
- text = os.path.expanduser('~') + os.path.sep
- line = f'shell {cmd} {text}'
+ text = f"~{os.path.sep}"
+ line = f"shell {cmd} {text}"
endidx = len(line)
begidx = endidx - len(text)
- completions_home = [match.replace(text, '', 1) for match in cmd2_app.path_complete(text, line, begidx, endidx)]
-
- assert completions_tilde_slash == completions_home
+ completions_tilde_slash = cmd2_app.path_complete(text, line, begidx, endidx)
+ # To compare matches, strip off ~/ from the front of completions_tilde_slash.
+ stripped_paths = [CompletionItem(value=item.text.replace(text, "", 1)) for item in completions_tilde_slash]
+ completions_tilde_slash = dataclasses.replace(completions_tilde_slash, items=stripped_paths)
-def test_path_completion_directories_only(cmd2_app, request) -> None:
- test_dir = os.path.dirname(request.module.__file__)
-
- text = os.path.join(test_dir, 's')
- line = f'shell cat {text}'
-
+ # Run path complete on the user's home directory
+ text = os.path.expanduser("~") + os.path.sep
+ line = f"shell {cmd} {text}"
endidx = len(line)
begidx = endidx - len(text)
+ completions_home = cmd2_app.path_complete(text, line, begidx, endidx)
- expected = [text + 'cripts' + os.path.sep]
+ # To compare matches, strip off user's home directory from the front of completions_home.
+ stripped_paths = [CompletionItem(value=item.text.replace(text, "", 1)) for item in completions_home]
+ completions_home = dataclasses.replace(completions_home, items=stripped_paths)
- assert cmd2_app.path_complete(text, line, begidx, endidx, path_filter=os.path.isdir) == expected
-
-
-def test_basic_completion_single(cmd2_app) -> None:
- text = 'Pi'
- line = f'list_food -f {text}'
- endidx = len(line)
- begidx = endidx - len(text)
-
- assert cmd2_app.basic_complete(text, line, begidx, endidx, food_item_strs) == ['Pizza']
+ assert completions_tilde_slash == completions_home
-def test_basic_completion_multiple(cmd2_app) -> None:
- text = ''
- line = f'list_food -f {text}'
+def test_basic_completion(cmd2_app) -> None:
+ text = "P"
+ line = f"list_food -f {text}"
endidx = len(line)
begidx = endidx - len(text)
- matches = sorted(cmd2_app.basic_complete(text, line, begidx, endidx, food_item_strs))
- assert matches == sorted(food_item_strs)
+ expected = ["Pizza", "Potato"]
+ completions = cmd2_app.basic_complete(text, line, begidx, endidx, food_item_strs)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
def test_basic_completion_nomatch(cmd2_app) -> None:
- text = 'q'
- line = f'list_food -f {text}'
+ text = "q"
+ line = f"list_food -f {text}"
endidx = len(line)
begidx = endidx - len(text)
- assert cmd2_app.basic_complete(text, line, begidx, endidx, food_item_strs) == []
+ completions = cmd2_app.basic_complete(text, line, begidx, endidx, food_item_strs)
+ assert not completions
def test_delimiter_completion_partial(cmd2_app) -> None:
"""Test that a delimiter is added when an item has not been fully completed"""
- text = '/home/'
- line = f'command {text}'
+ text = "/home/"
+ line = f"command {text}"
endidx = len(line)
begidx = endidx - len(text)
- matches = cmd2_app.delimiter_complete(text, line, begidx, endidx, delimited_strs, '/')
-
# All matches end with the delimiter
- matches.sort(key=cmd2_app.default_sort_key)
- expected_matches = sorted(["/home/other user/", "/home/user/"], key=cmd2_app.default_sort_key)
-
- cmd2_app.display_matches.sort(key=cmd2_app.default_sort_key)
- expected_display = sorted(["other user/", "user/"], key=cmd2_app.default_sort_key)
+ expected_items = [
+ CompletionItem("/home/other user/", display="other user/"),
+ CompletionItem("/home/user/", display="user/"),
+ ]
+ expected_completions = Completions(expected_items)
+ completions = cmd2_app.delimiter_complete(text, line, begidx, endidx, delimited_strs, "/")
- assert matches == expected_matches
- assert cmd2_app.display_matches == expected_display
+ assert completions.to_strings() == expected_completions.to_strings()
+ assert [item.display for item in completions] == [item.display for item in expected_completions]
def test_delimiter_completion_full(cmd2_app) -> None:
"""Test that no delimiter is added when an item has been fully completed"""
- text = '/home/other user/'
- line = f'command {text}'
+ text = "/home/other user/"
+ line = f"command {text}"
endidx = len(line)
begidx = endidx - len(text)
- matches = cmd2_app.delimiter_complete(text, line, begidx, endidx, delimited_strs, '/')
-
# No matches end with the delimiter
- matches.sort(key=cmd2_app.default_sort_key)
- expected_matches = sorted(["/home/other user/maps", "/home/other user/tests"], key=cmd2_app.default_sort_key)
-
- cmd2_app.display_matches.sort(key=cmd2_app.default_sort_key)
- expected_display = sorted(["maps", "tests"], key=cmd2_app.default_sort_key)
-
- assert matches == expected_matches
- assert cmd2_app.display_matches == expected_display
-
-
-def test_delimiter_completion_nomatch(cmd2_app) -> None:
- text = '/nothing_to_see'
- line = f'command {text}'
- endidx = len(line)
- begidx = endidx - len(text)
-
- assert cmd2_app.delimiter_complete(text, line, begidx, endidx, delimited_strs, '/') == []
-
-
-def test_flag_based_completion_single(cmd2_app) -> None:
- text = 'Pi'
- line = f'list_food -f {text}'
- endidx = len(line)
- begidx = endidx - len(text)
-
- assert cmd2_app.flag_based_complete(text, line, begidx, endidx, flag_dict) == ['Pizza']
-
-
-def test_flag_based_completion_multiple(cmd2_app) -> None:
- text = ''
- line = f'list_food -f {text}'
- endidx = len(line)
- begidx = endidx - len(text)
-
- matches = sorted(cmd2_app.flag_based_complete(text, line, begidx, endidx, flag_dict))
- assert matches == sorted(food_item_strs)
-
-
-def test_flag_based_completion_nomatch(cmd2_app) -> None:
- text = 'q'
- line = f'list_food -f {text}'
- endidx = len(line)
- begidx = endidx - len(text)
-
- assert cmd2_app.flag_based_complete(text, line, begidx, endidx, flag_dict) == []
-
-
-def test_flag_based_default_completer(cmd2_app, request) -> None:
- test_dir = os.path.dirname(request.module.__file__)
-
- text = os.path.join(test_dir, 'c')
- line = f'list_food {text}'
-
- endidx = len(line)
- begidx = endidx - len(text)
-
- assert cmd2_app.flag_based_complete(text, line, begidx, endidx, flag_dict, all_else=cmd2_app.path_complete) == [
- text + 'onftest.py'
+ expected_items = [
+ CompletionItem("/home/other user/maps", display="maps"),
+ CompletionItem("/home/other user/tests", display="tests"),
]
+ expected_completions = Completions(expected_items)
+ completions = cmd2_app.delimiter_complete(text, line, begidx, endidx, delimited_strs, "/")
+ assert completions.to_strings() == expected_completions.to_strings()
+ assert [item.display for item in completions] == [item.display for item in expected_completions]
-def test_flag_based_callable_completer(cmd2_app, request) -> None:
- test_dir = os.path.dirname(request.module.__file__)
-
- text = os.path.join(test_dir, 'c')
- line = f'list_food -o {text}'
-
- endidx = len(line)
- begidx = endidx - len(text)
-
- flag_dict['-o'] = cmd2_app.path_complete
- assert cmd2_app.flag_based_complete(text, line, begidx, endidx, flag_dict) == [text + 'onftest.py']
-
-
-def test_index_based_completion_single(cmd2_app) -> None:
- text = 'Foo'
- line = f'command Pizza {text}'
- endidx = len(line)
- begidx = endidx - len(text)
-
- assert cmd2_app.index_based_complete(text, line, begidx, endidx, index_dict) == ['Football']
-
-
-def test_index_based_completion_multiple(cmd2_app) -> None:
- text = ''
- line = f'command Pizza {text}'
- endidx = len(line)
- begidx = endidx - len(text)
-
- matches = sorted(cmd2_app.index_based_complete(text, line, begidx, endidx, index_dict))
- assert matches == sorted(sport_item_strs)
-
-
-def test_index_based_completion_nomatch(cmd2_app) -> None:
- text = 'q'
- line = f'command {text}'
- endidx = len(line)
- begidx = endidx - len(text)
- assert cmd2_app.index_based_complete(text, line, begidx, endidx, index_dict) == []
-
-
-def test_index_based_default_completer(cmd2_app, request) -> None:
- test_dir = os.path.dirname(request.module.__file__)
-
- text = os.path.join(test_dir, 'c')
- line = f'command Pizza Bat Computer {text}'
-
- endidx = len(line)
- begidx = endidx - len(text)
-
- assert cmd2_app.index_based_complete(text, line, begidx, endidx, index_dict, all_else=cmd2_app.path_complete) == [
- text + 'onftest.py'
- ]
-
-
-def test_index_based_callable_completer(cmd2_app, request) -> None:
- test_dir = os.path.dirname(request.module.__file__)
-
- text = os.path.join(test_dir, 'c')
- line = f'command Pizza Bat {text}'
+def test_delimiter_completion_nomatch(cmd2_app) -> None:
+ text = "/nothing_to_see"
+ line = f"command {text}"
endidx = len(line)
begidx = endidx - len(text)
- index_dict[3] = cmd2_app.path_complete
- assert cmd2_app.index_based_complete(text, line, begidx, endidx, index_dict) == [text + 'onftest.py']
+ completions = cmd2_app.delimiter_complete(text, line, begidx, endidx, delimited_strs, "/")
+ assert not completions
def test_tokens_for_completion_quoted(cmd2_app) -> None:
- text = 'Pi'
+ text = "Pi"
line = f'list_food "{text}"'
endidx = len(line)
begidx = endidx
- expected_tokens = ['list_food', 'Pi', '']
- expected_raw_tokens = ['list_food', '"Pi"', '']
+ expected_tokens = ["list_food", "Pi", ""]
+ expected_raw_tokens = ["list_food", '"Pi"', ""]
tokens, raw_tokens = cmd2_app.tokens_for_completion(line, begidx, endidx)
assert expected_tokens == tokens
@@ -889,13 +699,13 @@ def test_tokens_for_completion_quoted(cmd2_app) -> None:
def test_tokens_for_completion_unclosed_quote(cmd2_app) -> None:
- text = 'Pi'
+ text = "Pi"
line = f'list_food "{text}'
endidx = len(line)
begidx = endidx - len(text)
- expected_tokens = ['list_food', 'Pi']
- expected_raw_tokens = ['list_food', '"Pi']
+ expected_tokens = ["list_food", "Pi"]
+ expected_raw_tokens = ["list_food", '"Pi']
tokens, raw_tokens = cmd2_app.tokens_for_completion(line, begidx, endidx)
assert expected_tokens == tokens
@@ -904,13 +714,13 @@ def test_tokens_for_completion_unclosed_quote(cmd2_app) -> None:
def test_tokens_for_completion_punctuation(cmd2_app) -> None:
"""Test that redirectors and terminators are word delimiters"""
- text = 'file'
- line = f'command | < ;>>{text}'
+ text = "file"
+ line = f"command | < ;>>{text}"
endidx = len(line)
begidx = endidx - len(text)
- expected_tokens = ['command', '|', '<', ';', '>>', 'file']
- expected_raw_tokens = ['command', '|', '<', ';', '>>', 'file']
+ expected_tokens = ["command", "|", "<", ";", ">>", "file"]
+ expected_raw_tokens = ["command", "|", "<", ";", ">>", "file"]
tokens, raw_tokens = cmd2_app.tokens_for_completion(line, begidx, endidx)
assert expected_tokens == tokens
@@ -919,208 +729,295 @@ def test_tokens_for_completion_punctuation(cmd2_app) -> None:
def test_tokens_for_completion_quoted_punctuation(cmd2_app) -> None:
"""Test that quoted punctuation characters are not word delimiters"""
- text = '>file'
+ text = ">file"
line = f'command "{text}'
endidx = len(line)
begidx = endidx - len(text)
- expected_tokens = ['command', '>file']
- expected_raw_tokens = ['command', '">file']
+ expected_tokens = ["command", ">file"]
+ expected_raw_tokens = ["command", '">file']
tokens, raw_tokens = cmd2_app.tokens_for_completion(line, begidx, endidx)
assert expected_tokens == tokens
assert expected_raw_tokens == raw_tokens
-def test_add_opening_quote_basic_no_text(cmd2_app) -> None:
- text = ''
- line = f'test_basic {text}'
+def test_add_opening_quote_double_quote_added(cmd2_app) -> None:
+ text = "Ha"
+ line = f"test_basic {text}"
endidx = len(line)
begidx = endidx - len(text)
- # The whole list will be returned with no opening quotes added
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert cmd2_app.completion_matches == sorted(food_item_strs, key=cmd2_app.default_sort_key)
+ # At least one match has a space, so quote them all
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert completions._add_opening_quote
+ assert completions._quote_char == '"'
-def test_add_opening_quote_basic_nothing_added(cmd2_app) -> None:
- text = 'P'
- line = f'test_basic {text}'
+def test_add_opening_quote_single_quote_added(cmd2_app) -> None:
+ text = "Ch"
+ line = f"test_basic {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert cmd2_app.completion_matches == ['Pizza', 'Potato']
+ # At least one match contains a double quote, so quote them all with a single quote
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert completions._add_opening_quote
+ assert completions._quote_char == "'"
-def test_add_opening_quote_basic_quote_added(cmd2_app) -> None:
- text = 'Ha'
- line = f'test_basic {text}'
+def test_add_opening_quote_nothing_added(cmd2_app) -> None:
+ text = "P"
+ line = f"test_basic {text}"
endidx = len(line)
begidx = endidx - len(text)
- expected = sorted(['"Ham', '"Ham Sandwich'], key=cmd2_app.default_sort_key)
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert cmd2_app.completion_matches == expected
+ # No matches have a space so don't quote them
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert not completions._add_opening_quote
+ assert not completions._quote_char
+
+def test_word_break_in_quote(cmd2_app) -> None:
+ """Test case where search text has a space and is in a quote."""
-def test_add_opening_quote_basic_single_quote_added(cmd2_app) -> None:
- text = 'Ch'
- line = f'test_basic {text}'
+ # Cmd2Completer still performs word breaks after a quote. Since space
+ # is word-break character, it says the search text starts at 'S' and
+ # passes that to the complete() function.
+ text = "S"
+ line = 'test_basic "Ham S'
endidx = len(line)
begidx = endidx - len(text)
- expected = ["'Cheese \"Pizza\"' "]
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert cmd2_app.completion_matches == expected
+ # Since the search text is within an opening quote, cmd2 will rebuild
+ # the whole search token as 'Ham S' and match it to 'Ham Sandwich'.
+ # But before it returns the results back to Cmd2Completer, it removes
+ # anything before the original search text since this is what Cmd2Completer
+ # expects. Therefore the actual match text is 'Sandwich'.
+ expected = ["Sandwich"]
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
-def test_add_opening_quote_basic_text_is_common_prefix(cmd2_app) -> None:
- # This tests when the text entered is the same as the common prefix of the matches
- text = 'Ham'
- line = f'test_basic {text}'
+def test_no_completer(cmd2_app) -> None:
+ text = ""
+ line = f"test_no_completer {text}"
endidx = len(line)
begidx = endidx - len(text)
- expected = sorted(['"Ham', '"Ham Sandwich'], key=cmd2_app.default_sort_key)
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert cmd2_app.completion_matches == expected
+ expected = ["default"]
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
-def test_add_opening_quote_delimited_no_text(cmd2_app) -> None:
- text = ''
- line = f'test_delimited {text}'
+def test_word_break_in_command(cmd2_app) -> None:
+ text = ""
+ line = f'"{text}'
endidx = len(line)
begidx = endidx - len(text)
- # Matches returned with no opening quote
- expected_matches = sorted(["/home/other user/", "/home/user/"], key=cmd2_app.default_sort_key)
- expected_display = sorted(["other user/", "user/"], key=cmd2_app.default_sort_key)
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert not completions
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert cmd2_app.completion_matches == expected_matches
- assert cmd2_app.display_matches == expected_display
-
-def test_add_opening_quote_delimited_nothing_added(cmd2_app) -> None:
- text = '/home/'
- line = f'test_delimited {text}'
+def test_complete_multiline_on_single_line(cmd2_app) -> None:
+ text = ""
+ line = f"test_multiline {text}"
endidx = len(line)
begidx = endidx - len(text)
- expected_matches = sorted(['/home/other user/', '/home/user/'], key=cmd2_app.default_sort_key)
- expected_display = sorted(['other user/', 'user/'], key=cmd2_app.default_sort_key)
+ expected = ["Basket", "Basketball", "Bat", "Football", "Space Ball"]
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert cmd2_app.completion_matches == expected_matches
- assert cmd2_app.display_matches == expected_display
-
-def test_add_opening_quote_delimited_quote_added(cmd2_app) -> None:
- text = '/home/user/fi'
- line = f'test_delimited {text}'
+def test_complete_multiline_on_multiple_lines(cmd2_app) -> None:
+ text = "Ba"
+ line = f"test_multiline\n{text}"
endidx = len(line)
begidx = endidx - len(text)
- expected_common_prefix = '"/home/user/file'
- expected_display = sorted(['file.txt', 'file space.txt'], key=cmd2_app.default_sort_key)
+ expected = ["Bat", "Basket", "Basketball"]
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert os.path.commonprefix(cmd2_app.completion_matches) == expected_common_prefix
- assert cmd2_app.display_matches == expected_display
+def test_completions_iteration() -> None:
+ items = [CompletionItem(1), CompletionItem(2)]
+ completions = Completions(items)
-def test_add_opening_quote_delimited_text_is_common_prefix(cmd2_app) -> None:
- # This tests when the text entered is the same as the common prefix of the matches
- text = '/home/user/file'
- line = f'test_delimited {text}'
- endidx = len(line)
- begidx = endidx - len(text)
+ # Test __iter__
+ assert list(completions) == items
- expected_common_prefix = '"/home/user/file'
- expected_display = sorted(['file.txt', 'file space.txt'], key=cmd2_app.default_sort_key)
+ # Test __reversed__
+ assert list(reversed(completions)) == items[::-1]
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert os.path.commonprefix(cmd2_app.completion_matches) == expected_common_prefix
- assert cmd2_app.display_matches == expected_display
+def test_numeric_sorting() -> None:
+ """Test that numbers and numeric strings are sorted numerically."""
+ numbers = [5, 6, 4, 3, 7.2, 9.1]
+ completions = Completions.from_values(numbers)
+ assert [item.value for item in completions] == sorted(numbers)
-def test_add_opening_quote_delimited_space_in_prefix(cmd2_app) -> None:
- # This tests when a space appears before the part of the string that is the display match
- text = '/home/oth'
- line = f'test_delimited {text}'
- endidx = len(line)
- begidx = endidx - len(text)
+ number_strs = ["5", "6", "4", "3", "7.2", "9.1"]
+ completions = Completions.from_values(number_strs)
+ assert list(completions.to_strings()) == sorted(number_strs, key=float)
- expected_common_prefix = '"/home/other user/'
- expected_display = ['maps', 'tests']
+ mixed = ["5", "6", "4", 3, "7.2", 9.1]
+ completions = Completions.from_values(mixed)
+ assert list(completions.to_strings()) == [str(v) for v in sorted(number_strs, key=float)]
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert os.path.commonprefix(cmd2_app.completion_matches) == expected_common_prefix
- assert cmd2_app.display_matches == expected_display
+def test_is_sorted() -> None:
+ """Test that already sorted results are not re-sorted."""
+ values = [5, 6, 4, 3]
+ already_sorted = Completions.from_values(values, is_sorted=True)
+ sorted_on_creation = Completions.from_values(values, is_sorted=False)
-def test_no_completer(cmd2_app) -> None:
- text = ''
- line = f'test_no_completer {text}'
- endidx = len(line)
- begidx = endidx - len(text)
+ assert already_sorted.to_strings() != sorted_on_creation.to_strings()
+ assert [item.value for item in already_sorted] == values
- expected = ['default ']
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is not None
- assert cmd2_app.completion_matches == expected
+@pytest.mark.parametrize(
+ ("values", "numeric_display"),
+ [
+ ([2, 3], True),
+ ([2, 3.7], True),
+ ([2, "3"], True),
+ ([2.2, "3.4"], True),
+ ([2, "3g"], False),
+ # The display_plain field strips off ANSI sequences
+ (["\x1b[31m5\x1b[0m", "\x1b[32m9.2\x1b[0m"], True),
+ (["\x1b[31mNOT_STRING\x1b[0m", "\x1b[32m9.2\x1b[0m"], False),
+ ],
+)
+def test_numeric_display(values: list[int | float | str], numeric_display: bool) -> None:
+ """Test setting of the Completions.numeric_display field."""
+ completions = Completions.from_values(values)
+ assert completions.numeric_display == numeric_display
-def test_wordbreak_in_command(cmd2_app) -> None:
- text = ''
- line = f'"{text}'
- endidx = len(line)
- begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is None
- assert not cmd2_app.completion_matches
+def test_remove_duplicates() -> None:
+ """Test that duplicate CompletionItems are removed."""
+ # Create items which alter the fields used in CompletionItem.__eq__().
+ orig_item = CompletionItem(value="orig item", display="orig display", display_meta="orig meta")
+ new_value = dataclasses.replace(orig_item, value="new value")
+ new_text = dataclasses.replace(orig_item, text="new text")
+ new_display = dataclasses.replace(orig_item, display="new display")
+ new_meta = dataclasses.replace(orig_item, display_meta="new meta")
-def test_complete_multiline_on_single_line(cmd2_app) -> None:
- text = ''
- line = f'test_multiline {text}'
- endidx = len(line)
- begidx = endidx - len(text)
+ # Include each item twice.
+ items = [orig_item, orig_item, new_value, new_value, new_text, new_text, new_display, new_display, new_meta, new_meta]
+ completions = Completions(items)
- expected = sorted(sport_item_strs, key=cmd2_app.default_sort_key)
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ # Make sure we have exactly 1 of each item.
+ assert len(completions) == 5
+ assert orig_item in completions
+ assert new_value in completions
+ assert new_text in completions
+ assert new_display in completions
+ assert new_meta in completions
- assert first_match is not None
- assert cmd2_app.completion_matches == expected
+def test_completion_item_deepcopy() -> None:
+ """Test that deepcopy of a CompletionItem preserves identity of its members."""
-def test_complete_multiline_on_multiple_lines(cmd2_app) -> None:
- # Set the same variables _complete_statement() sets when a user is entering data at a continuation prompt
- cmd2_app._at_continuation_prompt = True
- cmd2_app._multiline_in_progress = "test_multiline\n"
+ class ComplexValue:
+ pass
- text = 'Ba'
- line = f'{text}'
- endidx = len(line)
- begidx = endidx - len(text)
+ value = ComplexValue()
+ table_data = (ComplexValue(),)
+ orig_item = CompletionItem(
+ value=value,
+ text="my_text",
+ display="my_display",
+ display_meta="my_meta",
+ table_data=table_data,
+ )
+
+ # Perform deepcopy
+ copied_item = copy.deepcopy(orig_item)
+
+ # We should have a new object
+ assert copied_item is not orig_item
+
+ # But its member should be the same objects
+ assert copied_item.value is orig_item.value
+ assert copied_item.text is orig_item.text
+ assert copied_item.display is orig_item.display
+ assert copied_item.display_meta is orig_item.display_meta
+ assert copied_item.table_data is orig_item.table_data
+
+
+def test_plain_fields() -> None:
+ """Test the plain text fields in CompletionItem."""
+ display = "\x1b[31mApple\x1b[0m"
+ display_meta = "\x1b[32mA tasty apple\x1b[0m"
+
+ # Show that the plain fields remove the ANSI sequences.
+ completion_item = CompletionItem("apple", display=display, display_meta=display_meta)
+ assert completion_item.display == display
+ assert completion_item.display_plain == "Apple"
+ assert completion_item.display_meta == display_meta
+ assert completion_item.display_meta_plain == "A tasty apple"
+
+
+def test_clean_display() -> None:
+ """Test display string cleaning in CompletionItem."""
+ # Test all problematic characters being replaced by a single space.
+ # Also verify that \r\n is replaced by a single space.
+ display = "str1\r\nstr2\nstr3\rstr4\tstr5\fstr6\vstr7"
+ expected = "str1 str2 str3 str4 str5 str6 str7"
+
+ # Since display defaults to text if not provided, we test both text and display fields
+ completion_item = CompletionItem("item", display=display, display_meta=display)
+ assert completion_item.display == expected
+ assert completion_item.display_meta == expected
+
+ # Verify that text-derived display is also sanitized
+ text = "item\nwith\nnewlines"
+ expected_text_display = "item with newlines"
+ completion_item = CompletionItem(text)
+ assert completion_item.display == expected_text_display
+
+
+def test_styled_completion_sort() -> None:
+ """Test that sorting is done with the display_plain field."""
+
+ # First sort with strings that include ANSI style sequences.
+ red_apple = "\x1b[31mApple\x1b[0m"
+ green_cherry = "\x1b[32mCherry\x1b[0m"
+ blue_banana = "\x1b[34mBanana\x1b[0m"
+
+ # This sorts by ASCII: [31m (Red), [32m (Green), [34m (Blue)
+ unsorted_strs = [blue_banana, red_apple, green_cherry]
+ sorted_strs = sorted(unsorted_strs, key=utils.DEFAULT_STR_SORT_KEY)
+ assert sorted_strs == [red_apple, green_cherry, blue_banana]
+
+ # Now create a Completions object with these values.
+ unsorted_items = [
+ CompletionItem("banana", display=blue_banana),
+ CompletionItem("cherry", display=green_cherry),
+ CompletionItem("apple", display=red_apple),
+ ]
- expected = sorted(['Bat', 'Basket', 'Basketball'], key=cmd2_app.default_sort_key)
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ completions = Completions(unsorted_items)
- assert first_match is not None
- assert cmd2_app.completion_matches == expected
+ # Expected order: Apple (A), Banana (B), Cherry (C)
+ expected_plain = ["Apple", "Banana", "Cherry"]
+ expected_styled = [red_apple, blue_banana, green_cherry]
+
+ for index, item in enumerate(completions):
+ # Prove the ANSI stripping worked correctly
+ assert item.display_plain == expected_plain[index]
+
+ # Prove the sort order used the plain text, not the ANSI codes
+ assert item.display == expected_styled[index]
+
+ # Prove the order of completions is not the same as the raw string sort order
+ completion_displays = [item.display for item in completions]
+ assert completion_displays != sorted_strs
# Used by redirect_complete tests
@@ -1132,46 +1029,46 @@ class RedirCompType(enum.Enum):
@pytest.mark.parametrize(
- ('line', 'comp_type'),
+ ("line", "comp_type"),
[
- ('fake', RedirCompType.DEFAULT),
- ('fake arg', RedirCompType.DEFAULT),
- ('fake |', RedirCompType.SHELL_CMD),
- ('fake | grep', RedirCompType.PATH),
- ('fake | grep arg', RedirCompType.PATH),
- ('fake | grep >', RedirCompType.PATH),
- ('fake | grep > >', RedirCompType.NONE),
- ('fake | grep > file', RedirCompType.NONE),
- ('fake | grep > file >', RedirCompType.NONE),
- ('fake | grep > file |', RedirCompType.SHELL_CMD),
- ('fake | grep > file | grep', RedirCompType.PATH),
- ('fake | |', RedirCompType.NONE),
- ('fake | >', RedirCompType.NONE),
- ('fake >', RedirCompType.PATH),
- ('fake >>', RedirCompType.PATH),
- ('fake > >', RedirCompType.NONE),
- ('fake > |', RedirCompType.SHELL_CMD),
- ('fake >> file |', RedirCompType.SHELL_CMD),
- ('fake >> file | grep', RedirCompType.PATH),
- ('fake > file', RedirCompType.NONE),
- ('fake > file >', RedirCompType.NONE),
- ('fake > file >>', RedirCompType.NONE),
+ ("fake", RedirCompType.DEFAULT),
+ ("fake arg", RedirCompType.DEFAULT),
+ ("fake |", RedirCompType.SHELL_CMD),
+ ("fake | grep", RedirCompType.PATH),
+ ("fake | grep arg", RedirCompType.PATH),
+ ("fake | grep >", RedirCompType.PATH),
+ ("fake | grep > >", RedirCompType.NONE),
+ ("fake | grep > file", RedirCompType.NONE),
+ ("fake | grep > file >", RedirCompType.NONE),
+ ("fake | grep > file |", RedirCompType.SHELL_CMD),
+ ("fake | grep > file | grep", RedirCompType.PATH),
+ ("fake | |", RedirCompType.NONE),
+ ("fake | >", RedirCompType.NONE),
+ ("fake >", RedirCompType.PATH),
+ ("fake >>", RedirCompType.PATH),
+ ("fake > >", RedirCompType.NONE),
+ ("fake > |", RedirCompType.SHELL_CMD),
+ ("fake >> file |", RedirCompType.SHELL_CMD),
+ ("fake >> file | grep", RedirCompType.PATH),
+ ("fake > file", RedirCompType.NONE),
+ ("fake > file >", RedirCompType.NONE),
+ ("fake > file >>", RedirCompType.NONE),
],
)
def test_redirect_complete(cmd2_app, monkeypatch, line, comp_type) -> None:
# Test both cases of allow_redirection
cmd2_app.allow_redirection = True
for _ in range(2):
- shell_cmd_complete_mock = mock.MagicMock(name='shell_cmd_complete')
+ shell_cmd_complete_mock = mock.MagicMock(name="shell_cmd_complete")
monkeypatch.setattr("cmd2.Cmd.shell_cmd_complete", shell_cmd_complete_mock)
- path_complete_mock = mock.MagicMock(name='path_complete')
+ path_complete_mock = mock.MagicMock(name="path_complete")
monkeypatch.setattr("cmd2.Cmd.path_complete", path_complete_mock)
- default_complete_mock = mock.MagicMock(name='fake_completer')
+ default_complete_mock = mock.MagicMock(name="fake_completer")
- text = ''
- line = f'{line} {text}'
+ text = ""
+ line = f"{line} {text}"
endidx = len(line)
begidx = endidx - len(text)
@@ -1195,27 +1092,26 @@ def test_redirect_complete(cmd2_app, monkeypatch, line, comp_type) -> None:
def test_complete_set_value(cmd2_app) -> None:
- text = ''
- line = f'set foo {text}'
+ text = ""
+ line = f"set foo {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match == "SUCCESS "
- assert cmd2_app.completion_hint == "Hint:\n value a settable param\n"
+ expected = ["SUCCESS"]
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
+ assert completions.hint.strip() == ""
-def test_complete_set_value_invalid_settable(cmd2_app, capsys) -> None:
- text = ''
- line = f'set fake {text}'
+def test_complete_set_value_invalid_settable(cmd2_app) -> None:
+ text = ""
+ line = f"set fake {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
- assert first_match is None
-
- out, _err = capsys.readouterr()
- assert "fake is not a settable parameter" in out
+ completions = cmd2_app.complete(text, line, begidx, endidx)
+ assert not completions
+ assert "fake is not a settable parameter" in completions.error
@pytest.fixture
@@ -1225,111 +1121,70 @@ def sc_app():
return c
-def test_cmd2_subcommand_completion_single_end(sc_app) -> None:
- text = 'f'
- line = f'base {text}'
- endidx = len(line)
- begidx = endidx - len(text)
-
- first_match = complete_tester(text, line, begidx, endidx, sc_app)
-
- # It is at end of line, so extra space is present
- assert first_match is not None
- assert sc_app.completion_matches == ['foo ']
-
-
-def test_cmd2_subcommand_completion_multiple(sc_app) -> None:
- text = ''
- line = f'base {text}'
+def test_cmd2_subcommand_completion(sc_app) -> None:
+ text = ""
+ line = f"base {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, sc_app)
- assert first_match is not None
- assert sc_app.completion_matches == ['bar', 'foo', 'sport']
+ expected = ["bar", "foo", "sport"]
+ completions = sc_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
def test_cmd2_subcommand_completion_nomatch(sc_app) -> None:
- text = 'z'
- line = f'base {text}'
+ text = "z"
+ line = f"base {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, sc_app)
- assert first_match is None
-
-
-def test_help_subcommand_completion_single(sc_app) -> None:
- text = 'base'
- line = f'help {text}'
- endidx = len(line)
- begidx = endidx - len(text)
-
- first_match = complete_tester(text, line, begidx, endidx, sc_app)
-
- # It is at end of line, so extra space is present
- assert first_match is not None
- assert sc_app.completion_matches == ['base ']
+ completions = sc_app.complete(text, line, begidx, endidx)
+ assert not completions
def test_help_subcommand_completion_multiple(sc_app) -> None:
- text = ''
- line = f'help base {text}'
+ text = ""
+ line = f"help base {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, sc_app)
- assert first_match is not None
- assert sc_app.completion_matches == ['bar', 'foo', 'sport']
+ expected = ["bar", "foo", "sport"]
+ completions = sc_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
def test_help_subcommand_completion_nomatch(sc_app) -> None:
- text = 'z'
- line = f'help base {text}'
+ text = "z"
+ line = f"help base {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, sc_app)
- assert first_match is None
+ completions = sc_app.complete(text, line, begidx, endidx)
+ assert not completions
def test_subcommand_tab_completion(sc_app) -> None:
# This makes sure the correct completer for the sport subcommand is called
- text = 'Foot'
- line = f'base sport {text}'
+ text = "Foot"
+ line = f"base sport {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, sc_app)
-
- # It is at end of line, so extra space is present
- assert first_match is not None
- assert sc_app.completion_matches == ['Football ']
+ expected = ["Football"]
+ completions = sc_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
def test_subcommand_tab_completion_with_no_completer(sc_app) -> None:
# This tests what happens when a subcommand has no completer
# In this case, the foo subcommand has no completer defined
- text = 'Foot'
- line = f'base foo {text}'
- endidx = len(line)
- begidx = endidx - len(text)
-
- first_match = complete_tester(text, line, begidx, endidx, sc_app)
- assert first_match is None
-
-
-def test_subcommand_tab_completion_space_in_text(sc_app) -> None:
- text = 'B'
- line = f'base sport "Space {text}'
+ text = "Foot"
+ line = f"base foo {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, sc_app)
-
- assert first_match is not None
- assert sc_app.completion_matches == ['Ball" ']
- assert sc_app.display_matches == ['Space Ball']
+ completions = sc_app.complete(text, line, begidx, endidx)
+ assert not completions
####################################################
@@ -1350,41 +1205,41 @@ def base_foo(self, args) -> None:
def base_bar(self, args) -> None:
"""Bar subcommand of base command"""
- self.poutput(f'(({args.z}))')
+ self.poutput(f"(({args.z}))")
def base_sport(self, args) -> None:
"""Sport subcommand of base command"""
- self.poutput(f'Sport is {args.sport}')
+ self.poutput(f"Sport is {args.sport}")
# create the top-level parser for the base command
base_parser = cmd2.Cmd2ArgumentParser()
- base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help')
+ base_subparsers = base_parser.add_subparsers(title="subcommands", help="subcommand help")
# create the parser for the "foo" subcommand
- parser_foo = base_subparsers.add_parser('foo', help='foo help')
- parser_foo.add_argument('-x', type=int, default=1, help='integer')
- parser_foo.add_argument('y', type=float, help='float')
+ parser_foo = base_subparsers.add_parser("foo", help="foo help")
+ parser_foo.add_argument("-x", type=int, default=1, help="integer")
+ parser_foo.add_argument("y", type=float, help="float")
parser_foo.set_defaults(func=base_foo)
# create the parser for the "bar" subcommand
- parser_bar = base_subparsers.add_parser('bar', help='bar help')
- parser_bar.add_argument('z', help='string')
+ parser_bar = base_subparsers.add_parser("bar", help="bar help")
+ parser_bar.add_argument("z", help="string")
parser_bar.set_defaults(func=base_bar)
# create the parser for the "sport" subcommand
- parser_sport = base_subparsers.add_parser('sport', help='sport help')
- sport_arg = parser_sport.add_argument('sport', help='Enter name of a sport', choices=sport_item_strs)
+ parser_sport = base_subparsers.add_parser("sport", help="sport help")
+ sport_arg = parser_sport.add_argument("sport", help="Enter name of a sport", choices=sport_item_strs)
@cmd2.with_argparser(base_parser, with_unknown_args=True)
def do_base(self, args) -> None:
"""Base command help"""
- func = getattr(args, 'func', None)
+ func = getattr(args, "func", None)
if func is not None:
# Call whatever subcommand function was selected
func(self, args)
else:
# No subcommand was provided, so call help
- self.do_help('base')
+ self.do_help("base")
@pytest.fixture
@@ -1393,132 +1248,225 @@ def scu_app():
return SubcommandsWithUnknownExample()
-def test_subcmd_with_unknown_completion_single_end(scu_app) -> None:
- text = 'f'
- line = f'base {text}'
+def test_subcmd_with_unknown_completion(scu_app) -> None:
+ text = ""
+ line = f"base {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, scu_app)
-
- print(f'first_match: {first_match}')
-
- # It is at end of line, so extra space is present
- assert first_match is not None
- assert scu_app.completion_matches == ['foo ']
-
-
-def test_subcmd_with_unknown_completion_multiple(scu_app) -> None:
- text = ''
- line = f'base {text}'
- endidx = len(line)
- begidx = endidx - len(text)
-
- first_match = complete_tester(text, line, begidx, endidx, scu_app)
- assert first_match is not None
- assert scu_app.completion_matches == ['bar', 'foo', 'sport']
+ expected = ["bar", "foo", "sport"]
+ completions = scu_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
def test_subcmd_with_unknown_completion_nomatch(scu_app) -> None:
- text = 'z'
- line = f'base {text}'
+ text = "z"
+ line = f"base {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, scu_app)
- assert first_match is None
-
-
-def test_help_subcommand_completion_single_scu(scu_app) -> None:
- text = 'base'
- line = f'help {text}'
- endidx = len(line)
- begidx = endidx - len(text)
-
- first_match = complete_tester(text, line, begidx, endidx, scu_app)
-
- # It is at end of line, so extra space is present
- assert first_match is not None
- assert scu_app.completion_matches == ['base ']
+ completions = scu_app.complete(text, line, begidx, endidx)
+ assert not completions
-def test_help_subcommand_completion_multiple_scu(scu_app) -> None:
- text = ''
- line = f'help base {text}'
+def test_help_subcommand_completion_scu(scu_app) -> None:
+ text = ""
+ line = f"help base {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, scu_app)
- assert first_match is not None
- assert scu_app.completion_matches == ['bar', 'foo', 'sport']
+ expected = ["bar", "foo", "sport"]
+ completions = scu_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
def test_help_subcommand_completion_with_flags_before_command(scu_app) -> None:
- text = ''
- line = f'help -h -v base {text}'
+ text = ""
+ line = f"help -h -v base {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, scu_app)
- assert first_match is not None
- assert scu_app.completion_matches == ['bar', 'foo', 'sport']
+ expected = ["bar", "foo", "sport"]
+ completions = scu_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
def test_complete_help_subcommands_with_blank_command(scu_app) -> None:
- text = ''
+ text = ""
line = f'help "" {text}'
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, scu_app)
- assert first_match is None
- assert not scu_app.completion_matches
+ completions = scu_app.complete(text, line, begidx, endidx)
+ assert not completions
def test_help_subcommand_completion_nomatch_scu(scu_app) -> None:
- text = 'z'
- line = f'help base {text}'
+ text = "z"
+ line = f"help base {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, scu_app)
- assert first_match is None
+ completions = scu_app.complete(text, line, begidx, endidx)
+ assert not completions
def test_subcommand_tab_completion_scu(scu_app) -> None:
# This makes sure the correct completer for the sport subcommand is called
- text = 'Foot'
- line = f'base sport {text}'
+ text = "Foot"
+ line = f"base sport {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, scu_app)
-
- # It is at end of line, so extra space is present
- assert first_match is not None
- assert scu_app.completion_matches == ['Football ']
+ expected = ["Football"]
+ completions = scu_app.complete(text, line, begidx, endidx)
+ assert completions.to_strings() == Completions.from_values(expected).to_strings()
def test_subcommand_tab_completion_with_no_completer_scu(scu_app) -> None:
# This tests what happens when a subcommand has no completer
# In this case, the foo subcommand has no completer defined
- text = 'Foot'
- line = f'base foo {text}'
+ text = "Foot"
+ line = f"base foo {text}"
endidx = len(line)
begidx = endidx - len(text)
- first_match = complete_tester(text, line, begidx, endidx, scu_app)
- assert first_match is None
+ completions = scu_app.complete(text, line, begidx, endidx)
+ assert not completions
+
+
+def test_set_completion_item_text() -> None:
+ """Test setting CompletionItem.text and how it affects CompletionItem.display."""
+ value = 5
+
+ # Don't provide text
+ item = CompletionItem(value=value)
+ assert item.text == str(value)
+
+ # Provide text
+ item = CompletionItem(value=value, text="my_text")
+ assert item.text == "my_text"
+
+ # Provide blank text
+ item = CompletionItem(value=value, text="")
+ assert item.text == ""
+
+def test_replace_completion_item_text() -> None:
+ """Test replacing the value of CompletionItem.text"""
+ value = 5
-def test_subcommand_tab_completion_space_in_text_scu(scu_app) -> None:
- text = 'B'
- line = f'base sport "Space {text}'
+ # Replace text value
+ item = CompletionItem(value=value, text="my_text")
+ updated_item = dataclasses.replace(item, text="new_text")
+ assert item.text == "my_text"
+ assert item.display == "my_text"
+
+ # Text should be updated and display should be the same
+ assert updated_item.text == "new_text"
+ assert updated_item.display == "my_text"
+
+ # Replace text value with blank
+ item = CompletionItem(value=value, text="my_text")
+ updated_item = dataclasses.replace(item, text="")
+ assert item.text == "my_text"
+ assert item.display == "my_text"
+
+ # Text should be updated and display should be the same
+ assert updated_item.text == ""
+ assert updated_item.display == "my_text"
+
+
+def test_set_completion_item_display() -> None:
+ """Test setting CompletionItem.display and how it is affected by CompletionItem.text."""
+ value = 5
+
+ # Don't provide text or display
+ value = 5
+ item = CompletionItem(value=value)
+ assert item.text == str(value)
+ assert item.display == item.text
+
+ # Don't provide display but provide text
+ item = CompletionItem(value=value, text="my_text")
+ assert item.text == "my_text"
+ assert item.display == item.text
+
+ # Provide display
+ item = CompletionItem(value=value, text="my_text", display="my_display")
+ assert item.text == "my_text"
+ assert item.display == "my_display"
+
+ # Provide blank display
+ item = CompletionItem(value=value, text="my_text", display="")
+ assert item.text == "my_text"
+ assert item.display == ""
+
+
+def test_replace_completion_item_display() -> None:
+ """Test replacing the value of CompletionItem.display"""
+ value = 5
+
+ # Replace display value
+ item = CompletionItem(value=value, display="my_display")
+ updated_item = dataclasses.replace(item, display="new_display")
+
+ assert item.display == "my_display"
+ assert updated_item.display == "new_display"
+
+ # Replace display value with blank
+ item = CompletionItem(value=value, display="my_display")
+ updated_item = dataclasses.replace(item, display="")
+
+ assert item.display == "my_display"
+ assert updated_item.display == ""
+
+
+def test_full_prefix_removal() -> None:
+ """Verify that Cmd._perform_completion() can clear item.text when
+ text_to_remove matches item.text exactly. This occurs when completing
+ a nested quoted string where the command line already contains the
+ full unquoted content of the completion match.
+ """
+
+ class TestApp(cmd2.Cmd):
+ def get_choices(self) -> Choices:
+ """Return choices."""
+ choices = [
+ "'This is a single-quoted item'",
+ '"This is a double-quoted item"',
+ ]
+ return cmd2.Choices.from_values(choices)
+
+ parser = cmd2.Cmd2ArgumentParser()
+ parser.add_argument("arg", choices_provider=get_choices)
+
+ @cmd2.with_argparser(parser)
+ def do_command(self, args: argparse.Namespace) -> None:
+ """Test stuff."""
+
+ # Test single-quoted item
+ text = ""
+ line = "command \"'This is a single-quoted item'"
endidx = len(line)
- begidx = endidx - len(text)
+ begidx = endidx
+
+ app = TestApp()
+ completions = app.complete(text, line, begidx, endidx)
+ assert len(completions) == 1
+
+ item = completions[0]
+ assert item.text == ""
+
+ # Test double-quoted item
+ text = ""
+ line = 'command \'"This is a double-quoted item"'
+ endidx = len(line)
+ begidx = endidx
- first_match = complete_tester(text, line, begidx, endidx, scu_app)
+ app = TestApp()
+ completions = app.complete(text, line, begidx, endidx)
+ assert len(completions) == 1
- assert first_match is not None
- assert scu_app.completion_matches == ['Ball" ']
- assert scu_app.display_matches == ['Space Ball']
+ item = completions[0]
+ assert item.text == ""
diff --git a/tests/test_dynamic_complete_style.py b/tests/test_dynamic_complete_style.py
new file mode 100644
index 000000000..52dc3fe66
--- /dev/null
+++ b/tests/test_dynamic_complete_style.py
@@ -0,0 +1,75 @@
+import pytest
+from prompt_toolkit.shortcuts import CompleteStyle, PromptSession
+
+import cmd2
+from cmd2 import Completions
+
+
+class AutoStyleApp(cmd2.Cmd):
+ def __init__(self):
+ super().__init__()
+
+ def do_foo(self, args):
+ pass
+
+ def complete_foo(self, text, line, begidx, endidx) -> Completions:
+ # Return 10 items
+ items = [f"item{i}" for i in range(10) if f"item{i}".startswith(text)]
+ return Completions.from_values(items)
+
+ def do_bar(self, args):
+ pass
+
+ def complete_bar(self, text, line, begidx, endidx) -> Completions:
+ # Return 5 items
+ items = [f"item{i}" for i in range(5) if f"item{i}".startswith(text)]
+ return Completions.from_values(items)
+
+
+@pytest.fixture
+def app():
+ return AutoStyleApp()
+
+
+def test_dynamic_complete_style(app):
+ # Cmd.complete() interacts with app.active_session.
+ # Set it here since it's normally set when the prompt is created.
+ app.active_session: PromptSession[str] = PromptSession(
+ input=app.main_session.input,
+ output=app.main_session.output,
+ )
+
+ # Default max_column_completion_results is 7
+ assert app.max_column_completion_results == 7
+
+ # Complete 'foo' which has 10 items (> 7)
+ # text='item', state=0, line='foo item', begidx=4, endidx=8
+ app.complete("item", "foo item", 4, 8)
+ assert app.active_session.complete_style == CompleteStyle.MULTI_COLUMN
+
+ # Complete 'bar' which has 5 items (<= 7)
+ app.complete("item", "bar item", 4, 8)
+ assert app.active_session.complete_style == CompleteStyle.COLUMN
+
+
+def test_dynamic_complete_style_custom_limit(app):
+ # Cmd.complete() interacts with app.active_session.
+ # Set it here since it's normally set when the prompt is created.
+ app.active_session: PromptSession[str] = PromptSession(
+ input=app.main_session.input,
+ output=app.main_session.output,
+ )
+
+ # Change limit to 3
+ app.max_column_completion_results = 3
+
+ # Complete 'bar' which has 5 items (> 3)
+ app.complete("item", "bar item", 4, 8)
+ assert app.active_session.complete_style == CompleteStyle.MULTI_COLUMN
+
+ # Change limit to 15
+ app.max_column_completion_results = 15
+
+ # Complete 'foo' which has 10 items (<= 15)
+ app.complete("item", "foo item", 4, 8)
+ assert app.active_session.complete_style == CompleteStyle.COLUMN
diff --git a/tests/test_future_annotations.py b/tests/test_future_annotations.py
index 8ba0741a6..743522f87 100644
--- a/tests/test_future_annotations.py
+++ b/tests/test_future_annotations.py
@@ -17,6 +17,6 @@ def hook(self: cmd2.Cmd, data: cmd2.plugin.CommandFinalizationData) -> cmd2.plug
return data
hook_app = HookApp()
- out, _err = run_cmd(hook_app, '')
- expected = normalize('')
+ out, _err = run_cmd(hook_app, "")
+ expected = normalize("")
assert out == expected
diff --git a/tests/test_history.py b/tests/test_history.py
index 1754f84f9..8fff6b7e5 100644
--- a/tests/test_history.py
+++ b/tests/test_history.py
@@ -28,19 +28,27 @@ def verify_hi_last_result(app: cmd2.Cmd, expected_length: int) -> None:
#
-# readline tests
+# prompt-toolkit tests
#
-def test_readline_remove_history_item() -> None:
- from cmd2.rl_utils import (
- readline,
- )
+def test_pt_add_history_item() -> None:
+ from prompt_toolkit import PromptSession
+ from prompt_toolkit.history import InMemoryHistory
+ from prompt_toolkit.input import DummyInput
+ from prompt_toolkit.output import DummyOutput
+
+ # Create a history object and add some initial items
+ history = InMemoryHistory()
+ history.append_string("command one")
+ history.append_string("command two")
+ assert "command one" in history.get_strings()
+ assert len(history.get_strings()) == 2
+
+ # Start a session and use this history
+ session = PromptSession(history=history, input=DummyInput(), output=DummyOutput())
- readline.clear_history()
- assert readline.get_current_history_length() == 0
- readline.add_history('this is a test')
- assert readline.get_current_history_length() == 1
- readline.remove_history_item(0)
- assert readline.get_current_history_length() == 0
+ session.history.get_strings().append("new command")
+ assert "new command" not in session.history.get_strings()
+ assert len(history.get_strings()) == 2
#
@@ -58,77 +66,69 @@ def hist():
return History(
[
- HistoryItem(Statement('', raw='first')),
- HistoryItem(Statement('', raw='second')),
- HistoryItem(Statement('', raw='third')),
- HistoryItem(Statement('', raw='fourth')),
+ HistoryItem(Statement("", raw="first")),
+ HistoryItem(Statement("", raw="second")),
+ HistoryItem(Statement("", raw="third")),
+ HistoryItem(Statement("", raw="fourth")),
]
)
# Represents the hist fixture's JSON
hist_json = (
- '{\n'
- ' "history_version": "1.0.0",\n'
+ "{\n"
+ ' "history_version": "4.0.0",\n'
' "history_items": [\n'
- ' {\n'
+ " {\n"
' "statement": {\n'
' "args": "",\n'
' "raw": "first",\n'
' "command": "",\n'
- ' "arg_list": [],\n'
- ' "multiline_command": "",\n'
+ ' "multiline_command": false,\n'
' "terminator": "",\n'
' "suffix": "",\n'
- ' "pipe_to": "",\n'
- ' "output": "",\n'
- ' "output_to": ""\n'
- ' }\n'
- ' },\n'
- ' {\n'
+ ' "redirector": "",\n'
+ ' "redirect_to": ""\n'
+ " }\n"
+ " },\n"
+ " {\n"
' "statement": {\n'
' "args": "",\n'
' "raw": "second",\n'
' "command": "",\n'
- ' "arg_list": [],\n'
- ' "multiline_command": "",\n'
+ ' "multiline_command": false,\n'
' "terminator": "",\n'
' "suffix": "",\n'
- ' "pipe_to": "",\n'
- ' "output": "",\n'
- ' "output_to": ""\n'
- ' }\n'
- ' },\n'
- ' {\n'
+ ' "redirector": "",\n'
+ ' "redirect_to": ""\n'
+ " }\n"
+ " },\n"
+ " {\n"
' "statement": {\n'
' "args": "",\n'
' "raw": "third",\n'
' "command": "",\n'
- ' "arg_list": [],\n'
- ' "multiline_command": "",\n'
+ ' "multiline_command": false,\n'
' "terminator": "",\n'
' "suffix": "",\n'
- ' "pipe_to": "",\n'
- ' "output": "",\n'
- ' "output_to": ""\n'
- ' }\n'
- ' },\n'
- ' {\n'
+ ' "redirector": "",\n'
+ ' "redirect_to": ""\n'
+ " }\n"
+ " },\n"
+ " {\n"
' "statement": {\n'
' "args": "",\n'
' "raw": "fourth",\n'
' "command": "",\n'
- ' "arg_list": [],\n'
- ' "multiline_command": "",\n'
+ ' "multiline_command": false,\n'
' "terminator": "",\n'
' "suffix": "",\n'
- ' "pipe_to": "",\n'
- ' "output": "",\n'
- ' "output_to": ""\n'
- ' }\n'
- ' }\n'
- ' ]\n'
- '}'
+ ' "redirector": "",\n'
+ ' "redirect_to": ""\n'
+ " }\n"
+ " }\n"
+ " ]\n"
+ "}"
)
@@ -144,84 +144,84 @@ def persisted_hist():
h = History(
[
- HistoryItem(Statement('', raw='first')),
- HistoryItem(Statement('', raw='second')),
- HistoryItem(Statement('', raw='third')),
- HistoryItem(Statement('', raw='fourth')),
+ HistoryItem(Statement("", raw="first")),
+ HistoryItem(Statement("", raw="second")),
+ HistoryItem(Statement("", raw="third")),
+ HistoryItem(Statement("", raw="fourth")),
]
)
h.start_session()
- h.append(Statement('', raw='fifth'))
- h.append(Statement('', raw='sixth'))
+ h.append(Statement("", raw="fifth"))
+ h.append(Statement("", raw="sixth"))
return h
def test_history_class_span(hist) -> None:
- span = hist.span('2..')
+ span = hist.span("2..")
assert len(span) == 3
- assert span[2].statement.raw == 'second'
- assert span[3].statement.raw == 'third'
- assert span[4].statement.raw == 'fourth'
+ assert span[2].statement.raw == "second"
+ assert span[3].statement.raw == "third"
+ assert span[4].statement.raw == "fourth"
- span = hist.span('2:')
+ span = hist.span("2:")
assert len(span) == 3
- assert span[2].statement.raw == 'second'
- assert span[3].statement.raw == 'third'
- assert span[4].statement.raw == 'fourth'
+ assert span[2].statement.raw == "second"
+ assert span[3].statement.raw == "third"
+ assert span[4].statement.raw == "fourth"
- span = hist.span('-2..')
+ span = hist.span("-2..")
assert len(span) == 2
- assert span[3].statement.raw == 'third'
- assert span[4].statement.raw == 'fourth'
+ assert span[3].statement.raw == "third"
+ assert span[4].statement.raw == "fourth"
- span = hist.span('-2:')
+ span = hist.span("-2:")
assert len(span) == 2
- assert span[3].statement.raw == 'third'
- assert span[4].statement.raw == 'fourth'
+ assert span[3].statement.raw == "third"
+ assert span[4].statement.raw == "fourth"
- span = hist.span('1..3')
+ span = hist.span("1..3")
assert len(span) == 3
- assert span[1].statement.raw == 'first'
- assert span[2].statement.raw == 'second'
- assert span[3].statement.raw == 'third'
+ assert span[1].statement.raw == "first"
+ assert span[2].statement.raw == "second"
+ assert span[3].statement.raw == "third"
- span = hist.span('1:3')
+ span = hist.span("1:3")
assert len(span) == 3
- assert span[1].statement.raw == 'first'
- assert span[2].statement.raw == 'second'
- assert span[3].statement.raw == 'third'
+ assert span[1].statement.raw == "first"
+ assert span[2].statement.raw == "second"
+ assert span[3].statement.raw == "third"
- span = hist.span('2:-1')
+ span = hist.span("2:-1")
assert len(span) == 3
- assert span[2].statement.raw == 'second'
- assert span[3].statement.raw == 'third'
- assert span[4].statement.raw == 'fourth'
+ assert span[2].statement.raw == "second"
+ assert span[3].statement.raw == "third"
+ assert span[4].statement.raw == "fourth"
- span = hist.span('-3:4')
+ span = hist.span("-3:4")
assert len(span) == 3
- assert span[2].statement.raw == 'second'
- assert span[3].statement.raw == 'third'
- assert span[4].statement.raw == 'fourth'
+ assert span[2].statement.raw == "second"
+ assert span[3].statement.raw == "third"
+ assert span[4].statement.raw == "fourth"
- span = hist.span('-4:-2')
+ span = hist.span("-4:-2")
assert len(span) == 3
- assert span[1].statement.raw == 'first'
- assert span[2].statement.raw == 'second'
- assert span[3].statement.raw == 'third'
+ assert span[1].statement.raw == "first"
+ assert span[2].statement.raw == "second"
+ assert span[3].statement.raw == "third"
- span = hist.span(':-2')
+ span = hist.span(":-2")
assert len(span) == 3
- assert span[1].statement.raw == 'first'
- assert span[2].statement.raw == 'second'
- assert span[3].statement.raw == 'third'
+ assert span[1].statement.raw == "first"
+ assert span[2].statement.raw == "second"
+ assert span[3].statement.raw == "third"
- span = hist.span('..-2')
+ span = hist.span("..-2")
assert len(span) == 3
- assert span[1].statement.raw == 'first'
- assert span[2].statement.raw == 'second'
- assert span[3].statement.raw == 'third'
+ assert span[1].statement.raw == "first"
+ assert span[2].statement.raw == "second"
+ assert span[3].statement.raw == "third"
- value_errors = ['fred', 'fred:joe', '2', '-2', 'a..b', '2 ..', '1 : 3', '1:0', '0:3']
+ value_errors = ["fred", "fred:joe", "2", "-2", "a..b", "2 ..", "1 : 3", "1:0", "0:3"]
expected_err = "History indices must be positive or negative integers, and may not be zero."
for tryit in value_errors:
with pytest.raises(ValueError, match=expected_err):
@@ -229,50 +229,50 @@ def test_history_class_span(hist) -> None:
def test_persisted_history_span(persisted_hist) -> None:
- span = persisted_hist.span('2..')
+ span = persisted_hist.span("2..")
assert len(span) == 5
- assert span[2].statement.raw == 'second'
- assert span[3].statement.raw == 'third'
- assert span[4].statement.raw == 'fourth'
- assert span[5].statement.raw == 'fifth'
- assert span[6].statement.raw == 'sixth'
+ assert span[2].statement.raw == "second"
+ assert span[3].statement.raw == "third"
+ assert span[4].statement.raw == "fourth"
+ assert span[5].statement.raw == "fifth"
+ assert span[6].statement.raw == "sixth"
- span = persisted_hist.span('-2..')
+ span = persisted_hist.span("-2..")
assert len(span) == 2
- assert span[5].statement.raw == 'fifth'
- assert span[6].statement.raw == 'sixth'
+ assert span[5].statement.raw == "fifth"
+ assert span[6].statement.raw == "sixth"
- span = persisted_hist.span('1..3')
+ span = persisted_hist.span("1..3")
assert len(span) == 3
- assert span[1].statement.raw == 'first'
- assert span[2].statement.raw == 'second'
- assert span[3].statement.raw == 'third'
+ assert span[1].statement.raw == "first"
+ assert span[2].statement.raw == "second"
+ assert span[3].statement.raw == "third"
- span = persisted_hist.span('2:-1')
+ span = persisted_hist.span("2:-1")
assert len(span) == 5
- assert span[2].statement.raw == 'second'
- assert span[3].statement.raw == 'third'
- assert span[4].statement.raw == 'fourth'
- assert span[5].statement.raw == 'fifth'
- assert span[6].statement.raw == 'sixth'
+ assert span[2].statement.raw == "second"
+ assert span[3].statement.raw == "third"
+ assert span[4].statement.raw == "fourth"
+ assert span[5].statement.raw == "fifth"
+ assert span[6].statement.raw == "sixth"
- span = persisted_hist.span('-3:4')
+ span = persisted_hist.span("-3:4")
assert len(span) == 1
- assert span[4].statement.raw == 'fourth'
+ assert span[4].statement.raw == "fourth"
- span = persisted_hist.span(':-2', include_persisted=True)
+ span = persisted_hist.span(":-2", include_persisted=True)
assert len(span) == 5
- assert span[1].statement.raw == 'first'
- assert span[2].statement.raw == 'second'
- assert span[3].statement.raw == 'third'
- assert span[4].statement.raw == 'fourth'
- assert span[5].statement.raw == 'fifth'
+ assert span[1].statement.raw == "first"
+ assert span[2].statement.raw == "second"
+ assert span[3].statement.raw == "third"
+ assert span[4].statement.raw == "fourth"
+ assert span[5].statement.raw == "fifth"
- span = persisted_hist.span(':-2', include_persisted=False)
+ span = persisted_hist.span(":-2", include_persisted=False)
assert len(span) == 1
- assert span[5].statement.raw == 'fifth'
+ assert span[5].statement.raw == "fifth"
- value_errors = ['fred', 'fred:joe', '2', '-2', 'a..b', '2 ..', '1 : 3', '1:0', '0:3']
+ value_errors = ["fred", "fred:joe", "2", "-2", "a..b", "2 ..", "1 : 3", "1:0", "0:3"]
expected_err = "History indices must be positive or negative integers, and may not be zero."
for tryit in value_errors:
with pytest.raises(ValueError, match=expected_err):
@@ -280,10 +280,10 @@ def test_persisted_history_span(persisted_hist) -> None:
def test_history_class_get(hist) -> None:
- assert hist.get(1).statement.raw == 'first'
- assert hist.get(3).statement.raw == 'third'
+ assert hist.get(1).statement.raw == "first"
+ assert hist.get(3).statement.raw == "third"
assert hist.get(-2) == hist[-2]
- assert hist.get(-1).statement.raw == 'fourth'
+ assert hist.get(-1).statement.raw == "fourth"
with pytest.raises(IndexError):
hist.get(0)
@@ -293,24 +293,24 @@ def test_history_class_get(hist) -> None:
def test_history_str_search(hist) -> None:
- items = hist.str_search('ir')
+ items = hist.str_search("ir")
assert len(items) == 2
- assert items[1].statement.raw == 'first'
- assert items[3].statement.raw == 'third'
+ assert items[1].statement.raw == "first"
+ assert items[3].statement.raw == "third"
- items = hist.str_search('rth')
+ items = hist.str_search("rth")
assert len(items) == 1
- assert items[4].statement.raw == 'fourth'
+ assert items[4].statement.raw == "fourth"
def test_history_regex_search(hist) -> None:
- items = hist.regex_search('/i.*d/')
+ items = hist.regex_search("/i.*d/")
assert len(items) == 1
- assert items[3].statement.raw == 'third'
+ assert items[3].statement.raw == "third"
- items = hist.regex_search('s[a-z]+ond')
+ items = hist.regex_search("s[a-z]+ond")
assert len(items) == 1
- assert items[2].statement.raw == 'second'
+ assert items[2].statement.raw == "second"
def test_history_max_length_zero(hist) -> None:
@@ -326,8 +326,8 @@ def test_history_max_length_negative(hist) -> None:
def test_history_max_length(hist) -> None:
hist.truncate(2)
assert len(hist) == 2
- assert hist.get(1).statement.raw == 'third'
- assert hist.get(2).statement.raw == 'fourth'
+ assert hist.get(1).statement.raw == "third"
+ assert hist.get(2).statement.raw == "fourth"
def test_history_to_json(hist) -> None:
@@ -357,7 +357,7 @@ def test_history_from_json(hist) -> None:
invalid_ver_json = hist.to_json()
History._history_version = backed_up_ver
- expected_err = "Unsupported history file version: BAD_VERSION. This application uses version 1.0.0."
+ expected_err = f"Unsupported history file version: BAD_VERSION. This application uses version {History._history_version}."
with pytest.raises(ValueError, match=expected_err):
hist.from_json(invalid_ver_json)
@@ -375,10 +375,9 @@ def histitem():
)
statement = Statement(
- 'history',
- raw='help history',
- command='help',
- arg_list=['history'],
+ "history",
+ raw="help history",
+ command="help",
)
return HistoryItem(statement)
@@ -390,16 +389,16 @@ def parser():
)
return StatementParser(
- terminators=[';', '&'],
- multiline_commands=['multiline'],
+ terminators=[";", "&"],
+ multiline_commands=["multiline"],
aliases={
- 'helpalias': 'help',
- '42': 'theanswer',
- 'l': '!ls -al',
- 'anothermultiline': 'multiline',
- 'fake': 'run_pyscript',
+ "helpalias": "help",
+ "42": "theanswer",
+ "l": "!ls -al",
+ "anothermultiline": "multiline",
+ "fake": "run_pyscript",
},
- shortcuts={'?': 'help', '!': 'shell'},
+ shortcuts={"?": "help", "!": "shell"},
)
@@ -408,7 +407,7 @@ def test_multiline_histitem(parser) -> None:
History,
)
- line = 'multiline foo\nbar\n\n'
+ line = "multiline foo\nbar\n\n"
statement = parser.parse(line)
history = History()
history.append(statement)
@@ -416,7 +415,7 @@ def test_multiline_histitem(parser) -> None:
hist_item = history[0]
assert hist_item.raw == line
pr_lines = hist_item.pr(1).splitlines()
- assert pr_lines[0].endswith('multiline foo bar')
+ assert pr_lines[0].endswith("multiline foo bar")
def test_multiline_with_quotes_histitem(parser) -> None:
@@ -436,7 +435,7 @@ def test_multiline_with_quotes_histitem(parser) -> None:
# Since spaces and newlines in quotes are preserved, this history entry spans multiple lines.
pr_lines = hist_item.pr(1).splitlines()
assert pr_lines[0].endswith('Look, "There are newlines')
- assert pr_lines[1] == ' and spaces '
+ assert pr_lines[1] == " and spaces "
assert pr_lines[2] == ' " in quotes.;'
@@ -445,7 +444,7 @@ def test_multiline_histitem_verbose(parser) -> None:
History,
)
- line = 'multiline foo\nbar\n\n'
+ line = "multiline foo\nbar\n\n"
statement = parser.parse(line)
history = History()
history.append(statement)
@@ -453,8 +452,8 @@ def test_multiline_histitem_verbose(parser) -> None:
hist_item = history[0]
assert hist_item.raw == line
pr_lines = hist_item.pr(1, verbose=True).splitlines()
- assert pr_lines[0].endswith('multiline foo')
- assert pr_lines[1] == 'bar'
+ assert pr_lines[0].endswith("multiline foo")
+ assert pr_lines[1] == "bar"
def test_single_line_format_blank(parser) -> None:
@@ -476,28 +475,27 @@ def test_history_item_instantiate() -> None:
)
Statement(
- 'history',
- raw='help history',
- command='help',
- arg_list=['history'],
+ "history",
+ raw="help history",
+ command="help",
)
with pytest.raises(TypeError):
_ = HistoryItem()
def test_history_item_properties(histitem) -> None:
- assert histitem.raw == 'help history'
- assert histitem.expanded == 'help history'
- assert str(histitem) == 'help history'
+ assert histitem.raw == "help history"
+ assert histitem.expanded == "help history"
+ assert str(histitem) == "help history"
#
# test history command
#
def test_base_history(base_app) -> None:
- run_cmd(base_app, 'help')
- run_cmd(base_app, 'shortcuts')
- out, _err = run_cmd(base_app, 'history')
+ run_cmd(base_app, "help")
+ run_cmd(base_app, "shortcuts")
+ out, _err = run_cmd(base_app, "history")
expected = normalize(
"""
1 help
@@ -506,7 +504,7 @@ def test_base_history(base_app) -> None:
)
assert out == expected
- out, _err = run_cmd(base_app, 'history he')
+ out, _err = run_cmd(base_app, "history he")
expected = normalize(
"""
1 help
@@ -515,7 +513,7 @@ def test_base_history(base_app) -> None:
assert out == expected
verify_hi_last_result(base_app, 1)
- out, _err = run_cmd(base_app, 'history sh')
+ out, _err = run_cmd(base_app, "history sh")
expected = normalize(
"""
2 shortcuts
@@ -526,9 +524,9 @@ def test_base_history(base_app) -> None:
def test_history_script_format(base_app) -> None:
- run_cmd(base_app, 'help')
- run_cmd(base_app, 'shortcuts')
- out, _err = run_cmd(base_app, 'history -s')
+ run_cmd(base_app, "help")
+ run_cmd(base_app, "shortcuts")
+ out, _err = run_cmd(base_app, "history -s")
expected = normalize(
"""
help
@@ -540,10 +538,10 @@ def test_history_script_format(base_app) -> None:
def test_history_with_string_argument(base_app) -> None:
- run_cmd(base_app, 'help')
- run_cmd(base_app, 'shortcuts')
- run_cmd(base_app, 'help history')
- out, _err = run_cmd(base_app, 'history help')
+ run_cmd(base_app, "help")
+ run_cmd(base_app, "shortcuts")
+ run_cmd(base_app, "help history")
+ out, _err = run_cmd(base_app, "history help")
expected = normalize(
"""
1 help
@@ -555,11 +553,11 @@ def test_history_with_string_argument(base_app) -> None:
def test_history_expanded_with_string_argument(base_app) -> None:
- run_cmd(base_app, 'alias create sc shortcuts')
- run_cmd(base_app, 'help')
- run_cmd(base_app, 'help history')
- run_cmd(base_app, 'sc')
- out, _err = run_cmd(base_app, 'history -v shortcuts')
+ run_cmd(base_app, "alias create sc shortcuts")
+ run_cmd(base_app, "help")
+ run_cmd(base_app, "help history")
+ run_cmd(base_app, "sc")
+ out, _err = run_cmd(base_app, "history -v shortcuts")
expected = normalize(
"""
1 alias create sc shortcuts
@@ -572,11 +570,11 @@ def test_history_expanded_with_string_argument(base_app) -> None:
def test_history_expanded_with_regex_argument(base_app) -> None:
- run_cmd(base_app, 'alias create sc shortcuts')
- run_cmd(base_app, 'help')
- run_cmd(base_app, 'help history')
- run_cmd(base_app, 'sc')
- out, _err = run_cmd(base_app, 'history -v /sh.*cuts/')
+ run_cmd(base_app, "alias create sc shortcuts")
+ run_cmd(base_app, "help")
+ run_cmd(base_app, "help history")
+ run_cmd(base_app, "sc")
+ out, _err = run_cmd(base_app, "history -v /sh.*cuts/")
expected = normalize(
"""
1 alias create sc shortcuts
@@ -589,9 +587,9 @@ def test_history_expanded_with_regex_argument(base_app) -> None:
def test_history_with_integer_argument(base_app) -> None:
- run_cmd(base_app, 'help')
- run_cmd(base_app, 'shortcuts')
- out, _err = run_cmd(base_app, 'history 1')
+ run_cmd(base_app, "help")
+ run_cmd(base_app, "shortcuts")
+ out, _err = run_cmd(base_app, "history 1")
expected = normalize(
"""
1 help
@@ -602,10 +600,10 @@ def test_history_with_integer_argument(base_app) -> None:
def test_history_with_integer_span(base_app) -> None:
- run_cmd(base_app, 'help')
- run_cmd(base_app, 'shortcuts')
- run_cmd(base_app, 'help history')
- out, _err = run_cmd(base_app, 'history 1..2')
+ run_cmd(base_app, "help")
+ run_cmd(base_app, "shortcuts")
+ run_cmd(base_app, "help history")
+ out, _err = run_cmd(base_app, "history 1..2")
expected = normalize(
"""
1 help
@@ -617,10 +615,10 @@ def test_history_with_integer_span(base_app) -> None:
def test_history_with_span_start(base_app) -> None:
- run_cmd(base_app, 'help')
- run_cmd(base_app, 'shortcuts')
- run_cmd(base_app, 'help history')
- out, _err = run_cmd(base_app, 'history 2:')
+ run_cmd(base_app, "help")
+ run_cmd(base_app, "shortcuts")
+ run_cmd(base_app, "help history")
+ out, _err = run_cmd(base_app, "history 2:")
expected = normalize(
"""
2 shortcuts
@@ -632,10 +630,10 @@ def test_history_with_span_start(base_app) -> None:
def test_history_with_span_end(base_app) -> None:
- run_cmd(base_app, 'help')
- run_cmd(base_app, 'shortcuts')
- run_cmd(base_app, 'help history')
- out, _err = run_cmd(base_app, 'history :2')
+ run_cmd(base_app, "help")
+ run_cmd(base_app, "shortcuts")
+ run_cmd(base_app, "help history")
+ out, _err = run_cmd(base_app, "history :2")
expected = normalize(
"""
1 help
@@ -647,36 +645,36 @@ def test_history_with_span_end(base_app) -> None:
def test_history_with_span_index_error(base_app) -> None:
- run_cmd(base_app, 'help')
- run_cmd(base_app, 'help history')
- run_cmd(base_app, '!ls -hal :')
+ run_cmd(base_app, "help")
+ run_cmd(base_app, "help history")
+ run_cmd(base_app, "!ls -hal :")
expected_err = "History indices must be positive or negative integers, and may not be zero."
with pytest.raises(ValueError, match=expected_err):
base_app.onecmd('history "hal :"')
def test_history_output_file() -> None:
- app = cmd2.Cmd(multiline_commands=['alias'])
- run_cmd(app, 'help')
- run_cmd(app, 'shortcuts')
- run_cmd(app, 'help history')
- run_cmd(app, 'alias create my_alias history;')
+ app = cmd2.Cmd(multiline_commands=["alias"])
+ run_cmd(app, "help")
+ run_cmd(app, "shortcuts")
+ run_cmd(app, "help history")
+ run_cmd(app, "alias create my_alias history;")
- fd, fname = tempfile.mkstemp(prefix='', suffix='.txt')
+ fd, fname = tempfile.mkstemp(prefix="", suffix=".txt")
os.close(fd)
run_cmd(app, f'history -o "{fname}"')
assert app.last_result is True
- expected = normalize('help\nshortcuts\nhelp history\nalias create my_alias history;')
+ expected = normalize("help\nshortcuts\nhelp history\nalias create my_alias history;")
with open(fname) as f:
content = normalize(f.read())
assert content == expected
def test_history_bad_output_file(base_app) -> None:
- run_cmd(base_app, 'help')
- run_cmd(base_app, 'shortcuts')
- run_cmd(base_app, 'help history')
+ run_cmd(base_app, "help")
+ run_cmd(base_app, "shortcuts")
+ run_cmd(base_app, "help history")
fname = os.path.join(os.path.sep, "fake", "fake", "fake")
out, err = run_cmd(base_app, f'history -o "{fname}"')
@@ -687,25 +685,25 @@ def test_history_bad_output_file(base_app) -> None:
def test_history_edit(monkeypatch) -> None:
- app = cmd2.Cmd(multiline_commands=['alias'])
+ app = cmd2.Cmd(multiline_commands=["alias"])
# Set a fake editor just to make sure we have one. We aren't really
# going to call it due to the mock
- app.editor = 'fooedit'
+ app.editor = "fooedit"
# Mock out the run_editor call so we don't actually open an editor
- edit_mock = mock.MagicMock(name='run_editor')
+ edit_mock = mock.MagicMock(name="run_editor")
monkeypatch.setattr("cmd2.Cmd.run_editor", edit_mock)
# Mock out the run_script call since the mocked edit won't produce a file
- run_script_mock = mock.MagicMock(name='do_run_script')
+ run_script_mock = mock.MagicMock(name="do_run_script")
monkeypatch.setattr("cmd2.Cmd.do_run_script", run_script_mock)
# Put commands in history
- run_cmd(app, 'help')
- run_cmd(app, 'alias create my_alias history;')
+ run_cmd(app, "help")
+ run_cmd(app, "alias create my_alias history;")
- run_cmd(app, 'history -e 1:2')
+ run_cmd(app, "history -e 1:2")
# Make sure both functions were called
edit_mock.assert_called_once()
@@ -714,8 +712,8 @@ def test_history_edit(monkeypatch) -> None:
def test_history_run_all_commands(base_app) -> None:
# make sure we refuse to run all commands as a default
- run_cmd(base_app, 'shortcuts')
- out, err = run_cmd(base_app, 'history -r')
+ run_cmd(base_app, "shortcuts")
+ out, err = run_cmd(base_app, "history -r")
assert not out
assert err[0].startswith("Cowardly refusing to run all")
@@ -723,8 +721,8 @@ def test_history_run_all_commands(base_app) -> None:
def test_history_run_one_command(base_app) -> None:
- out1, _err1 = run_cmd(base_app, 'help')
- out2, _err2 = run_cmd(base_app, 'history -r 1')
+ out1, _err1 = run_cmd(base_app, "help")
+ out2, _err2 = run_cmd(base_app, "history -r 1")
assert out1 == out2
assert base_app.last_result is True
@@ -732,52 +730,52 @@ def test_history_run_one_command(base_app) -> None:
def test_history_clear(mocker, hist_file) -> None:
# Add commands to history
app = cmd2.Cmd(persistent_history_file=hist_file)
- run_cmd(app, 'help')
- run_cmd(app, 'alias')
+ run_cmd(app, "help")
+ run_cmd(app, "alias")
# Make sure history has items
- out, err = run_cmd(app, 'history')
+ out, err = run_cmd(app, "history")
assert out
verify_hi_last_result(app, 2)
# Clear the history
- run_cmd(app, 'history --clear')
+ run_cmd(app, "history --clear")
assert app.last_result is True
# Make sure history is empty and its file is gone
- out, err = run_cmd(app, 'history')
+ out, err = run_cmd(app, "history")
assert out == []
assert not os.path.exists(hist_file)
verify_hi_last_result(app, 0)
# Clear the history again and make sure the FileNotFoundError from trying to delete missing history file is silent
- run_cmd(app, 'history --clear')
+ run_cmd(app, "history --clear")
assert app.last_result is True
# Cause os.remove to fail and make sure error gets printed
- mock_remove = mocker.patch('os.remove')
+ mock_remove = mocker.patch("os.remove")
mock_remove.side_effect = OSError
- out, err = run_cmd(app, 'history --clear')
+ out, err = run_cmd(app, "history --clear")
assert out == []
- assert 'Error removing history file' in err[0]
+ assert "Error removing history file" in err[0]
assert app.last_result is False
def test_history_verbose_with_other_options(base_app) -> None:
# make sure -v shows a usage error if any other options are present
- options_to_test = ['-r', '-e', '-o file', '-t file', '-c', '-x']
+ options_to_test = ["-r", "-e", "-o file", "-c", "-x"]
for opt in options_to_test:
- out, _err = run_cmd(base_app, 'history -v ' + opt)
- assert '-v cannot be used with any other options' in out
+ out, _err = run_cmd(base_app, "history -v " + opt)
+ assert "-v cannot be used with any other options" in out
assert base_app.last_result is False
def test_history_verbose(base_app) -> None:
# validate function of -v option
- run_cmd(base_app, 'alias create s shortcuts')
- run_cmd(base_app, 's')
- out, _err = run_cmd(base_app, 'history -v')
+ run_cmd(base_app, "alias create s shortcuts")
+ run_cmd(base_app, "s")
+ out, _err = run_cmd(base_app, "history -v")
expected = normalize(
"""
@@ -791,69 +789,69 @@ def test_history_verbose(base_app) -> None:
def test_history_script_with_invalid_options(base_app) -> None:
- # make sure -s shows a usage error if -c, -r, -e, -o, or -t are present
- options_to_test = ['-r', '-e', '-o file', '-t file', '-c']
+ # make sure -s shows a usage error if -c, -r, -e, or -o are present
+ options_to_test = ["-r", "-e", "-o file", "-c"]
for opt in options_to_test:
- out, _err = run_cmd(base_app, 'history -s ' + opt)
- assert '-s and -x cannot be used with -c, -r, -e, -o, or -t' in out
+ out, _err = run_cmd(base_app, "history -s " + opt)
+ assert "-s and -x cannot be used with -c, -r, -e, or -o" in out
assert base_app.last_result is False
def test_history_script(base_app) -> None:
- cmds = ['alias create s shortcuts', 's']
+ cmds = ["alias create s shortcuts", "s"]
for cmd in cmds:
run_cmd(base_app, cmd)
- out, _err = run_cmd(base_app, 'history -s')
+ out, _err = run_cmd(base_app, "history -s")
assert out == cmds
verify_hi_last_result(base_app, 2)
def test_history_expanded_with_invalid_options(base_app) -> None:
- # make sure -x shows a usage error if -c, -r, -e, -o, or -t are present
- options_to_test = ['-r', '-e', '-o file', '-t file', '-c']
+ # make sure -x shows a usage error if -c, -r, -e, or -o are present
+ options_to_test = ["-r", "-e", "-o file", "-c"]
for opt in options_to_test:
- out, _err = run_cmd(base_app, 'history -x ' + opt)
- assert '-s and -x cannot be used with -c, -r, -e, -o, or -t' in out
+ out, _err = run_cmd(base_app, "history -x " + opt)
+ assert "-s and -x cannot be used with -c, -r, -e, or -o" in out
assert base_app.last_result is False
def test_history_expanded(base_app) -> None:
# validate function of -x option
- cmds = ['alias create s shortcuts', 's']
+ cmds = ["alias create s shortcuts", "s"]
for cmd in cmds:
run_cmd(base_app, cmd)
- out, _err = run_cmd(base_app, 'history -x')
- expected = [' 1 alias create s shortcuts', ' 2 shortcuts']
+ out, _err = run_cmd(base_app, "history -x")
+ expected = [" 1 alias create s shortcuts", " 2 shortcuts"]
assert out == expected
verify_hi_last_result(base_app, 2)
def test_history_script_expanded(base_app) -> None:
# validate function of -s -x options together
- cmds = ['alias create s shortcuts', 's']
+ cmds = ["alias create s shortcuts", "s"]
for cmd in cmds:
run_cmd(base_app, cmd)
- out, _err = run_cmd(base_app, 'history -sx')
- expected = ['alias create s shortcuts', 'shortcuts']
+ out, _err = run_cmd(base_app, "history -sx")
+ expected = ["alias create s shortcuts", "shortcuts"]
assert out == expected
verify_hi_last_result(base_app, 2)
def test_exclude_from_history(base_app) -> None:
# Run history command
- run_cmd(base_app, 'history')
+ run_cmd(base_app, "history")
verify_hi_last_result(base_app, 0)
# Verify that the history is empty
- out, _err = run_cmd(base_app, 'history')
+ out, _err = run_cmd(base_app, "history")
assert out == []
verify_hi_last_result(base_app, 0)
# Now run a command which isn't excluded from the history
- run_cmd(base_app, 'help')
+ run_cmd(base_app, "help")
# And verify we have a history now ...
- out, _err = run_cmd(base_app, 'history')
+ out, _err = run_cmd(base_app, "history")
expected = normalize(""" 1 help""")
assert out == expected
verify_hi_last_result(base_app, 1)
@@ -864,7 +862,7 @@ def test_exclude_from_history(base_app) -> None:
#
@pytest.fixture(scope="session")
def hist_file():
- fd, filename = tempfile.mkstemp(prefix='hist_file', suffix='.dat')
+ fd, filename = tempfile.mkstemp(prefix="hist_file", suffix=".dat")
os.close(fd)
yield filename
# teardown code
@@ -877,13 +875,13 @@ def test_history_file_is_directory(capsys) -> None:
# Create a new cmd2 app
cmd2.Cmd(persistent_history_file=test_dir)
_, err = capsys.readouterr()
- assert 'is a directory' in err
+ assert "is a directory" in err
def test_history_can_create_directory(mocker) -> None:
# Mock out atexit.register so the persistent file doesn't written when this function
# exists because we will be deleting the directory it needs to go to.
- mocker.patch('atexit.register')
+ mocker.patch("atexit.register")
# Create a temp path for us to use and let it get deleted
with tempfile.TemporaryDirectory() as test_dir:
@@ -891,8 +889,8 @@ def test_history_can_create_directory(mocker) -> None:
assert not os.path.isdir(test_dir)
# Add some subdirectories for the complete history file directory
- hist_file_dir = os.path.join(test_dir, 'subdir1', 'subdir2')
- hist_file = os.path.join(hist_file_dir, 'hist_file')
+ hist_file_dir = os.path.join(test_dir, "subdir1", "subdir2")
+ hist_file = os.path.join(hist_file_dir, "hist_file")
# Make sure cmd2 creates the history file directory
cmd2.Cmd(persistent_history_file=hist_file)
@@ -903,34 +901,34 @@ def test_history_can_create_directory(mocker) -> None:
def test_history_cannot_create_directory(mocker, capsys) -> None:
- mock_open = mocker.patch('os.makedirs')
+ mock_open = mocker.patch("os.makedirs")
mock_open.side_effect = OSError
- hist_file_path = os.path.join('fake_dir', 'file')
+ hist_file_path = os.path.join("fake_dir", "file")
cmd2.Cmd(persistent_history_file=hist_file_path)
_, err = capsys.readouterr()
- assert 'Error creating persistent history file directory' in err
+ assert "Error creating persistent history file directory" in err
def test_history_file_permission_error(mocker, capsys) -> None:
- mock_open = mocker.patch('builtins.open')
+ mock_open = mocker.patch("builtins.open")
mock_open.side_effect = PermissionError
- cmd2.Cmd(persistent_history_file='/tmp/doesntmatter')
+ cmd2.Cmd(persistent_history_file="/tmp/doesntmatter")
out, err = capsys.readouterr()
assert not out
- assert 'Cannot read persistent history file' in err
+ assert "Cannot read persistent history file" in err
def test_history_file_bad_compression(mocker, capsys) -> None:
- history_file = '/tmp/doesntmatter'
+ history_file = "/tmp/doesntmatter"
with open(history_file, "wb") as f:
f.write(b"THIS IS NOT COMPRESSED DATA")
cmd2.Cmd(persistent_history_file=history_file)
out, err = capsys.readouterr()
assert not out
- assert 'Error decompressing persistent history data' in err
+ assert "Error decompressing persistent history data" in err
def test_history_file_bad_json(mocker, capsys) -> None:
@@ -939,45 +937,42 @@ def test_history_file_bad_json(mocker, capsys) -> None:
data = b"THIS IS NOT JSON"
compressed_data = lzma.compress(data)
- history_file = '/tmp/doesntmatter'
+ history_file = "/tmp/doesntmatter"
with open(history_file, "wb") as f:
f.write(compressed_data)
cmd2.Cmd(persistent_history_file=history_file)
out, err = capsys.readouterr()
assert not out
- assert 'Error processing persistent history data' in err
+ assert "Error processing persistent history data" in err
-def test_history_populates_readline(hist_file) -> None:
+def test_history_populates_pt(hist_file) -> None:
# - create a cmd2 with persistent history
app = cmd2.Cmd(persistent_history_file=hist_file)
- run_cmd(app, 'help')
- run_cmd(app, 'shortcuts')
- run_cmd(app, 'shortcuts')
- run_cmd(app, 'alias')
+ run_cmd(app, "help")
+ run_cmd(app, "shortcuts")
+ run_cmd(app, "shortcuts")
+ run_cmd(app, "alias")
# call the private method which is registered to write history at exit
app._persist_history()
# see if history came back
app = cmd2.Cmd(persistent_history_file=hist_file)
assert len(app.history) == 4
- assert app.history.get(1).statement.raw == 'help'
- assert app.history.get(2).statement.raw == 'shortcuts'
- assert app.history.get(3).statement.raw == 'shortcuts'
- assert app.history.get(4).statement.raw == 'alias'
+ assert app.history.get(1).statement.raw == "help"
+ assert app.history.get(2).statement.raw == "shortcuts"
+ assert app.history.get(3).statement.raw == "shortcuts"
+ assert app.history.get(4).statement.raw == "alias"
- # readline only adds a single entry for multiple sequential identical commands
- # so we check to make sure that cmd2 populated the readline history
+ # prompt-toolkit only adds a single entry for multiple sequential identical commands
+ # so we check to make sure that cmd2 populated the prompt-toolkit history
# using the same rules
- from cmd2.rl_utils import (
- readline,
- )
-
- assert readline.get_current_history_length() == 3
- assert readline.get_history_item(1) == 'help'
- assert readline.get_history_item(2) == 'shortcuts'
- assert readline.get_history_item(3) == 'alias'
+ pt_history = app.main_session.history.get_strings()
+ assert len(pt_history) == 3
+ assert pt_history[0] == "help"
+ assert pt_history[1] == "shortcuts"
+ assert pt_history[2] == "alias"
#
@@ -997,10 +992,10 @@ def test_persist_history_ensure_no_error_if_no_histfile(base_app, capsys) -> Non
def test_persist_history_permission_error(hist_file, mocker, capsys) -> None:
app = cmd2.Cmd(persistent_history_file=hist_file)
- run_cmd(app, 'help')
- mock_open = mocker.patch('builtins.open')
+ run_cmd(app, "help")
+ mock_open = mocker.patch("builtins.open")
mock_open.side_effect = PermissionError
app._persist_history()
out, err = capsys.readouterr()
assert not out
- assert 'Cannot write persistent history file' in err
+ assert "Cannot write persistent history file" in err
diff --git a/tests/test_parsing.py b/tests/test_parsing.py
index b7af37145..2bb21ba82 100644
--- a/tests/test_parsing.py
+++ b/tests/test_parsing.py
@@ -20,16 +20,16 @@
@pytest.fixture
def parser():
return StatementParser(
- terminators=[';', '&'],
- multiline_commands=['multiline'],
+ terminators=[";", "&"],
+ multiline_commands=["multiline"],
aliases={
- 'helpalias': 'help',
- '42': 'theanswer',
- 'l': '!ls -al',
- 'anothermultiline': 'multiline',
- 'fake': 'run_pyscript',
+ "helpalias": "help",
+ "42": "theanswer",
+ "l": "!ls -al",
+ "anothermultiline": "multiline",
+ "fake": "run_pyscript",
},
- shortcuts={'?': 'help', '!': 'shell'},
+ shortcuts={"?": "help", "!": "shell"},
)
@@ -39,52 +39,50 @@ def default_parser():
def test_parse_empty_string(parser) -> None:
- line = ''
+ line = ""
statement = parser.parse(line)
- assert statement == ''
+ assert statement == ""
assert statement.args == statement
assert statement.raw == line
- assert statement.command == ''
+ assert statement.command == ""
assert statement.arg_list == []
- assert statement.multiline_command == ''
- assert statement.terminator == ''
- assert statement.suffix == ''
- assert statement.pipe_to == ''
- assert statement.output == ''
- assert statement.output_to == ''
+ assert not statement.multiline_command
+ assert statement.terminator == ""
+ assert statement.suffix == ""
+ assert statement.redirector == ""
+ assert statement.redirect_to == ""
assert statement.command_and_args == line
assert statement.argv == statement.arg_list
def test_parse_empty_string_default(default_parser) -> None:
- line = ''
+ line = ""
statement = default_parser.parse(line)
- assert statement == ''
+ assert statement == ""
assert statement.args == statement
assert statement.raw == line
- assert statement.command == ''
+ assert statement.command == ""
assert statement.arg_list == []
- assert statement.multiline_command == ''
- assert statement.terminator == ''
- assert statement.suffix == ''
- assert statement.pipe_to == ''
- assert statement.output == ''
- assert statement.output_to == ''
+ assert not statement.multiline_command
+ assert statement.terminator == ""
+ assert statement.suffix == ""
+ assert statement.redirector == ""
+ assert statement.redirect_to == ""
assert statement.command_and_args == line
assert statement.argv == statement.arg_list
@pytest.mark.parametrize(
- ('line', 'tokens'),
+ ("line", "tokens"),
[
- ('command', ['command']),
- (constants.COMMENT_CHAR + 'comment', []),
- ('not ' + constants.COMMENT_CHAR + ' a comment', ['not', constants.COMMENT_CHAR, 'a', 'comment']),
- ('termbare ; > /tmp/output', ['termbare', ';', '>', '/tmp/output']),
- ('termbare; > /tmp/output', ['termbare', ';', '>', '/tmp/output']),
- ('termbare & > /tmp/output', ['termbare', '&', '>', '/tmp/output']),
- ('termbare& > /tmp/output', ['termbare&', '>', '/tmp/output']),
- ('help|less', ['help', '|', 'less']),
+ ("command", ["command"]),
+ (constants.COMMENT_CHAR + "comment", []),
+ ("not " + constants.COMMENT_CHAR + " a comment", ["not", constants.COMMENT_CHAR, "a", "comment"]),
+ ("termbare ; > /tmp/output", ["termbare", ";", ">", "/tmp/output"]),
+ ("termbare; > /tmp/output", ["termbare", ";", ">", "/tmp/output"]),
+ ("termbare & > /tmp/output", ["termbare", "&", ">", "/tmp/output"]),
+ ("termbare& > /tmp/output", ["termbare&", ">", "/tmp/output"]),
+ ("help|less", ["help", "|", "less"]),
],
)
def test_tokenize_default(default_parser, line, tokens) -> None:
@@ -93,19 +91,19 @@ def test_tokenize_default(default_parser, line, tokens) -> None:
@pytest.mark.parametrize(
- ('line', 'tokens'),
+ ("line", "tokens"),
[
- ('command', ['command']),
- ('# comment', []),
- ('not ' + constants.COMMENT_CHAR + ' a comment', ['not', constants.COMMENT_CHAR, 'a', 'comment']),
- ('42 arg1 arg2', ['theanswer', 'arg1', 'arg2']),
- ('l', ['shell', 'ls', '-al']),
- ('termbare ; > /tmp/output', ['termbare', ';', '>', '/tmp/output']),
- ('termbare; > /tmp/output', ['termbare', ';', '>', '/tmp/output']),
- ('termbare & > /tmp/output', ['termbare', '&', '>', '/tmp/output']),
- ('termbare& > /tmp/output', ['termbare', '&', '>', '/tmp/output']),
- ('help|less', ['help', '|', 'less']),
- ('l|less', ['shell', 'ls', '-al', '|', 'less']),
+ ("command", ["command"]),
+ ("# comment", []),
+ ("not " + constants.COMMENT_CHAR + " a comment", ["not", constants.COMMENT_CHAR, "a", "comment"]),
+ ("42 arg1 arg2", ["theanswer", "arg1", "arg2"]),
+ ("l", ["shell", "ls", "-al"]),
+ ("termbare ; > /tmp/output", ["termbare", ";", ">", "/tmp/output"]),
+ ("termbare; > /tmp/output", ["termbare", ";", ">", "/tmp/output"]),
+ ("termbare & > /tmp/output", ["termbare", "&", ">", "/tmp/output"]),
+ ("termbare& > /tmp/output", ["termbare", "&", ">", "/tmp/output"]),
+ ("help|less", ["help", "|", "less"]),
+ ("l|less", ["shell", "ls", "-al", "|", "less"]),
],
)
def test_tokenize(parser, line, tokens) -> None:
@@ -119,8 +117,8 @@ def test_tokenize_unclosed_quotes(parser) -> None:
@pytest.mark.parametrize(
- ('tokens', 'command', 'args'),
- [([], '', ''), (['command'], 'command', ''), (['command', 'arg1', 'arg2'], 'command', 'arg1 arg2')],
+ ("tokens", "command", "args"),
+ [([], "", ""), (["command"], "command", ""), (["command", "arg1", "arg2"], "command", "arg1 arg2")],
)
def test_command_and_args(parser, tokens, command, args) -> None:
(parsed_command, parsed_args) = parser._command_and_args(tokens)
@@ -129,9 +127,9 @@ def test_command_and_args(parser, tokens, command, args) -> None:
@pytest.mark.parametrize(
- 'line',
+ "line",
[
- 'plainword',
+ "plainword",
'"one word"',
"'one word'",
],
@@ -139,362 +137,354 @@ def test_command_and_args(parser, tokens, command, args) -> None:
def test_parse_single_word(parser, line) -> None:
statement = parser.parse(line)
assert statement.command == line
- assert statement == ''
+ assert statement == ""
+ assert statement.args == statement
assert statement.argv == [su.strip_quotes(line)]
assert not statement.arg_list
- assert statement.args == statement
assert statement.raw == line
- assert statement.multiline_command == ''
- assert statement.terminator == ''
- assert statement.suffix == ''
- assert statement.pipe_to == ''
- assert statement.output == ''
- assert statement.output_to == ''
+ assert not statement.multiline_command
+ assert statement.terminator == ""
+ assert statement.suffix == ""
+ assert statement.redirector == ""
+ assert statement.redirect_to == ""
assert statement.command_and_args == line
@pytest.mark.parametrize(
- ('line', 'terminator'),
+ ("line", "terminator"),
[
- ('termbare;', ';'),
- ('termbare ;', ';'),
- ('termbare&', '&'),
- ('termbare &', '&'),
+ ("termbare;", ";"),
+ ("termbare ;", ";"),
+ ("termbare&", "&"),
+ ("termbare &", "&"),
],
)
def test_parse_word_plus_terminator(parser, line, terminator) -> None:
statement = parser.parse(line)
- assert statement.command == 'termbare'
- assert statement == ''
- assert statement.argv == ['termbare']
+ assert statement.command == "termbare"
+ assert statement == ""
+ assert statement.argv == ["termbare"]
assert not statement.arg_list
assert statement.terminator == terminator
assert statement.expanded_command_line == statement.command + statement.terminator
@pytest.mark.parametrize(
- ('line', 'terminator'),
+ ("line", "terminator"),
[
- ('termbare; suffx', ';'),
- ('termbare ;suffx', ';'),
- ('termbare& suffx', '&'),
- ('termbare &suffx', '&'),
+ ("termbare; suffx", ";"),
+ ("termbare ;suffx", ";"),
+ ("termbare& suffx", "&"),
+ ("termbare &suffx", "&"),
],
)
def test_parse_suffix_after_terminator(parser, line, terminator) -> None:
statement = parser.parse(line)
- assert statement.command == 'termbare'
- assert statement == ''
+ assert statement.command == "termbare"
+ assert statement == ""
assert statement.args == statement
- assert statement.argv == ['termbare']
+ assert statement.argv == ["termbare"]
assert not statement.arg_list
assert statement.terminator == terminator
- assert statement.suffix == 'suffx'
- assert statement.expanded_command_line == statement.command + statement.terminator + ' ' + statement.suffix
+ assert statement.suffix == "suffx"
+ assert statement.expanded_command_line == statement.command + statement.terminator + " " + statement.suffix
def test_parse_command_with_args(parser) -> None:
- line = 'command with args'
+ line = "command with args"
statement = parser.parse(line)
- assert statement.command == 'command'
- assert statement == 'with args'
+ assert statement.command == "command"
+ assert statement == "with args"
assert statement.args == statement
- assert statement.argv == ['command', 'with', 'args']
+ assert statement.argv == ["command", "with", "args"]
assert statement.arg_list == statement.argv[1:]
def test_parse_command_with_quoted_args(parser) -> None:
line = 'command with "quoted args" and "some not"'
statement = parser.parse(line)
- assert statement.command == 'command'
+ assert statement.command == "command"
assert statement == 'with "quoted args" and "some not"'
assert statement.args == statement
- assert statement.argv == ['command', 'with', 'quoted args', 'and', 'some not']
- assert statement.arg_list == ['with', '"quoted args"', 'and', '"some not"']
+ assert statement.argv == ["command", "with", "quoted args", "and", "some not"]
+ assert statement.arg_list == ["with", '"quoted args"', "and", '"some not"']
def test_parse_command_with_args_terminator_and_suffix(parser) -> None:
- line = 'command with args and terminator; and suffix'
+ line = "command with args and terminator; and suffix"
statement = parser.parse(line)
- assert statement.command == 'command'
+ assert statement.command == "command"
assert statement == "with args and terminator"
assert statement.args == statement
- assert statement.argv == ['command', 'with', 'args', 'and', 'terminator']
+ assert statement.argv == ["command", "with", "args", "and", "terminator"]
assert statement.arg_list == statement.argv[1:]
- assert statement.terminator == ';'
- assert statement.suffix == 'and suffix'
+ assert statement.terminator == ";"
+ assert statement.suffix == "and suffix"
def test_parse_comment(parser) -> None:
- statement = parser.parse(constants.COMMENT_CHAR + ' this is all a comment')
- assert statement.command == ''
- assert statement == ''
+ statement = parser.parse(constants.COMMENT_CHAR + " this is all a comment")
+ assert statement.command == ""
+ assert statement == ""
assert statement.args == statement
assert not statement.argv
assert not statement.arg_list
def test_parse_embedded_comment_char(parser) -> None:
- command_str = 'hi ' + constants.COMMENT_CHAR + ' not a comment'
+ command_str = "hi " + constants.COMMENT_CHAR + " not a comment"
statement = parser.parse(command_str)
- assert statement.command == 'hi'
- assert statement == constants.COMMENT_CHAR + ' not a comment'
+ assert statement == constants.COMMENT_CHAR + " not a comment"
assert statement.args == statement
+ assert statement.command == "hi"
assert statement.argv == shlex_split(command_str)
assert statement.arg_list == statement.argv[1:]
@pytest.mark.parametrize(
- 'line',
+ "line",
[
- 'simple | piped',
- 'simple|piped',
+ "simple | piped",
+ "simple|piped",
],
)
def test_parse_simple_pipe(parser, line) -> None:
statement = parser.parse(line)
- assert statement.command == 'simple'
- assert statement == ''
+ assert statement.command == "simple"
+ assert statement == ""
assert statement.args == statement
- assert statement.argv == ['simple']
+ assert statement.argv == ["simple"]
assert not statement.arg_list
- assert statement.pipe_to == 'piped'
- assert statement.expanded_command_line == statement.command + ' | ' + statement.pipe_to
+ assert statement.redirector == constants.REDIRECTION_PIPE
+ assert statement.redirect_to == "piped"
+ assert statement.expanded_command_line == statement.command + " | " + statement.redirect_to
def test_parse_double_pipe_is_not_a_pipe(parser) -> None:
- line = 'double-pipe || is not a pipe'
+ line = "double-pipe || is not a pipe"
statement = parser.parse(line)
- assert statement.command == 'double-pipe'
- assert statement == '|| is not a pipe'
+ assert statement.command == "double-pipe"
+ assert statement == "|| is not a pipe"
assert statement.args == statement
- assert statement.argv == ['double-pipe', '||', 'is', 'not', 'a', 'pipe']
+ assert statement.argv == ["double-pipe", "||", "is", "not", "a", "pipe"]
assert statement.arg_list == statement.argv[1:]
- assert not statement.pipe_to
+ assert not statement.redirector
+ assert not statement.redirect_to
def test_parse_complex_pipe(parser) -> None:
- line = 'command with args, terminator&sufx | piped'
+ line = "command with args, terminator&sufx | piped"
statement = parser.parse(line)
- assert statement.command == 'command'
+ assert statement.command == "command"
assert statement == "with args, terminator"
assert statement.args == statement
- assert statement.argv == ['command', 'with', 'args,', 'terminator']
+ assert statement.argv == ["command", "with", "args,", "terminator"]
assert statement.arg_list == statement.argv[1:]
- assert statement.terminator == '&'
- assert statement.suffix == 'sufx'
- assert statement.pipe_to == 'piped'
+ assert statement.terminator == "&"
+ assert statement.suffix == "sufx"
+ assert statement.redirector == constants.REDIRECTION_PIPE
+ assert statement.redirect_to == "piped"
@pytest.mark.parametrize(
- ('line', 'output'),
+ ("line", "redirector"),
[
- ('help > out.txt', '>'),
- ('help>out.txt', '>'),
- ('help >> out.txt', '>>'),
- ('help>>out.txt', '>>'),
+ ("help > out.txt", ">"),
+ ("help>out.txt", ">"),
+ ("help >> out.txt", ">>"),
+ ("help>>out.txt", ">>"),
],
)
-def test_parse_redirect(parser, line, output) -> None:
+def test_parse_redirect(parser, line, redirector) -> None:
statement = parser.parse(line)
- assert statement.command == 'help'
- assert statement == ''
+ assert statement.command == "help"
+ assert statement == ""
assert statement.args == statement
- assert statement.output == output
- assert statement.output_to == 'out.txt'
- assert statement.expanded_command_line == statement.command + ' ' + statement.output + ' ' + statement.output_to
+ assert statement.redirector == redirector
+ assert statement.redirect_to == "out.txt"
+ assert statement.expanded_command_line == statement.command + " " + statement.redirector + " " + statement.redirect_to
@pytest.mark.parametrize(
- 'dest',
+ "dest",
[
- 'afile.txt',
- 'python-cmd2/afile.txt',
+ "afile.txt",
+ "python-cmd2/afile.txt",
],
) # without dashes # with dashes in path
def test_parse_redirect_with_args(parser, dest) -> None:
- line = f'output into > {dest}'
+ line = f"output into > {dest}"
statement = parser.parse(line)
- assert statement.command == 'output'
- assert statement == 'into'
+ assert statement.command == "output"
+ assert statement == "into"
assert statement.args == statement
- assert statement.argv == ['output', 'into']
+ assert statement.argv == ["output", "into"]
assert statement.arg_list == statement.argv[1:]
- assert statement.output == '>'
- assert statement.output_to == dest
+ assert statement.redirector == ">"
+ assert statement.redirect_to == dest
def test_parse_redirect_append(parser) -> None:
- line = 'output appended to >> /tmp/afile.txt'
+ line = "output appended to >> /tmp/afile.txt"
statement = parser.parse(line)
- assert statement.command == 'output'
- assert statement == 'appended to'
+ assert statement.command == "output"
+ assert statement == "appended to"
assert statement.args == statement
- assert statement.argv == ['output', 'appended', 'to']
+ assert statement.argv == ["output", "appended", "to"]
assert statement.arg_list == statement.argv[1:]
- assert statement.output == '>>'
- assert statement.output_to == '/tmp/afile.txt'
+ assert statement.redirector == ">>"
+ assert statement.redirect_to == "/tmp/afile.txt"
def test_parse_pipe_then_redirect(parser) -> None:
- line = 'output into;sufx | pipethrume plz > afile.txt'
+ line = "output into;sufx | pipethrume plz > afile.txt"
statement = parser.parse(line)
- assert statement.command == 'output'
- assert statement == 'into'
+ assert statement.command == "output"
+ assert statement == "into"
assert statement.args == statement
- assert statement.argv == ['output', 'into']
+ assert statement.argv == ["output", "into"]
assert statement.arg_list == statement.argv[1:]
- assert statement.terminator == ';'
- assert statement.suffix == 'sufx'
- assert statement.pipe_to == 'pipethrume plz > afile.txt'
- assert statement.output == ''
- assert statement.output_to == ''
+ assert statement.terminator == ";"
+ assert statement.suffix == "sufx"
+ assert statement.redirector == constants.REDIRECTION_PIPE
+ assert statement.redirect_to == "pipethrume plz > afile.txt"
def test_parse_multiple_pipes(parser) -> None:
- line = 'output into;sufx | pipethrume plz | grep blah'
+ line = "output into;sufx | pipethrume plz | grep blah"
statement = parser.parse(line)
- assert statement.command == 'output'
- assert statement == 'into'
+ assert statement.command == "output"
+ assert statement == "into"
assert statement.args == statement
- assert statement.argv == ['output', 'into']
+ assert statement.argv == ["output", "into"]
assert statement.arg_list == statement.argv[1:]
- assert statement.terminator == ';'
- assert statement.suffix == 'sufx'
- assert statement.pipe_to == 'pipethrume plz | grep blah'
- assert statement.output == ''
- assert statement.output_to == ''
+ assert statement.terminator == ";"
+ assert statement.suffix == "sufx"
+ assert statement.redirector == constants.REDIRECTION_PIPE
+ assert statement.redirect_to == "pipethrume plz | grep blah"
def test_redirect_then_pipe(parser) -> None:
- line = 'help alias > file.txt | grep blah'
+ line = "help alias > file.txt | grep blah"
statement = parser.parse(line)
- assert statement.command == 'help'
- assert statement == 'alias'
+ assert statement.command == "help"
+ assert statement == "alias"
assert statement.args == statement
- assert statement.argv == ['help', 'alias']
+ assert statement.argv == ["help", "alias"]
assert statement.arg_list == statement.argv[1:]
- assert statement.terminator == ''
- assert statement.suffix == ''
- assert statement.pipe_to == ''
- assert statement.output == '>'
- assert statement.output_to == 'file.txt'
+ assert statement.terminator == ""
+ assert statement.suffix == ""
+ assert statement.redirector == ">"
+ assert statement.redirect_to == "file.txt"
def test_append_then_pipe(parser) -> None:
- line = 'help alias >> file.txt | grep blah'
+ line = "help alias >> file.txt | grep blah"
statement = parser.parse(line)
- assert statement.command == 'help'
- assert statement == 'alias'
+ assert statement.command == "help"
+ assert statement == "alias"
assert statement.args == statement
- assert statement.argv == ['help', 'alias']
+ assert statement.argv == ["help", "alias"]
assert statement.arg_list == statement.argv[1:]
- assert statement.terminator == ''
- assert statement.suffix == ''
- assert statement.pipe_to == ''
- assert statement.output == '>>'
- assert statement.output_to == 'file.txt'
+ assert statement.terminator == ""
+ assert statement.suffix == ""
+ assert statement.redirector == ">>"
+ assert statement.redirect_to == "file.txt"
def test_append_then_redirect(parser) -> None:
- line = 'help alias >> file.txt > file2.txt'
+ line = "help alias >> file.txt > file2.txt"
statement = parser.parse(line)
- assert statement.command == 'help'
- assert statement == 'alias'
+ assert statement.command == "help"
+ assert statement == "alias"
assert statement.args == statement
- assert statement.argv == ['help', 'alias']
+ assert statement.argv == ["help", "alias"]
assert statement.arg_list == statement.argv[1:]
- assert statement.terminator == ''
- assert statement.suffix == ''
- assert statement.pipe_to == ''
- assert statement.output == '>>'
- assert statement.output_to == 'file.txt'
+ assert statement.terminator == ""
+ assert statement.suffix == ""
+ assert statement.redirector == ">>"
+ assert statement.redirect_to == "file.txt"
def test_redirect_then_append(parser) -> None:
- line = 'help alias > file.txt >> file2.txt'
+ line = "help alias > file.txt >> file2.txt"
statement = parser.parse(line)
- assert statement.command == 'help'
- assert statement == 'alias'
+ assert statement.command == "help"
+ assert statement == "alias"
assert statement.args == statement
- assert statement.argv == ['help', 'alias']
+ assert statement.argv == ["help", "alias"]
assert statement.arg_list == statement.argv[1:]
- assert statement.terminator == ''
- assert statement.suffix == ''
- assert statement.pipe_to == ''
- assert statement.output == '>'
- assert statement.output_to == 'file.txt'
+ assert statement.terminator == ""
+ assert statement.suffix == ""
+ assert statement.redirector == ">"
+ assert statement.redirect_to == "file.txt"
def test_redirect_to_quoted_string(parser) -> None:
line = 'help alias > "file.txt"'
statement = parser.parse(line)
- assert statement.command == 'help'
- assert statement == 'alias'
+ assert statement.command == "help"
+ assert statement == "alias"
assert statement.args == statement
- assert statement.argv == ['help', 'alias']
+ assert statement.argv == ["help", "alias"]
assert statement.arg_list == statement.argv[1:]
- assert statement.terminator == ''
- assert statement.suffix == ''
- assert statement.pipe_to == ''
- assert statement.output == '>'
- assert statement.output_to == '"file.txt"'
+ assert statement.terminator == ""
+ assert statement.suffix == ""
+ assert statement.redirector == ">"
+ assert statement.redirect_to == '"file.txt"'
def test_redirect_to_single_quoted_string(parser) -> None:
line = "help alias > 'file.txt'"
statement = parser.parse(line)
- assert statement.command == 'help'
- assert statement == 'alias'
+ assert statement.command == "help"
+ assert statement == "alias"
assert statement.args == statement
- assert statement.argv == ['help', 'alias']
+ assert statement.argv == ["help", "alias"]
assert statement.arg_list == statement.argv[1:]
- assert statement.terminator == ''
- assert statement.suffix == ''
- assert statement.pipe_to == ''
- assert statement.output == '>'
- assert statement.output_to == "'file.txt'"
+ assert statement.terminator == ""
+ assert statement.suffix == ""
+ assert statement.redirector == ">"
+ assert statement.redirect_to == "'file.txt'"
def test_redirect_to_empty_quoted_string(parser) -> None:
line = 'help alias > ""'
statement = parser.parse(line)
- assert statement.command == 'help'
- assert statement == 'alias'
+ assert statement.command == "help"
+ assert statement == "alias"
assert statement.args == statement
- assert statement.argv == ['help', 'alias']
+ assert statement.argv == ["help", "alias"]
assert statement.arg_list == statement.argv[1:]
- assert statement.terminator == ''
- assert statement.suffix == ''
- assert statement.pipe_to == ''
- assert statement.output == '>'
- assert statement.output_to == ''
+ assert statement.terminator == ""
+ assert statement.suffix == ""
+ assert statement.redirector == ">"
+ assert statement.redirect_to == ""
def test_redirect_to_empty_single_quoted_string(parser) -> None:
line = "help alias > ''"
statement = parser.parse(line)
- assert statement.command == 'help'
- assert statement == 'alias'
+ assert statement.command == "help"
+ assert statement == "alias"
assert statement.args == statement
- assert statement.argv == ['help', 'alias']
+ assert statement.argv == ["help", "alias"]
assert statement.arg_list == statement.argv[1:]
- assert statement.terminator == ''
- assert statement.suffix == ''
- assert statement.pipe_to == ''
- assert statement.output == '>'
- assert statement.output_to == ''
+ assert statement.terminator == ""
+ assert statement.suffix == ""
+ assert statement.redirector == ">"
+ assert statement.redirect_to == ""
-def test_parse_output_to_paste_buffer(parser) -> None:
- line = 'output to paste buffer >> '
+def test_parse_redirect_to_paste_buffer(parser) -> None:
+ line = "redirect to paste buffer >> "
statement = parser.parse(line)
- assert statement.command == 'output'
- assert statement == 'to paste buffer'
+ assert statement.command == "redirect"
+ assert statement == "to paste buffer"
assert statement.args == statement
- assert statement.argv == ['output', 'to', 'paste', 'buffer']
+ assert statement.argv == ["redirect", "to", "paste", "buffer"]
assert statement.arg_list == statement.argv[1:]
- assert statement.output == '>>'
+ assert statement.redirector == ">>"
def test_parse_redirect_inside_terminator(parser) -> None:
@@ -502,160 +492,160 @@ def test_parse_redirect_inside_terminator(parser) -> None:
If a redirector occurs before a terminator, then it will be treated as
part of the arguments and not as a redirector.
"""
- line = 'has > inside;'
+ line = "has > inside;"
statement = parser.parse(line)
- assert statement.command == 'has'
- assert statement == '> inside'
+ assert statement.command == "has"
+ assert statement == "> inside"
assert statement.args == statement
- assert statement.argv == ['has', '>', 'inside']
+ assert statement.argv == ["has", ">", "inside"]
assert statement.arg_list == statement.argv[1:]
- assert statement.terminator == ';'
+ assert statement.terminator == ";"
@pytest.mark.parametrize(
- ('line', 'terminator'),
+ ("line", "terminator"),
[
- ('multiline with | inside;', ';'),
- ('multiline with | inside ;', ';'),
- ('multiline with | inside;;;', ';'),
- ('multiline with | inside;; ;;', ';'),
- ('multiline with | inside&', '&'),
- ('multiline with | inside &;', '&'),
- ('multiline with | inside&&;', '&'),
- ('multiline with | inside &; &;', '&'),
+ ("multiline with | inside;", ";"),
+ ("multiline with | inside ;", ";"),
+ ("multiline with | inside;;;", ";"),
+ ("multiline with | inside;; ;;", ";"),
+ ("multiline with | inside&", "&"),
+ ("multiline with | inside &;", "&"),
+ ("multiline with | inside&&;", "&"),
+ ("multiline with | inside &; &;", "&"),
],
)
def test_parse_multiple_terminators(parser, line, terminator) -> None:
statement = parser.parse(line)
- assert statement.multiline_command == 'multiline'
- assert statement == 'with | inside'
+ assert statement.multiline_command
+ assert statement == "with | inside"
assert statement.args == statement
- assert statement.argv == ['multiline', 'with', '|', 'inside']
+ assert statement.argv == ["multiline", "with", "|", "inside"]
assert statement.arg_list == statement.argv[1:]
assert statement.terminator == terminator
def test_parse_unfinished_multiliine_command(parser) -> None:
- line = 'multiline has > inside an unfinished command'
+ line = "multiline has > inside an unfinished command"
statement = parser.parse(line)
- assert statement.multiline_command == 'multiline'
- assert statement.command == 'multiline'
- assert statement == 'has > inside an unfinished command'
+ assert statement.multiline_command
+ assert statement.command == "multiline"
+ assert statement == "has > inside an unfinished command"
assert statement.args == statement
- assert statement.argv == ['multiline', 'has', '>', 'inside', 'an', 'unfinished', 'command']
+ assert statement.argv == ["multiline", "has", ">", "inside", "an", "unfinished", "command"]
assert statement.arg_list == statement.argv[1:]
- assert statement.terminator == ''
+ assert statement.terminator == ""
def test_parse_basic_multiline_command(parser) -> None:
- line = 'multiline foo\nbar\n\n'
+ line = "multiline foo\nbar\n\n"
statement = parser.parse(line)
- assert statement.multiline_command == 'multiline'
- assert statement.command == 'multiline'
- assert statement == 'foo bar'
+ assert statement.multiline_command
+ assert statement.command == "multiline"
+ assert statement == "foo bar"
assert statement.args == statement
- assert statement.argv == ['multiline', 'foo', 'bar']
- assert statement.arg_list == ['foo', 'bar']
+ assert statement.argv == ["multiline", "foo", "bar"]
+ assert statement.arg_list == ["foo", "bar"]
assert statement.raw == line
- assert statement.terminator == '\n'
+ assert statement.terminator == "\n"
@pytest.mark.parametrize(
- ('line', 'terminator'),
+ ("line", "terminator"),
[
- ('multiline has > inside;', ';'),
- ('multiline has > inside;;;', ';'),
- ('multiline has > inside;; ;;', ';'),
- ('multiline has > inside &', '&'),
- ('multiline has > inside & &', '&'),
+ ("multiline has > inside;", ";"),
+ ("multiline has > inside;;;", ";"),
+ ("multiline has > inside;; ;;", ";"),
+ ("multiline has > inside &", "&"),
+ ("multiline has > inside & &", "&"),
],
)
def test_parse_multiline_command_ignores_redirectors_within_it(parser, line, terminator) -> None:
statement = parser.parse(line)
- assert statement.multiline_command == 'multiline'
- assert statement == 'has > inside'
+ assert statement.multiline_command
+ assert statement == "has > inside"
assert statement.args == statement
- assert statement.argv == ['multiline', 'has', '>', 'inside']
+ assert statement.argv == ["multiline", "has", ">", "inside"]
assert statement.arg_list == statement.argv[1:]
assert statement.terminator == terminator
def test_parse_multiline_terminated_by_empty_line(parser) -> None:
- line = 'multiline command ends\n\n'
+ line = "multiline command ends\n\n"
statement = parser.parse(line)
- assert statement.multiline_command == 'multiline'
- assert statement.command == 'multiline'
- assert statement == 'command ends'
+ assert statement.multiline_command
+ assert statement.command == "multiline"
+ assert statement == "command ends"
assert statement.args == statement
- assert statement.argv == ['multiline', 'command', 'ends']
+ assert statement.argv == ["multiline", "command", "ends"]
assert statement.arg_list == statement.argv[1:]
- assert statement.terminator == '\n'
+ assert statement.terminator == "\n"
@pytest.mark.parametrize(
- ('line', 'terminator'),
+ ("line", "terminator"),
[
- ('multiline command "with\nembedded newline";', ';'),
- ('multiline command "with\nembedded newline";;;', ';'),
- ('multiline command "with\nembedded newline";; ;;', ';'),
- ('multiline command "with\nembedded newline" &', '&'),
- ('multiline command "with\nembedded newline" & &', '&'),
- ('multiline command "with\nembedded newline"\n\n', '\n'),
+ ('multiline command "with\nembedded newline";', ";"),
+ ('multiline command "with\nembedded newline";;;', ";"),
+ ('multiline command "with\nembedded newline";; ;;', ";"),
+ ('multiline command "with\nembedded newline" &', "&"),
+ ('multiline command "with\nembedded newline" & &', "&"),
+ ('multiline command "with\nembedded newline"\n\n', "\n"),
],
)
def test_parse_multiline_with_embedded_newline(parser, line, terminator) -> None:
statement = parser.parse(line)
- assert statement.multiline_command == 'multiline'
- assert statement.command == 'multiline'
+ assert statement.multiline_command
+ assert statement.command == "multiline"
assert statement == 'command "with\nembedded newline"'
assert statement.args == statement
- assert statement.argv == ['multiline', 'command', 'with\nembedded newline']
- assert statement.arg_list == ['command', '"with\nembedded newline"']
+ assert statement.argv == ["multiline", "command", "with\nembedded newline"]
+ assert statement.arg_list == ["command", '"with\nembedded newline"']
assert statement.terminator == terminator
def test_parse_multiline_ignores_terminators_in_quotes(parser) -> None:
line = 'multiline command "with term; ends" now\n\n'
statement = parser.parse(line)
- assert statement.multiline_command == 'multiline'
- assert statement.command == 'multiline'
+ assert statement.multiline_command
+ assert statement.command == "multiline"
assert statement == 'command "with term; ends" now'
assert statement.args == statement
- assert statement.argv == ['multiline', 'command', 'with term; ends', 'now']
- assert statement.arg_list == ['command', '"with term; ends"', 'now']
- assert statement.terminator == '\n'
+ assert statement.argv == ["multiline", "command", "with term; ends", "now"]
+ assert statement.arg_list == ["command", '"with term; ends"', "now"]
+ assert statement.terminator == "\n"
def test_parse_command_with_unicode_args(parser) -> None:
- line = 'drink café'
+ line = "drink café"
statement = parser.parse(line)
- assert statement.command == 'drink'
- assert statement == 'café'
+ assert statement.command == "drink"
+ assert statement == "café"
assert statement.args == statement
- assert statement.argv == ['drink', 'café']
+ assert statement.argv == ["drink", "café"]
assert statement.arg_list == statement.argv[1:]
def test_parse_unicode_command(parser) -> None:
- line = 'café au lait'
+ line = "café au lait"
statement = parser.parse(line)
- assert statement.command == 'café'
- assert statement == 'au lait'
+ assert statement.command == "café"
+ assert statement == "au lait"
assert statement.args == statement
- assert statement.argv == ['café', 'au', 'lait']
+ assert statement.argv == ["café", "au", "lait"]
assert statement.arg_list == statement.argv[1:]
def test_parse_redirect_to_unicode_filename(parser) -> None:
- line = 'dir home > café'
+ line = "dir home > café"
statement = parser.parse(line)
- assert statement.command == 'dir'
- assert statement == 'home'
+ assert statement.command == "dir"
+ assert statement == "home"
assert statement.args == statement
- assert statement.argv == ['dir', 'home']
+ assert statement.argv == ["dir", "home"]
assert statement.arg_list == statement.argv[1:]
- assert statement.output == '>'
- assert statement.output_to == 'café'
+ assert statement.redirector == ">"
+ assert statement.redirect_to == "café"
def test_parse_unclosed_quotes(parser) -> None:
@@ -666,22 +656,22 @@ def test_parse_unclosed_quotes(parser) -> None:
def test_empty_statement_raises_exception() -> None:
app = cmd2.Cmd()
with pytest.raises(exceptions.EmptyStatement):
- app._complete_statement('')
+ app._complete_statement("")
with pytest.raises(exceptions.EmptyStatement):
- app._complete_statement(' ')
+ app._complete_statement(" ")
@pytest.mark.parametrize(
- ('line', 'command', 'args'),
+ ("line", "command", "args"),
[
- ('helpalias', 'help', ''),
- ('helpalias mycommand', 'help', 'mycommand'),
- ('42', 'theanswer', ''),
- ('42 arg1 arg2', 'theanswer', 'arg1 arg2'),
- ('!ls', 'shell', 'ls'),
- ('!ls -al /tmp', 'shell', 'ls -al /tmp'),
- ('l', 'shell', 'ls -al'),
+ ("helpalias", "help", ""),
+ ("helpalias mycommand", "help", "mycommand"),
+ ("42", "theanswer", ""),
+ ("42 arg1 arg2", "theanswer", "arg1 arg2"),
+ ("!ls", "shell", "ls"),
+ ("!ls -al /tmp", "shell", "ls -al /tmp"),
+ ("l", "shell", "ls -al"),
],
)
def test_parse_alias_and_shortcut_expansion(parser, line, command, args) -> None:
@@ -692,274 +682,214 @@ def test_parse_alias_and_shortcut_expansion(parser, line, command, args) -> None
def test_parse_alias_on_multiline_command(parser) -> None:
- line = 'anothermultiline has > inside an unfinished command'
+ line = "anothermultiline has > inside an unfinished command"
statement = parser.parse(line)
- assert statement.multiline_command == 'multiline'
- assert statement.command == 'multiline'
+ assert statement == "has > inside an unfinished command"
assert statement.args == statement
- assert statement == 'has > inside an unfinished command'
- assert statement.terminator == ''
+ assert statement.multiline_command
+ assert statement.command == "multiline"
+ assert statement.terminator == ""
@pytest.mark.parametrize(
- ('line', 'output'),
+ ("line", "redirector"),
[
- ('helpalias > out.txt', '>'),
- ('helpalias>out.txt', '>'),
- ('helpalias >> out.txt', '>>'),
- ('helpalias>>out.txt', '>>'),
+ ("helpalias > out.txt", ">"),
+ ("helpalias>out.txt", ">"),
+ ("helpalias >> out.txt", ">>"),
+ ("helpalias>>out.txt", ">>"),
],
)
-def test_parse_alias_redirection(parser, line, output) -> None:
+def test_parse_alias_redirection(parser, line, redirector) -> None:
statement = parser.parse(line)
- assert statement.command == 'help'
- assert statement == ''
+ assert statement.command == "help"
+ assert statement == ""
assert statement.args == statement
- assert statement.output == output
- assert statement.output_to == 'out.txt'
+ assert statement.redirector == redirector
+ assert statement.redirect_to == "out.txt"
@pytest.mark.parametrize(
- 'line',
+ "line",
[
- 'helpalias | less',
- 'helpalias|less',
+ "helpalias | less",
+ "helpalias|less",
],
)
def test_parse_alias_pipe(parser, line) -> None:
statement = parser.parse(line)
- assert statement.command == 'help'
- assert statement == ''
+ assert statement.command == "help"
+ assert statement == ""
assert statement.args == statement
- assert statement.pipe_to == 'less'
+ assert statement.redirector == constants.REDIRECTION_PIPE
+ assert statement.redirect_to == "less"
@pytest.mark.parametrize(
- 'line',
+ "line",
[
- 'helpalias;',
- 'helpalias;;',
- 'helpalias;; ;',
- 'helpalias ;',
- 'helpalias ; ;',
- 'helpalias ;; ;',
+ "helpalias;",
+ "helpalias;;",
+ "helpalias;; ;",
+ "helpalias ;",
+ "helpalias ; ;",
+ "helpalias ;; ;",
],
)
def test_parse_alias_terminator_no_whitespace(parser, line) -> None:
statement = parser.parse(line)
- assert statement.command == 'help'
- assert statement == ''
+ assert statement.command == "help"
+ assert statement == ""
assert statement.args == statement
- assert statement.terminator == ';'
+ assert statement.terminator == ";"
def test_parse_command_only_command_and_args(parser) -> None:
- line = 'help history'
- statement = parser.parse_command_only(line)
- assert statement == 'history'
- assert statement.args == statement
- assert statement.arg_list == []
- assert statement.command == 'help'
- assert statement.command_and_args == line
- assert statement.multiline_command == ''
- assert statement.raw == line
- assert statement.terminator == ''
- assert statement.suffix == ''
- assert statement.pipe_to == ''
- assert statement.output == ''
- assert statement.output_to == ''
+ line = "help history"
+ partial_statement = parser.parse_command_only(line)
+ assert partial_statement.command == "help"
+ assert partial_statement.args == "history"
+ assert partial_statement.raw == line
+ assert not partial_statement.multiline_command
+ assert partial_statement.command_and_args == line
def test_parse_command_only_strips_line(parser) -> None:
- line = ' help history '
- statement = parser.parse_command_only(line)
- assert statement == 'history'
- assert statement.args == statement
- assert statement.arg_list == []
- assert statement.command == 'help'
- assert statement.command_and_args == line.strip()
- assert statement.multiline_command == ''
- assert statement.raw == line
- assert statement.terminator == ''
- assert statement.suffix == ''
- assert statement.pipe_to == ''
- assert statement.output == ''
- assert statement.output_to == ''
+ line = " help history "
+ partial_statement = parser.parse_command_only(line)
+ assert partial_statement.command == "help"
+ assert partial_statement.args == "history"
+ assert partial_statement.raw == line
+ assert not partial_statement.multiline_command
+ assert partial_statement.command_and_args == line.strip()
def test_parse_command_only_expands_alias(parser) -> None:
line = 'fake foobar.py "somebody.py'
- statement = parser.parse_command_only(line)
- assert statement == 'foobar.py "somebody.py'
- assert statement.args == statement
- assert statement.arg_list == []
- assert statement.command == 'run_pyscript'
- assert statement.command_and_args == 'run_pyscript foobar.py "somebody.py'
- assert statement.multiline_command == ''
- assert statement.raw == line
- assert statement.terminator == ''
- assert statement.suffix == ''
- assert statement.pipe_to == ''
- assert statement.output == ''
- assert statement.output_to == ''
+ partial_statement = parser.parse_command_only(line)
+ assert partial_statement.command == "run_pyscript"
+ assert partial_statement.args == 'foobar.py "somebody.py'
+ assert partial_statement.raw == line
+ assert not partial_statement.multiline_command
+ assert partial_statement.command_and_args == 'run_pyscript foobar.py "somebody.py'
def test_parse_command_only_expands_shortcuts(parser) -> None:
- line = '!cat foobar.txt'
- statement = parser.parse_command_only(line)
- assert statement == 'cat foobar.txt'
- assert statement.args == statement
- assert statement.arg_list == []
- assert statement.command == 'shell'
- assert statement.command_and_args == 'shell cat foobar.txt'
- assert statement.multiline_command == ''
- assert statement.raw == line
- assert statement.multiline_command == ''
- assert statement.terminator == ''
- assert statement.suffix == ''
- assert statement.pipe_to == ''
- assert statement.output == ''
- assert statement.output_to == ''
+ line = "!cat foobar.txt"
+ partial_statement = parser.parse_command_only(line)
+ assert partial_statement.command == "shell"
+ assert partial_statement.args == "cat foobar.txt"
+ assert partial_statement.raw == line
+ assert not partial_statement.multiline_command
+ assert partial_statement.command_and_args == "shell cat foobar.txt"
def test_parse_command_only_quoted_args(parser) -> None:
line = 'l "/tmp/directory with spaces/doit.sh"'
- statement = parser.parse_command_only(line)
- assert statement == 'ls -al "/tmp/directory with spaces/doit.sh"'
- assert statement.args == statement
- assert statement.arg_list == []
- assert statement.command == 'shell'
- assert statement.command_and_args == line.replace('l', 'shell ls -al')
- assert statement.multiline_command == ''
- assert statement.raw == line
- assert statement.multiline_command == ''
- assert statement.terminator == ''
- assert statement.suffix == ''
- assert statement.pipe_to == ''
- assert statement.output == ''
- assert statement.output_to == ''
+ partial_statement = parser.parse_command_only(line)
+ assert partial_statement.command == "shell"
+ assert partial_statement.args == 'ls -al "/tmp/directory with spaces/doit.sh"'
+ assert partial_statement.raw == line
+ assert not partial_statement.multiline_command
+ assert partial_statement.command_and_args == line.replace("l", "shell ls -al")
def test_parse_command_only_unclosed_quote(parser) -> None:
# Quoted trailing spaces will be preserved
line = 'command with unclosed "quote '
- statement = parser.parse_command_only(line)
- assert statement == 'with unclosed "quote '
- assert statement.args == statement
- assert statement.arg_list == []
- assert statement.command == 'command'
- assert statement.command_and_args == line
- assert statement.multiline_command == ''
- assert statement.raw == line
- assert statement.multiline_command == ''
- assert statement.terminator == ''
- assert statement.suffix == ''
- assert statement.pipe_to == ''
- assert statement.output == ''
- assert statement.output_to == ''
+ partial_statement = parser.parse_command_only(line)
+ assert partial_statement.command == "command"
+ assert partial_statement.args == 'with unclosed "quote '
+ assert partial_statement.raw == line
+ assert not partial_statement.multiline_command
+ assert partial_statement.command_and_args == line
@pytest.mark.parametrize(
- ('line', 'args'),
+ ("line", "args"),
[
- ('helpalias > out.txt', '> out.txt'),
- ('helpalias>out.txt', '>out.txt'),
- ('helpalias >> out.txt', '>> out.txt'),
- ('helpalias>>out.txt', '>>out.txt'),
- ('help|less', '|less'),
- ('helpalias;', ';'),
- ('help ;;', ';;'),
- ('help; ;;', '; ;;'),
+ ("helpalias > out.txt", "> out.txt"),
+ ("helpalias>out.txt", ">out.txt"),
+ ("helpalias >> out.txt", ">> out.txt"),
+ ("helpalias>>out.txt", ">>out.txt"),
+ ("help|less", "|less"),
+ ("helpalias;", ";"),
+ ("help ;;", ";;"),
+ ("help; ;;", "; ;;"),
],
)
def test_parse_command_only_specialchars(parser, line, args) -> None:
- statement = parser.parse_command_only(line)
- assert statement == args
- assert statement.args == args
- assert statement.command == 'help'
- assert statement.multiline_command == ''
- assert statement.raw == line
- assert statement.multiline_command == ''
- assert statement.terminator == ''
- assert statement.suffix == ''
- assert statement.pipe_to == ''
- assert statement.output == ''
- assert statement.output_to == ''
+ partial_statement = parser.parse_command_only(line)
+ assert partial_statement.command == "help"
+ assert partial_statement.args == args
+ assert partial_statement.raw == line
+ assert not partial_statement.multiline_command
+ assert partial_statement.command_and_args == "help" + " " + args
@pytest.mark.parametrize(
- 'line',
+ "line",
[
- '',
- ';',
- ';;',
- ';; ;',
- '&',
- '& &',
- ' && &',
- '>',
+ "",
+ ";",
+ ";;",
+ ";; ;",
+ "&",
+ "& &",
+ " && &",
+ ">",
"'",
'"',
- '|',
+ "|",
],
)
def test_parse_command_only_empty(parser, line) -> None:
- statement = parser.parse_command_only(line)
- assert statement == ''
- assert statement.args == statement
- assert statement.arg_list == []
- assert statement.command == ''
- assert statement.command_and_args == ''
- assert statement.multiline_command == ''
- assert statement.raw == line
- assert statement.multiline_command == ''
- assert statement.terminator == ''
- assert statement.suffix == ''
- assert statement.pipe_to == ''
- assert statement.output == ''
- assert statement.output_to == ''
+ partial_statement = parser.parse_command_only(line)
+ assert partial_statement.command == ""
+ assert partial_statement.args == ""
+ assert partial_statement.raw == line
+ assert not partial_statement.multiline_command
+ assert partial_statement.command_and_args == ""
def test_parse_command_only_multiline(parser) -> None:
line = 'multiline with partially "open quotes and no terminator'
- statement = parser.parse_command_only(line)
- assert statement.command == 'multiline'
- assert statement.multiline_command == 'multiline'
- assert statement == 'with partially "open quotes and no terminator'
- assert statement.command_and_args == line
- assert statement.args == statement
+ partial_statement = parser.parse_command_only(line)
+ assert partial_statement.command == "multiline"
+ assert partial_statement.args == 'with partially "open quotes and no terminator'
+ assert partial_statement.raw == line
+ assert partial_statement.multiline_command
+ assert partial_statement.command_and_args == line
def test_statement_initialization() -> None:
- string = 'alias'
+ string = "alias"
statement = cmd2.Statement(string)
- assert string == statement
+ assert statement == string
assert statement.args == statement
- assert statement.raw == ''
- assert statement.command == ''
+ assert statement.raw == ""
+ assert statement.command == ""
assert isinstance(statement.arg_list, list)
- assert not statement.arg_list
+ assert statement.arg_list == ["alias"]
assert isinstance(statement.argv, list)
assert not statement.argv
- assert statement.multiline_command == ''
- assert statement.terminator == ''
- assert statement.suffix == ''
- assert isinstance(statement.pipe_to, str)
- assert not statement.pipe_to
- assert statement.output == ''
- assert statement.output_to == ''
+ assert not statement.multiline_command
+ assert statement.terminator == ""
+ assert statement.suffix == ""
+ assert statement.redirector == ""
+ assert statement.redirect_to == ""
def test_statement_is_immutable() -> None:
- string = 'foo'
+ string = "foo"
statement = cmd2.Statement(string)
- assert string == statement
+ assert statement == string
assert statement.args == statement
- assert statement.raw == ''
+ assert statement.raw == ""
with pytest.raises(dataclasses.FrozenInstanceError):
- statement.args = 'bar'
+ statement.args = "bar"
with pytest.raises(dataclasses.FrozenInstanceError):
- statement.raw = 'baz'
+ statement.raw = "baz"
def test_statement_as_dict(parser) -> None:
@@ -976,7 +906,7 @@ def test_statement_as_dict(parser) -> None:
# from_dict() should raise KeyError if required field is missing
statement = parser.parse("command")
statement_dict = statement.to_dict()
- del statement_dict[Statement._args_field]
+ del statement_dict["args"]
with pytest.raises(KeyError):
Statement.from_dict(statement_dict)
@@ -986,57 +916,57 @@ def test_is_valid_command_invalid(mocker, parser) -> None:
# Non-string command
valid, errmsg = parser.is_valid_command(5)
assert not valid
- assert 'must be a string' in errmsg
+ assert "must be a string" in errmsg
mock = mocker.MagicMock()
valid, errmsg = parser.is_valid_command(mock)
assert not valid
- assert 'must be a string' in errmsg
+ assert "must be a string" in errmsg
# Empty command
- valid, errmsg = parser.is_valid_command('')
+ valid, errmsg = parser.is_valid_command("")
assert not valid
- assert 'cannot be an empty string' in errmsg
+ assert "cannot be an empty string" in errmsg
# Start with the comment character
valid, errmsg = parser.is_valid_command(constants.COMMENT_CHAR)
assert not valid
- assert 'cannot start with the comment character' in errmsg
+ assert "cannot start with the comment character" in errmsg
# Starts with shortcut
- valid, errmsg = parser.is_valid_command('!ls')
+ valid, errmsg = parser.is_valid_command("!ls")
assert not valid
- assert 'cannot start with a shortcut' in errmsg
+ assert "cannot start with a shortcut" in errmsg
# Contains whitespace
- valid, errmsg = parser.is_valid_command('shell ls')
+ valid, errmsg = parser.is_valid_command("shell ls")
assert not valid
- assert 'cannot contain: whitespace, quotes,' in errmsg
+ assert "cannot contain: whitespace, quotes," in errmsg
# Contains a quote
valid, errmsg = parser.is_valid_command('"shell"')
assert not valid
- assert 'cannot contain: whitespace, quotes,' in errmsg
+ assert "cannot contain: whitespace, quotes," in errmsg
# Contains a redirector
- valid, errmsg = parser.is_valid_command('>shell')
+ valid, errmsg = parser.is_valid_command(">shell")
assert not valid
- assert 'cannot contain: whitespace, quotes,' in errmsg
+ assert "cannot contain: whitespace, quotes," in errmsg
# Contains a terminator
- valid, errmsg = parser.is_valid_command(';shell')
+ valid, errmsg = parser.is_valid_command(";shell")
assert not valid
- assert 'cannot contain: whitespace, quotes,' in errmsg
+ assert "cannot contain: whitespace, quotes," in errmsg
def test_is_valid_command_valid(parser) -> None:
# Valid command
- valid, errmsg = parser.is_valid_command('shell')
+ valid, errmsg = parser.is_valid_command("shell")
assert valid
assert not errmsg
# Subcommands can start with shortcut
- valid, errmsg = parser.is_valid_command('!subcmd', is_subcommand=True)
+ valid, errmsg = parser.is_valid_command("!subcmd", is_subcommand=True)
assert valid
assert not errmsg
@@ -1050,48 +980,48 @@ def test_macro_normal_arg_pattern() -> None:
pattern = MacroArg.macro_normal_arg_pattern
# Valid strings
- matches = pattern.findall('{5}')
- assert matches == ['{5}']
+ matches = pattern.findall("{5}")
+ assert matches == ["{5}"]
- matches = pattern.findall('{233}')
- assert matches == ['{233}']
+ matches = pattern.findall("{233}")
+ assert matches == ["{233}"]
- matches = pattern.findall('{{{{{4}')
- assert matches == ['{4}']
+ matches = pattern.findall("{{{{{4}")
+ assert matches == ["{4}"]
- matches = pattern.findall('{2}}}}}')
- assert matches == ['{2}']
+ matches = pattern.findall("{2}}}}}")
+ assert matches == ["{2}"]
- matches = pattern.findall('{3}{4}{5}')
- assert matches == ['{3}', '{4}', '{5}']
+ matches = pattern.findall("{3}{4}{5}")
+ assert matches == ["{3}", "{4}", "{5}"]
- matches = pattern.findall('{3} {4} {5}')
- assert matches == ['{3}', '{4}', '{5}']
+ matches = pattern.findall("{3} {4} {5}")
+ assert matches == ["{3}", "{4}", "{5}"]
- matches = pattern.findall('{3} {{{4} {5}}}}')
- assert matches == ['{3}', '{4}', '{5}']
+ matches = pattern.findall("{3} {{{4} {5}}}}")
+ assert matches == ["{3}", "{4}", "{5}"]
- matches = pattern.findall('{3} text {4} stuff {5}}}}')
- assert matches == ['{3}', '{4}', '{5}']
+ matches = pattern.findall("{3} text {4} stuff {5}}}}")
+ assert matches == ["{3}", "{4}", "{5}"]
# Unicode digit
- matches = pattern.findall('{\N{ARABIC-INDIC DIGIT ONE}}')
- assert matches == ['{\N{ARABIC-INDIC DIGIT ONE}}']
+ matches = pattern.findall("{\N{ARABIC-INDIC DIGIT ONE}}")
+ assert matches == ["{\N{ARABIC-INDIC DIGIT ONE}}"]
# Invalid strings
- matches = pattern.findall('5')
+ matches = pattern.findall("5")
assert not matches
- matches = pattern.findall('{5')
+ matches = pattern.findall("{5")
assert not matches
- matches = pattern.findall('5}')
+ matches = pattern.findall("5}")
assert not matches
- matches = pattern.findall('{{5}}')
+ matches = pattern.findall("{{5}}")
assert not matches
- matches = pattern.findall('{5text}')
+ matches = pattern.findall("{5text}")
assert not matches
@@ -1104,46 +1034,46 @@ def test_macro_escaped_arg_pattern() -> None:
pattern = MacroArg.macro_escaped_arg_pattern
# Valid strings
- matches = pattern.findall('{{5}}')
- assert matches == ['{{5}}']
+ matches = pattern.findall("{{5}}")
+ assert matches == ["{{5}}"]
- matches = pattern.findall('{{233}}')
- assert matches == ['{{233}}']
+ matches = pattern.findall("{{233}}")
+ assert matches == ["{{233}}"]
- matches = pattern.findall('{{{{{4}}')
- assert matches == ['{{4}}']
+ matches = pattern.findall("{{{{{4}}")
+ assert matches == ["{{4}}"]
- matches = pattern.findall('{{2}}}}}')
- assert matches == ['{{2}}']
+ matches = pattern.findall("{{2}}}}}")
+ assert matches == ["{{2}}"]
- matches = pattern.findall('{{3}}{{4}}{{5}}')
- assert matches == ['{{3}}', '{{4}}', '{{5}}']
+ matches = pattern.findall("{{3}}{{4}}{{5}}")
+ assert matches == ["{{3}}", "{{4}}", "{{5}}"]
- matches = pattern.findall('{{3}} {{4}} {{5}}')
- assert matches == ['{{3}}', '{{4}}', '{{5}}']
+ matches = pattern.findall("{{3}} {{4}} {{5}}")
+ assert matches == ["{{3}}", "{{4}}", "{{5}}"]
- matches = pattern.findall('{{3}} {{{4}} {{5}}}}')
- assert matches == ['{{3}}', '{{4}}', '{{5}}']
+ matches = pattern.findall("{{3}} {{{4}} {{5}}}}")
+ assert matches == ["{{3}}", "{{4}}", "{{5}}"]
- matches = pattern.findall('{{3}} text {{4}} stuff {{5}}}}')
- assert matches == ['{{3}}', '{{4}}', '{{5}}']
+ matches = pattern.findall("{{3}} text {{4}} stuff {{5}}}}")
+ assert matches == ["{{3}}", "{{4}}", "{{5}}"]
# Unicode digit
- matches = pattern.findall('{{\N{ARABIC-INDIC DIGIT ONE}}}')
- assert matches == ['{{\N{ARABIC-INDIC DIGIT ONE}}}']
+ matches = pattern.findall("{{\N{ARABIC-INDIC DIGIT ONE}}}")
+ assert matches == ["{{\N{ARABIC-INDIC DIGIT ONE}}}"]
# Invalid strings
- matches = pattern.findall('5')
+ matches = pattern.findall("5")
assert not matches
- matches = pattern.findall('{{5')
+ matches = pattern.findall("{{5")
assert not matches
- matches = pattern.findall('5}}')
+ matches = pattern.findall("5}}")
assert not matches
- matches = pattern.findall('{5}')
+ matches = pattern.findall("{5}")
assert not matches
- matches = pattern.findall('{{5text}}')
+ matches = pattern.findall("{{5text}}")
assert not matches
diff --git a/tests/test_plugin.py b/tests/test_plugin.py
index 8b1c9da8f..c47c30ad2 100644
--- a/tests/test_plugin.py
+++ b/tests/test_plugin.py
@@ -148,7 +148,7 @@ def precmd_hook_no_return_annotation(self, data: plugin.PrecommandData):
return data
def precmd_hook_wrong_return_annotation(self, data: plugin.PrecommandData) -> cmd2.Statement:
- return self.statement_parser.parse('hi there')
+ return self.statement_parser.parse("hi there")
###
#
@@ -190,7 +190,7 @@ def postcmd_hook_no_return_annotation(self, data: plugin.PostcommandData):
return data
def postcmd_hook_wrong_return_annotation(self, data: plugin.PostcommandData) -> cmd2.Statement:
- return self.statement_parser.parse('hi there')
+ return self.statement_parser.parse("hi there")
###
#
@@ -258,7 +258,7 @@ def cmdfinalization_hook_no_return_annotation(self, data: plugin.CommandFinaliza
def cmdfinalization_hook_wrong_return_annotation(self, data: plugin.CommandFinalizationData) -> cmd2.Statement:
"""A command finalization hook with the wrong return type annotation."""
- return self.statement_parser.parse('hi there')
+ return self.statement_parser.parse("hi there")
class PluggedApp(Plugin, cmd2.Cmd):
@@ -281,7 +281,7 @@ def do_skip_postcmd_hooks(self, _) -> NoReturn:
@with_argparser(parser)
def do_argparse_cmd(self, namespace: argparse.Namespace) -> None:
"""Repeat back the arguments"""
- self.poutput(namespace.cmd2_statement.get())
+ self.poutput(namespace.cmd2_statement)
###
@@ -303,30 +303,30 @@ def test_register_preloop_hook_with_return_annotation() -> None:
def test_preloop_hook(capsys) -> None:
# Need to patch sys.argv so cmd2 doesn't think it was called with arguments equal to the py.test args
- testargs = ["prog", "say hello", 'quit']
+ testargs = ["prog", "say hello", "quit"]
- with mock.patch.object(sys, 'argv', testargs):
+ with mock.patch.object(sys, "argv", testargs):
app = PluggedApp()
app.register_preloop_hook(app.prepost_hook_one)
app.cmdloop()
out, err = capsys.readouterr()
- assert out == 'one\nhello\n'
+ assert out == "one\nhello\n"
assert not err
def test_preloop_hooks(capsys) -> None:
# Need to patch sys.argv so cmd2 doesn't think it was called with arguments equal to the py.test args
- testargs = ["prog", "say hello", 'quit']
+ testargs = ["prog", "say hello", "quit"]
- with mock.patch.object(sys, 'argv', testargs):
+ with mock.patch.object(sys, "argv", testargs):
app = PluggedApp()
app.register_preloop_hook(app.prepost_hook_one)
app.register_preloop_hook(app.prepost_hook_two)
app.cmdloop()
out, err = capsys.readouterr()
- assert out == 'one\ntwo\nhello\n'
+ assert out == "one\ntwo\nhello\n"
assert not err
@@ -344,30 +344,30 @@ def test_register_postloop_hook_with_wrong_return_annotation() -> None:
def test_postloop_hook(capsys) -> None:
# Need to patch sys.argv so cmd2 doesn't think it was called with arguments equal to the py.test args
- testargs = ["prog", "say hello", 'quit']
+ testargs = ["prog", "say hello", "quit"]
- with mock.patch.object(sys, 'argv', testargs):
+ with mock.patch.object(sys, "argv", testargs):
app = PluggedApp()
app.register_postloop_hook(app.prepost_hook_one)
app.cmdloop()
out, err = capsys.readouterr()
- assert out == 'hello\none\n'
+ assert out == "hello\none\n"
assert not err
def test_postloop_hooks(capsys) -> None:
# Need to patch sys.argv so cmd2 doesn't think it was called with arguments equal to the py.test args
- testargs = ["prog", "say hello", 'quit']
+ testargs = ["prog", "say hello", "quit"]
- with mock.patch.object(sys, 'argv', testargs):
+ with mock.patch.object(sys, "argv", testargs):
app = PluggedApp()
app.register_postloop_hook(app.prepost_hook_one)
app.register_postloop_hook(app.prepost_hook_two)
app.cmdloop()
out, err = capsys.readouterr()
- assert out == 'hello\none\ntwo\n'
+ assert out == "hello\none\ntwo\n"
assert not err
@@ -379,9 +379,9 @@ def test_postloop_hooks(capsys) -> None:
def test_preparse(capsys) -> None:
app = PluggedApp()
app.register_postparsing_hook(app.preparse)
- app.onecmd_plus_hooks('say hello')
+ app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
- assert out == 'hello\n'
+ assert out == "hello\n"
assert not err
assert app.called_preparse == 1
@@ -423,26 +423,26 @@ def test_postparsing_hook_wrong_return_annotation() -> None:
def test_postparsing_hook(capsys) -> None:
app = PluggedApp()
- app.onecmd_plus_hooks('say hello')
+ app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
- assert out == 'hello\n'
+ assert out == "hello\n"
assert not err
assert not app.called_postparsing
app.reset_counters()
app.register_postparsing_hook(app.postparse_hook)
- app.onecmd_plus_hooks('say hello')
+ app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
- assert out == 'hello\n'
+ assert out == "hello\n"
assert not err
assert app.called_postparsing == 1
# register the function again, so it should be called twice
app.reset_counters()
app.register_postparsing_hook(app.postparse_hook)
- app.onecmd_plus_hooks('say hello')
+ app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
- assert out == 'hello\n'
+ assert out == "hello\n"
assert not err
assert app.called_postparsing == 2
@@ -450,14 +450,14 @@ def test_postparsing_hook(capsys) -> None:
def test_postparsing_hook_stop_first(capsys) -> None:
app = PluggedApp()
app.register_postparsing_hook(app.postparse_hook_stop)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
assert app.called_postparsing == 1
assert stop
# register another function but it shouldn't be called
app.reset_counters()
app.register_postparsing_hook(app.postparse_hook)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
assert app.called_postparsing == 1
assert stop
@@ -465,21 +465,21 @@ def test_postparsing_hook_stop_first(capsys) -> None:
def test_postparsing_hook_stop_second(capsys) -> None:
app = PluggedApp()
app.register_postparsing_hook(app.postparse_hook)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
assert app.called_postparsing == 1
assert not stop
# register another function and make sure it gets called
app.reset_counters()
app.register_postparsing_hook(app.postparse_hook_stop)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
assert app.called_postparsing == 2
assert stop
# register a third function which shouldn't be called
app.reset_counters()
app.register_postparsing_hook(app.postparse_hook)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
assert app.called_postparsing == 2
assert stop
@@ -487,7 +487,7 @@ def test_postparsing_hook_stop_second(capsys) -> None:
def test_postparsing_hook_emptystatement_first(capsys) -> None:
app = PluggedApp()
app.register_postparsing_hook(app.postparse_hook_emptystatement)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
assert not stop
assert not out
@@ -497,7 +497,7 @@ def test_postparsing_hook_emptystatement_first(capsys) -> None:
# register another function but it shouldn't be called
app.reset_counters()
stop = app.register_postparsing_hook(app.postparse_hook)
- app.onecmd_plus_hooks('say hello')
+ app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
assert not stop
assert not out
@@ -508,17 +508,17 @@ def test_postparsing_hook_emptystatement_first(capsys) -> None:
def test_postparsing_hook_emptystatement_second(capsys) -> None:
app = PluggedApp()
app.register_postparsing_hook(app.postparse_hook)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
assert not stop
- assert out == 'hello\n'
+ assert out == "hello\n"
assert not err
assert app.called_postparsing == 1
# register another function and make sure it gets called
app.reset_counters()
app.register_postparsing_hook(app.postparse_hook_emptystatement)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
assert not stop
assert not out
@@ -528,7 +528,7 @@ def test_postparsing_hook_emptystatement_second(capsys) -> None:
# register a third function which shouldn't be called
app.reset_counters()
app.register_postparsing_hook(app.postparse_hook)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
assert not stop
assert not out
@@ -539,7 +539,7 @@ def test_postparsing_hook_emptystatement_second(capsys) -> None:
def test_postparsing_hook_exception(capsys) -> None:
app = PluggedApp()
app.register_postparsing_hook(app.postparse_hook_exception)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
assert not stop
assert not out
@@ -549,7 +549,7 @@ def test_postparsing_hook_exception(capsys) -> None:
# register another function, but it shouldn't be called
app.reset_counters()
app.register_postparsing_hook(app.postparse_hook)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
assert not stop
assert not out
@@ -596,18 +596,18 @@ def test_register_precmd_hook_wrong_return_annotation() -> None:
def test_precmd_hook(capsys) -> None:
app = PluggedApp()
- app.onecmd_plus_hooks('say hello')
+ app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
- assert out == 'hello\n'
+ assert out == "hello\n"
assert not err
# without registering any hooks, precmd() should be called
assert app.called_precmd == 1
app.reset_counters()
app.register_precmd_hook(app.precmd_hook)
- app.onecmd_plus_hooks('say hello')
+ app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
- assert out == 'hello\n'
+ assert out == "hello\n"
assert not err
# with one hook registered, we should get precmd() and the hook
assert app.called_precmd == 2
@@ -615,9 +615,9 @@ def test_precmd_hook(capsys) -> None:
# register the function again, so it should be called twice
app.reset_counters()
app.register_precmd_hook(app.precmd_hook)
- app.onecmd_plus_hooks('say hello')
+ app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
- assert out == 'hello\n'
+ assert out == "hello\n"
assert not err
# with two hooks registered, we should get precmd() and both hooks
assert app.called_precmd == 3
@@ -626,7 +626,7 @@ def test_precmd_hook(capsys) -> None:
def test_precmd_hook_emptystatement_first(capsys) -> None:
app = PluggedApp()
app.register_precmd_hook(app.precmd_hook_emptystatement)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
assert not stop
assert not out
@@ -638,7 +638,7 @@ def test_precmd_hook_emptystatement_first(capsys) -> None:
# register another function but it shouldn't be called
app.reset_counters()
stop = app.register_precmd_hook(app.precmd_hook)
- app.onecmd_plus_hooks('say hello')
+ app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
assert not stop
assert not out
@@ -652,10 +652,10 @@ def test_precmd_hook_emptystatement_first(capsys) -> None:
def test_precmd_hook_emptystatement_second(capsys) -> None:
app = PluggedApp()
app.register_precmd_hook(app.precmd_hook)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
assert not stop
- assert out == 'hello\n'
+ assert out == "hello\n"
assert not err
# with one hook registered, we should get precmd() and the hook
assert app.called_precmd == 2
@@ -663,7 +663,7 @@ def test_precmd_hook_emptystatement_second(capsys) -> None:
# register another function and make sure it gets called
app.reset_counters()
app.register_precmd_hook(app.precmd_hook_emptystatement)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
assert not stop
assert not out
@@ -675,7 +675,7 @@ def test_precmd_hook_emptystatement_second(capsys) -> None:
# register a third function which shouldn't be called
app.reset_counters()
app.register_precmd_hook(app.precmd_hook)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
assert not stop
assert not out
@@ -725,18 +725,18 @@ def test_register_postcmd_hook_wrong_return_annotation() -> None:
def test_postcmd(capsys) -> None:
app = PluggedApp()
- app.onecmd_plus_hooks('say hello')
+ app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
- assert out == 'hello\n'
+ assert out == "hello\n"
assert not err
# without registering any hooks, postcmd() should be called
assert app.called_postcmd == 1
app.reset_counters()
app.register_postcmd_hook(app.postcmd_hook)
- app.onecmd_plus_hooks('say hello')
+ app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
- assert out == 'hello\n'
+ assert out == "hello\n"
assert not err
# with one hook registered, we should get precmd() and the hook
assert app.called_postcmd == 2
@@ -744,9 +744,9 @@ def test_postcmd(capsys) -> None:
# register the function again, so it should be called twice
app.reset_counters()
app.register_postcmd_hook(app.postcmd_hook)
- app.onecmd_plus_hooks('say hello')
+ app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
- assert out == 'hello\n'
+ assert out == "hello\n"
assert not err
# with two hooks registered, we should get precmd() and both hooks
assert app.called_postcmd == 3
@@ -755,10 +755,10 @@ def test_postcmd(capsys) -> None:
def test_postcmd_exception_first(capsys) -> None:
app = PluggedApp()
app.register_postcmd_hook(app.postcmd_hook_exception)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
assert not stop
- assert out == 'hello\n'
+ assert out == "hello\n"
assert err
# since the registered hooks are called before postcmd(), if a registered
# hook throws an exception, postcmd() is never called. So we should have
@@ -768,10 +768,10 @@ def test_postcmd_exception_first(capsys) -> None:
# register another function but it shouldn't be called
app.reset_counters()
stop = app.register_postcmd_hook(app.postcmd_hook)
- app.onecmd_plus_hooks('say hello')
+ app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
assert not stop
- assert out == 'hello\n'
+ assert out == "hello\n"
assert err
# the exception raised by the first hook should prevent the second
# hook from being called, and it also prevents postcmd() from being
@@ -782,10 +782,10 @@ def test_postcmd_exception_first(capsys) -> None:
def test_postcmd_exception_second(capsys) -> None:
app = PluggedApp()
app.register_postcmd_hook(app.postcmd_hook)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
assert not stop
- assert out == 'hello\n'
+ assert out == "hello\n"
assert not err
# with one hook registered, we should get the hook and postcmd()
assert app.called_postcmd == 2
@@ -793,10 +793,10 @@ def test_postcmd_exception_second(capsys) -> None:
# register another function which should be called
app.reset_counters()
stop = app.register_postcmd_hook(app.postcmd_hook_exception)
- app.onecmd_plus_hooks('say hello')
+ app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
assert not stop
- assert out == 'hello\n'
+ assert out == "hello\n"
assert err
# the exception raised by the first hook should prevent the second
# hook from being called, and it also prevents postcmd() from being
@@ -844,25 +844,25 @@ def test_register_cmdfinalization_hook_wrong_return_annotation() -> None:
def test_cmdfinalization(capsys) -> None:
app = PluggedApp()
- app.onecmd_plus_hooks('say hello')
+ app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
- assert out == 'hello\n'
+ assert out == "hello\n"
assert not err
assert app.called_cmdfinalization == 0
app.register_cmdfinalization_hook(app.cmdfinalization_hook)
- app.onecmd_plus_hooks('say hello')
+ app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
- assert out == 'hello\n'
+ assert out == "hello\n"
assert not err
assert app.called_cmdfinalization == 1
# register the function again, so it should be called twice
app.reset_counters()
app.register_cmdfinalization_hook(app.cmdfinalization_hook)
- app.onecmd_plus_hooks('say hello')
+ app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
- assert out == 'hello\n'
+ assert out == "hello\n"
assert not err
assert app.called_cmdfinalization == 2
@@ -871,9 +871,9 @@ def test_cmdfinalization_stop_first(capsys) -> None:
app = PluggedApp()
app.register_cmdfinalization_hook(app.cmdfinalization_hook_stop)
app.register_cmdfinalization_hook(app.cmdfinalization_hook)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
- assert out == 'hello\n'
+ assert out == "hello\n"
assert not err
assert app.called_cmdfinalization == 2
assert stop
@@ -883,9 +883,9 @@ def test_cmdfinalization_stop_second(capsys) -> None:
app = PluggedApp()
app.register_cmdfinalization_hook(app.cmdfinalization_hook)
app.register_cmdfinalization_hook(app.cmdfinalization_hook_stop)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
- assert out == 'hello\n'
+ assert out == "hello\n"
assert not err
assert app.called_cmdfinalization == 2
assert stop
@@ -894,20 +894,20 @@ def test_cmdfinalization_stop_second(capsys) -> None:
def test_cmdfinalization_hook_exception(capsys) -> None:
app = PluggedApp()
app.register_cmdfinalization_hook(app.cmdfinalization_hook_exception)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
assert not stop
- assert out == 'hello\n'
+ assert out == "hello\n"
assert err
assert app.called_cmdfinalization == 1
# register another function, but it shouldn't be called
app.reset_counters()
app.register_cmdfinalization_hook(app.cmdfinalization_hook)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
out, err = capsys.readouterr()
assert not stop
- assert out == 'hello\n'
+ assert out == "hello\n"
assert err
assert app.called_cmdfinalization == 1
@@ -915,7 +915,7 @@ def test_cmdfinalization_hook_exception(capsys) -> None:
def test_cmdfinalization_hook_system_exit() -> None:
app = PluggedApp()
app.register_cmdfinalization_hook(app.cmdfinalization_hook_system_exit)
- stop = app.onecmd_plus_hooks('say hello')
+ stop = app.onecmd_plus_hooks("say hello")
assert stop
assert app.called_cmdfinalization == 1
assert app.exit_code == 5
@@ -926,20 +926,20 @@ def test_cmdfinalization_hook_keyboard_interrupt() -> None:
app.register_cmdfinalization_hook(app.cmdfinalization_hook_keyboard_interrupt)
# First make sure KeyboardInterrupt isn't raised unless told to
- stop = app.onecmd_plus_hooks('say hello', raise_keyboard_interrupt=False)
+ stop = app.onecmd_plus_hooks("say hello", raise_keyboard_interrupt=False)
assert not stop
assert app.called_cmdfinalization == 1
# Now enable raising the KeyboardInterrupt
app.reset_counters()
with pytest.raises(KeyboardInterrupt):
- stop = app.onecmd_plus_hooks('say hello', raise_keyboard_interrupt=True)
+ stop = app.onecmd_plus_hooks("say hello", raise_keyboard_interrupt=True)
assert not stop
assert app.called_cmdfinalization == 1
# Now make sure KeyboardInterrupt isn't raised if stop is already True
app.reset_counters()
- stop = app.onecmd_plus_hooks('quit', raise_keyboard_interrupt=True)
+ stop = app.onecmd_plus_hooks("quit", raise_keyboard_interrupt=True)
assert stop
assert app.called_cmdfinalization == 1
@@ -950,7 +950,7 @@ def test_cmdfinalization_hook_passthrough_exception() -> None:
expected_err = "Pass me up"
with pytest.raises(OSError, match=expected_err):
- app.onecmd_plus_hooks('say hello')
+ app.onecmd_plus_hooks("say hello")
assert app.called_cmdfinalization == 1
@@ -960,7 +960,7 @@ def test_skip_postcmd_hooks(capsys) -> None:
app.register_cmdfinalization_hook(app.cmdfinalization_hook)
# Cause a SkipPostcommandHooks exception and verify no postcmd stuff runs but cmdfinalization_hook still does
- app.onecmd_plus_hooks('skip_postcmd_hooks')
+ app.onecmd_plus_hooks("skip_postcmd_hooks")
out, _err = capsys.readouterr()
assert "In do_skip_postcmd_hooks" in out
assert app.called_postcmd == 0
@@ -976,9 +976,9 @@ def test_cmd2_argparse_exception(capsys) -> None:
app.register_cmdfinalization_hook(app.cmdfinalization_hook)
# First generate no exception and make sure postcmd_hook, postcmd, and cmdfinalization_hook run
- app.onecmd_plus_hooks('argparse_cmd arg_val')
+ app.onecmd_plus_hooks("argparse_cmd arg_val")
out, err = capsys.readouterr()
- assert out == 'arg_val\n'
+ assert out == "arg_val\n"
assert not err
assert app.called_postcmd == 2
assert app.called_cmdfinalization == 1
@@ -986,7 +986,7 @@ def test_cmd2_argparse_exception(capsys) -> None:
app.reset_counters()
# Next cause an argparse exception and verify no postcmd stuff runs but cmdfinalization_hook still does
- app.onecmd_plus_hooks('argparse_cmd')
+ app.onecmd_plus_hooks("argparse_cmd")
out, err = capsys.readouterr()
assert not out
assert "Error: the following arguments are required: my_arg" in err
diff --git a/tests/test_pt_utils.py b/tests/test_pt_utils.py
new file mode 100644
index 000000000..a8fcdbacc
--- /dev/null
+++ b/tests/test_pt_utils.py
@@ -0,0 +1,602 @@
+"""Unit tests for cmd2/pt_utils.py"""
+
+import io
+import re
+from typing import Any, cast
+from unittest.mock import Mock
+
+import pytest
+from prompt_toolkit.document import Document
+from prompt_toolkit.formatted_text import (
+ ANSI,
+ to_formatted_text,
+)
+from rich.table import Table
+
+import cmd2
+from cmd2 import (
+ Cmd2Style,
+ pt_utils,
+ stylize,
+ utils,
+)
+from cmd2 import rich_utils as ru
+from cmd2 import string_utils as su
+from cmd2.pt_utils import pt_filter_style
+
+from .conftest import with_ansi_style
+
+
+# Mock for cmd2.Cmd
+class MockCmd:
+ def __init__(self) -> None:
+ # Return empty completions by default
+ self.complete = Mock(return_value=cmd2.Completions())
+
+ self.stdout = io.StringIO()
+ self.statement_parser = Mock()
+ self.statement_parser.terminators = [";"]
+ self.statement_parser.shortcuts = []
+ self.statement_parser._command_pattern = re.compile(r"\A\s*(\S*?)(\s|\Z)")
+ self.aliases = {}
+ self.macros = {}
+ self.all_commands = []
+
+ def get_all_commands(self) -> list[str]:
+ return self.all_commands
+
+
+@pytest.fixture
+def mock_cmd_app() -> MockCmd:
+ return MockCmd()
+
+
+@with_ansi_style(ru.AllowStyle.ALWAYS)
+def test_pt_filter_style_always() -> None:
+ """This should preserve all styles and return ANSI."""
+ unstyled = "unstyled"
+ result = pt_filter_style(unstyled)
+ assert isinstance(result, ANSI)
+ assert result.value == unstyled
+
+ styled = stylize("styled", Cmd2Style.COMMAND_LINE)
+ result = pt_filter_style(styled)
+ assert isinstance(result, ANSI)
+ assert result.value == styled
+
+
+@with_ansi_style(ru.AllowStyle.TERMINAL)
+def test_pt_filter_style_terminal() -> None:
+ """This should preserve all styles and return ANSI."""
+ unstyled = "unstyled"
+ result = pt_filter_style(unstyled)
+ assert isinstance(result, ANSI)
+ assert result.value == unstyled
+
+ styled = stylize("styled", Cmd2Style.COMMAND_LINE)
+ result = pt_filter_style(styled)
+ assert isinstance(result, ANSI)
+ assert result.value == styled
+
+
+@with_ansi_style(ru.AllowStyle.NEVER)
+def test_pt_filter_style_never() -> None:
+ """This should strip all styles and return str."""
+ unstyled = "unstyled"
+ result = pt_filter_style(unstyled)
+ assert isinstance(result, str)
+ assert result == unstyled
+
+ styled = stylize("styled", Cmd2Style.COMMAND_LINE)
+ result = pt_filter_style(styled)
+ assert isinstance(result, str)
+ assert result == su.strip_style(styled)
+
+
+class TestCmd2Lexer:
+ @with_ansi_style(ru.AllowStyle.NEVER)
+ def test_lex_document_no_style(self, mock_cmd_app):
+ """Test lexing when styles are disallowed."""
+ lexer = pt_utils.Cmd2Lexer(cast(Any, mock_cmd_app))
+
+ line = "help something"
+ document = Document(line)
+ get_line = lexer.lex_document(document)
+ tokens = get_line(0)
+
+ assert tokens == [("", line)]
+
+ def test_lex_document_command(self, mock_cmd_app):
+ """Test lexing a command name."""
+ mock_cmd_app.all_commands = ["help"]
+ lexer = pt_utils.Cmd2Lexer(cast(Any, mock_cmd_app))
+
+ line = "help something"
+ document = Document(line)
+ get_line = lexer.lex_document(document)
+ tokens = get_line(0)
+
+ assert tokens == [("ansigreen", "help"), ("", " "), ("ansiyellow", "something")]
+
+ def test_lex_document_alias(self, mock_cmd_app):
+ """Test lexing an alias."""
+ mock_cmd_app.aliases = {"ls": "dir"}
+ lexer = pt_utils.Cmd2Lexer(cast(Any, mock_cmd_app))
+
+ line = "ls -l"
+ document = Document(line)
+ get_line = lexer.lex_document(document)
+ tokens = get_line(0)
+
+ assert tokens == [("ansicyan", "ls"), ("", " "), ("ansired", "-l")]
+
+ def test_lex_document_macro(self, mock_cmd_app):
+ """Test lexing a macro."""
+ mock_cmd_app.macros = {"my_macro": "some value"}
+ lexer = pt_utils.Cmd2Lexer(cast(Any, mock_cmd_app))
+
+ line = "my_macro arg1"
+ document = Document(line)
+ get_line = lexer.lex_document(document)
+ tokens = get_line(0)
+
+ assert tokens == [("ansimagenta", "my_macro"), ("", " "), ("ansiyellow", "arg1")]
+
+ def test_lex_document_leading_whitespace(self, mock_cmd_app):
+ """Test lexing with leading whitespace."""
+ mock_cmd_app.all_commands = ["help"]
+ lexer = pt_utils.Cmd2Lexer(cast(Any, mock_cmd_app))
+
+ line = " help something"
+ document = Document(line)
+ get_line = lexer.lex_document(document)
+ tokens = get_line(0)
+
+ assert tokens == [("", " "), ("ansigreen", "help"), ("", " "), ("ansiyellow", "something")]
+
+ def test_lex_document_unknown_command(self, mock_cmd_app):
+ """Test lexing an unknown command."""
+ lexer = pt_utils.Cmd2Lexer(cast(Any, mock_cmd_app))
+
+ line = "unknown command"
+ document = Document(line)
+ get_line = lexer.lex_document(document)
+ tokens = get_line(0)
+
+ assert tokens == [("", "unknown"), ("", " "), ("ansiyellow", "command")]
+
+ def test_lex_document_no_command(self, mock_cmd_app):
+ """Test lexing an empty line or line with only whitespace."""
+ lexer = pt_utils.Cmd2Lexer(cast(Any, mock_cmd_app))
+
+ line = " "
+ document = Document(line)
+ get_line = lexer.lex_document(document)
+ tokens = get_line(0)
+
+ assert tokens == [("", " ")]
+
+ def test_lex_document_no_match(self, mock_cmd_app):
+ """Test lexing when command pattern fails to match."""
+ # Force the pattern to not match anything
+ mock_cmd_app.statement_parser._command_pattern = re.compile(r"something_impossible")
+ lexer = pt_utils.Cmd2Lexer(cast(Any, mock_cmd_app))
+
+ line = "test command"
+ document = Document(line)
+ get_line = lexer.lex_document(document)
+ tokens = get_line(0)
+
+ assert tokens == [("", line)]
+
+ def test_lex_document_arguments(self, mock_cmd_app):
+ """Test lexing a command with flags and values."""
+ mock_cmd_app.all_commands = ["help"]
+ lexer = pt_utils.Cmd2Lexer(cast(Any, mock_cmd_app))
+
+ line = 'help -v --name "John Doe" > out.txt'
+ document = Document(line)
+ get_line = lexer.lex_document(document)
+ tokens = get_line(0)
+
+ assert tokens == [
+ ("ansigreen", "help"),
+ ("", " "),
+ ("ansired", "-v"),
+ ("", " "),
+ ("ansired", "--name"),
+ ("", " "),
+ ("ansiyellow", '"John Doe"'),
+ ("", " "),
+ ("", ">"),
+ ("", " "),
+ ("ansiyellow", "out.txt"),
+ ]
+
+ def test_lex_document_unclosed_quote(self, mock_cmd_app):
+ """Test lexing with an unclosed quote."""
+ mock_cmd_app.all_commands = ["echo"]
+ lexer = pt_utils.Cmd2Lexer(cast(Any, mock_cmd_app))
+
+ line = 'echo "hello'
+ document = Document(line)
+ get_line = lexer.lex_document(document)
+ tokens = get_line(0)
+
+ assert tokens == [("ansigreen", "echo"), ("", " "), ("ansiyellow", '"hello')]
+
+ def test_lex_document_shortcut(self, mock_cmd_app):
+ """Test lexing a shortcut."""
+ mock_cmd_app.statement_parser.shortcuts = [("!", "shell")]
+ lexer = pt_utils.Cmd2Lexer(cast(Any, mock_cmd_app))
+
+ # Case 1: Shortcut glued to argument
+ line = "!ls"
+ document = Document(line)
+ get_line = lexer.lex_document(document)
+ tokens = get_line(0)
+ assert tokens == [("ansigreen", "!"), ("ansiyellow", "ls")]
+
+ line = "! ls"
+ document = Document(line)
+ get_line = lexer.lex_document(document)
+ tokens = get_line(0)
+ assert tokens == [("ansigreen", "!"), ("", " "), ("ansiyellow", "ls")]
+
+ def test_lex_document_multiline(self, mock_cmd_app):
+ """Test lexing a multiline command."""
+ mock_cmd_app.all_commands = ["orate"]
+ lexer = pt_utils.Cmd2Lexer(cast(Any, mock_cmd_app))
+
+ # Command on first line, argument on second line that looks like a command
+ line = "orate\nhelp"
+ document = Document(line)
+ get_line = lexer.lex_document(document)
+
+ # First line should have command
+ tokens0 = get_line(0)
+ assert tokens0 == [("ansigreen", "orate")]
+
+ # Second line should have argument (not command)
+ tokens1 = get_line(1)
+ assert tokens1 == [("ansiyellow", "help")]
+
+
+class TestCmd2Completer:
+ def test_get_completions(self, mock_cmd_app: MockCmd, monkeypatch) -> None:
+ """Test get_completions with matches."""
+ mock_print = Mock()
+ monkeypatch.setattr(pt_utils, "print_formatted_text", mock_print)
+
+ completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app))
+
+ # Set up document
+ line = ""
+ document = Document(line, cursor_position=0)
+
+ # Test plain and styled values for display and display_meta
+ foo_text = "foo"
+ foo_display = "Foo Display"
+ foo_meta = "Foo Meta"
+
+ bar_text = "bar"
+ bar_display = stylize("Bar Display", Cmd2Style.SUCCESS)
+ bar_meta = stylize("Bar Meta", Cmd2Style.WARNING)
+
+ # Set up matches
+ completion_items = [
+ cmd2.CompletionItem(foo_text, display=foo_display, display_meta=foo_meta),
+ cmd2.CompletionItem(bar_text, display=bar_display, display_meta=bar_meta),
+ ]
+
+ table = Table("Table Header")
+ table.add_row("Table Data")
+ cmd2_completions = cmd2.Completions(completion_items, table=table)
+ mock_cmd_app.complete.return_value = cmd2_completions
+
+ # Call get_completions
+ completions = list(completer.get_completions(document, None))
+
+ assert len(completions) == len(cmd2_completions)
+
+ assert completions[0].text == bar_text
+ assert to_formatted_text(completions[0].display) == to_formatted_text(ANSI(bar_display))
+ assert to_formatted_text(completions[0].display_meta) == to_formatted_text(ANSI(bar_meta))
+
+ assert completions[1].text == foo_text
+ assert to_formatted_text(completions[1].display) == to_formatted_text(ANSI(foo_display))
+ assert to_formatted_text(completions[1].display_meta) == to_formatted_text(ANSI(foo_meta))
+
+ # Verify that only the completion table printed
+ assert mock_print.call_count == 1
+ args, _ = mock_print.call_args
+ assert "Table Header" in str(args[0])
+ assert "Table Data" in str(args[0])
+
+ def test_get_completions_no_matches(self, mock_cmd_app: MockCmd, monkeypatch) -> None:
+ """Test get_completions with no matches."""
+ mock_print = Mock()
+ monkeypatch.setattr(pt_utils, "print_formatted_text", mock_print)
+
+ completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app))
+
+ document = Document("", cursor_position=0)
+
+ # Set up matches
+ cmd2_completions = cmd2.Completions(hint="Completion Hint")
+ mock_cmd_app.complete.return_value = cmd2_completions
+
+ completions = list(completer.get_completions(document, None))
+ assert not completions
+
+ # Verify that only the completion hint printed
+ assert mock_print.call_count == 1
+ args, _ = mock_print.call_args
+ assert cmd2_completions.hint in str(args[0])
+
+ def test_get_completions_with_error(self, mock_cmd_app: MockCmd, monkeypatch) -> None:
+ """Test get_completions with a completion error."""
+ mock_print = Mock()
+ monkeypatch.setattr(pt_utils, "print_formatted_text", mock_print)
+
+ completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app))
+
+ document = Document("", cursor_position=0)
+
+ # Set up matches
+ cmd2_completions = cmd2.Completions(error="Completion Error")
+ mock_cmd_app.complete.return_value = cmd2_completions
+
+ completions = list(completer.get_completions(document, None))
+ assert not completions
+
+ # Verify that only the completion error printed
+ assert mock_print.call_count == 1
+ args, _ = mock_print.call_args
+ assert cmd2_completions.error in str(args[0])
+
+ @pytest.mark.parametrize(
+ # search_text_offset is the starting index of the user-provided search text within a full match.
+ # This accounts for leading shortcuts (e.g., in '@has', the offset is 1).
+ ("line", "match", "search_text_offset"),
+ [
+ ("has", "has space", 0),
+ ("@has", "@has space", 1),
+ ],
+ )
+ def test_get_completions_add_opening_quote_and_abort(self, line, match, search_text_offset, mock_cmd_app) -> None:
+ """Test case where adding an opening quote changes text before cursor.
+
+ This applies when there is search text.
+ """
+ completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app))
+
+ # Set up document
+ document = Document(line, cursor_position=len(line))
+
+ # Set up matches
+ completion_items = [cmd2.CompletionItem(match)]
+ cmd2_completions = cmd2.Completions(
+ completion_items,
+ _add_opening_quote=True,
+ _search_text_offset=search_text_offset,
+ _quote_char='"',
+ )
+ mock_cmd_app.complete.return_value = cmd2_completions
+
+ # Call get_completions
+ completions = list(completer.get_completions(document, None))
+
+ # get_completions inserted an opening quote in the buffer and then aborted before returning completions
+ assert not completions
+
+ @pytest.mark.parametrize(
+ # search_text_offset is the starting index of the user-provided search text within a full match.
+ # This accounts for leading shortcuts (e.g., in '@has', the offset is 1).
+ ("line", "matches", "search_text_offset", "quote_char", "expected"),
+ [
+ # Single matches need opening quote, closing quote, and trailing space
+ ("", ["has space"], 0, '"', ['"has space" ']),
+ ("@", ["@has space"], 1, "'", ["@'has space' "]),
+ # Multiple matches only need opening quote
+ ("", ["has space", "more space"], 0, '"', ['"has space', '"more space']),
+ ("@", ["@has space", "@more space"], 1, "'", ["@'has space", "@'more space"]),
+ ],
+ )
+ def test_get_completions_add_opening_quote_and_return_results(
+ self, line, matches, search_text_offset, quote_char, expected, mock_cmd_app
+ ) -> None:
+ """Test case where adding an opening quote does not change text before cursor.
+
+ This applies when search text is empty.
+ """
+ completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app))
+
+ # Set up document
+ document = Document(line, cursor_position=len(line))
+
+ # Set up matches
+ completion_items = [cmd2.CompletionItem(match) for match in matches]
+
+ cmd2_completions = cmd2.Completions(
+ completion_items,
+ _add_opening_quote=True,
+ _search_text_offset=search_text_offset,
+ _quote_char=quote_char,
+ )
+ mock_cmd_app.complete.return_value = cmd2_completions
+
+ # Call get_completions
+ completions = list(completer.get_completions(document, None))
+
+ # Compare results
+ completion_texts = [c.text for c in completions]
+ assert completion_texts == expected
+
+ @pytest.mark.parametrize(
+ ("line", "match", "quote_char", "end_of_line", "expected"),
+ [
+ # --- Unquoted search text ---
+ # Append a trailing space when end_of_line is True
+ ("ma", "match", "", True, "match "),
+ ("ma", "match", "", False, "match"),
+ # --- Quoted search text ---
+ # Ensure closing quotes are added
+ # Append a trailing space when end_of_line is True
+ ('"ma', '"match', '"', True, '"match" '),
+ ("'ma", "'match", "'", False, "'match'"),
+ ],
+ )
+ def test_get_completions_allow_finalization(
+ self, line, match, quote_char, end_of_line, expected, mock_cmd_app: MockCmd
+ ) -> None:
+ """Test that get_completions correctly handles finalizing single matches."""
+ completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app))
+
+ # Set up document
+ cursor_position = len(line) if end_of_line else len(line) - 1
+ document = Document(line, cursor_position=cursor_position)
+
+ # Set up matches
+ completion_items = [cmd2.CompletionItem(match)]
+ cmd2_completions = cmd2.Completions(completion_items, _quote_char=quote_char)
+ mock_cmd_app.complete.return_value = cmd2_completions
+
+ # Call get_completions and compare results
+ completions = list(completer.get_completions(document, None))
+ assert completions[0].text == expected
+
+ @pytest.mark.parametrize(
+ ("line", "match", "quote_char", "end_of_line", "expected"),
+ [
+ # Do not add a trailing space or closing quote to any of the matches
+ ("ma", "match", "", True, "match"),
+ ("ma", "match", "", False, "match"),
+ ('"ma', '"match', '"', True, '"match'),
+ ("'ma", "'match", "'", False, "'match"),
+ ],
+ )
+ def test_get_completions_do_not_allow_finalization(
+ self, line, match, quote_char, end_of_line, expected, mock_cmd_app: MockCmd
+ ) -> None:
+ """Test that get_completions does not finalize single matches when allow_finalization if False."""
+ completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app))
+
+ # Set up document
+ cursor_position = len(line) if end_of_line else len(line) - 1
+ document = Document(line, cursor_position=cursor_position)
+
+ # Set up matches
+ completion_items = [cmd2.CompletionItem(match)]
+ cmd2_completions = cmd2.Completions(
+ completion_items,
+ allow_finalization=False,
+ _quote_char=quote_char,
+ )
+ mock_cmd_app.complete.return_value = cmd2_completions
+
+ # Call get_completions and compare results
+ completions = list(completer.get_completions(document, None))
+ assert completions[0].text == expected
+
+ def test_init_with_custom_settings(self, mock_cmd_app: MockCmd) -> None:
+ """Test initializing with custom settings."""
+ mock_parser = Mock()
+ custom_settings = utils.CustomCompletionSettings(parser=mock_parser)
+ completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app), custom_settings=custom_settings)
+
+ document = Document("", cursor_position=0)
+
+ mock_cmd_app.complete.return_value = cmd2.Completions()
+
+ list(completer.get_completions(document, None))
+
+ mock_cmd_app.complete.assert_called_once()
+ assert mock_cmd_app.complete.call_args[1]["custom_settings"] == custom_settings
+
+ def test_get_completions_custom_delimiters(self, mock_cmd_app: MockCmd) -> None:
+ """Test that custom delimiters (terminators) are respected."""
+ mock_cmd_app.statement_parser.terminators = ["#"]
+ completer = pt_utils.Cmd2Completer(cast(Any, mock_cmd_app))
+
+ # '#' should act as a word boundary
+ document = Document("cmd#arg", cursor_position=7)
+ list(completer.get_completions(document, None))
+
+ # text should be "arg", begidx=4, endidx=7
+ mock_cmd_app.complete.assert_called_with("arg", line="cmd#arg", begidx=4, endidx=7, custom_settings=None)
+
+
+class TestCmd2History:
+ def test_load_history_strings(self):
+ """Test loading history strings yields all items newest to oldest."""
+
+ history_strings = ["cmd1", "cmd2", "cmd2", "cmd3", "cmd2"]
+ history = pt_utils.Cmd2History(history_strings)
+ assert history._loaded
+
+ # Consecutive duplicates are removed
+ expected = ["cmd2", "cmd3", "cmd2", "cmd1"]
+ assert list(history.load_history_strings()) == expected
+
+ def test_load_history_strings_empty(self):
+ """Test loading history strings with empty history."""
+ history = pt_utils.Cmd2History()
+ assert history._loaded
+ assert list(history.load_history_strings()) == []
+
+ history = pt_utils.Cmd2History([])
+ assert history._loaded
+ assert list(history.load_history_strings()) == []
+
+ history = pt_utils.Cmd2History(None)
+ assert history._loaded
+ assert list(history.load_history_strings()) == []
+
+ def test_get_strings(self):
+ history_strings = ["cmd1", "cmd2", "cmd2", "cmd3", "cmd2"]
+ history = pt_utils.Cmd2History(history_strings)
+ assert history._loaded
+
+ # Consecutive duplicates are removed
+ expected = ["cmd1", "cmd2", "cmd3", "cmd2"]
+ assert history.get_strings() == expected
+
+ def test_append_string(self):
+ """Test that append_string() adds data."""
+ history = pt_utils.Cmd2History()
+ assert history._loaded
+ assert not history._loaded_strings
+
+ history.append_string("new command")
+ assert len(history._loaded_strings) == 1
+ assert history._loaded_strings[0] == "new command"
+
+ # Show that consecutive duplicates are filtered
+ history.append_string("new command")
+ assert len(history._loaded_strings) == 1
+ assert history._loaded_strings[0] == "new command"
+
+ # Show that new items are placed at the front
+ history.append_string("even newer command")
+ assert len(history._loaded_strings) == 2
+ assert history._loaded_strings[0] == "even newer command"
+ assert history._loaded_strings[1] == "new command"
+
+ def test_store_string(self):
+ """Test that store_string() does nothing."""
+ history = pt_utils.Cmd2History()
+ assert history._loaded
+ assert not history._loaded_strings
+
+ history.store_string("new command")
+ assert not history._loaded_strings
+
+ def test_clear(self):
+ history_strings = ["cmd1", "cmd2"]
+ history = pt_utils.Cmd2History(history_strings)
+ assert history._loaded
+ assert history.get_strings() == history_strings
+
+ history.clear()
+ assert not history.get_strings()
diff --git a/tests/test_py_completion.py b/tests/test_py_completion.py
new file mode 100644
index 000000000..d463119b7
--- /dev/null
+++ b/tests/test_py_completion.py
@@ -0,0 +1,54 @@
+import sys
+from code import InteractiveConsole
+from unittest import mock
+
+import pytest
+
+pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="This test file is not applicable on Windows")
+
+
+def test_py_completion_setup_readline(base_app):
+ # Mock readline and rlcompleter
+ mock_readline = mock.MagicMock()
+ mock_readline.__doc__ = "GNU Readline"
+ mock_rlcompleter = mock.MagicMock()
+
+ with mock.patch.dict(sys.modules, {"readline": mock_readline, "rlcompleter": mock_rlcompleter}):
+ interp = InteractiveConsole()
+ base_app._set_up_py_shell_env(interp)
+
+ # Verify completion setup for GNU Readline
+ mock_readline.parse_and_bind.assert_called_with("tab: complete")
+ mock_readline.set_completer.assert_called()
+
+
+def test_py_completion_setup_libedit(base_app):
+ # Mock readline and rlcompleter
+ mock_readline = mock.MagicMock()
+ mock_readline.__doc__ = "libedit"
+ mock_rlcompleter = mock.MagicMock()
+
+ with mock.patch.dict(sys.modules, {"readline": mock_readline, "rlcompleter": mock_rlcompleter}):
+ interp = InteractiveConsole()
+ base_app._set_up_py_shell_env(interp)
+
+ # Verify completion setup for LibEdit
+ mock_readline.parse_and_bind.assert_called_with("bind ^I rl_complete")
+ mock_readline.set_completer.assert_called()
+
+
+def test_py_completion_restore(base_app):
+ # Mock readline
+ mock_readline = mock.MagicMock()
+ original_completer = mock.Mock()
+ mock_readline.get_completer.return_value = original_completer
+
+ with mock.patch.dict(sys.modules, {"readline": mock_readline, "rlcompleter": mock.MagicMock()}):
+ interp = InteractiveConsole()
+ env = base_app._set_up_py_shell_env(interp)
+
+ # Restore and verify
+ base_app._restore_cmd2_env(env)
+
+ # set_completer is called twice: once in setup, once in restore
+ mock_readline.set_completer.assert_called_with(original_completer)
diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py
index 9e0435b82..1401c860c 100644
--- a/tests/test_rich_utils.py
+++ b/tests/test_rich_utils.py
@@ -1,32 +1,44 @@
"""Unit testing for cmd2/rich_utils.py module"""
+import sys
+from typing import Any
+from unittest import mock
+
import pytest
import rich.box
+from pytest_mock import MockerFixture
from rich.console import Console
from rich.style import Style
from rich.table import Table
from rich.text import Text
from cmd2 import (
+ Cmd2ArgumentParser,
Cmd2Style,
Color,
)
from cmd2 import rich_utils as ru
+from .conftest import with_ansi_style
+
def test_cmd2_base_console() -> None:
# Test the keyword arguments which are not allowed.
+ with pytest.raises(TypeError) as excinfo:
+ ru.Cmd2BaseConsole(color_system="auto")
+ assert "color_system" in str(excinfo.value)
+
with pytest.raises(TypeError) as excinfo:
ru.Cmd2BaseConsole(force_terminal=True)
- assert 'force_terminal' in str(excinfo.value)
+ assert "force_terminal" in str(excinfo.value)
with pytest.raises(TypeError) as excinfo:
ru.Cmd2BaseConsole(force_interactive=True)
- assert 'force_interactive' in str(excinfo.value)
+ assert "force_interactive" in str(excinfo.value)
with pytest.raises(TypeError) as excinfo:
ru.Cmd2BaseConsole(theme=None)
- assert 'theme' in str(excinfo.value)
+ assert "theme" in str(excinfo.value)
def test_indented_text() -> None:
@@ -68,77 +80,284 @@ def test_indented_table() -> None:
@pytest.mark.parametrize(
- ('rich_text', 'string'),
+ ("rich_text", "string"),
[
(Text("Hello"), "Hello"),
(Text("Hello\n"), "Hello\n"),
- (Text("Hello", style="blue"), "\x1b[34mHello\x1b[0m"),
+ # Test standard color support
+ (Text("Standard", style="blue"), "\x1b[34mStandard\x1b[0m"),
+ # Test 256-color support
+ (Text("256-color", style=Color.NAVY_BLUE), "\x1b[38;5;17m256-color\x1b[0m"),
+ # Test 24-bit color (TrueColor) support
+ (Text("TrueColor", style="#123456"), "\x1b[38;2;18;52;86mTrueColor\x1b[0m"),
],
)
def test_rich_text_to_string(rich_text: Text, string: str) -> None:
assert ru.rich_text_to_string(rich_text) == string
+def test_rich_text_to_string_type_error() -> None:
+ with pytest.raises(TypeError) as excinfo:
+ ru.rich_text_to_string("not a Text object") # type: ignore[arg-type]
+ assert "rich_text_to_string() expected a rich.text.Text object, but got str" in str(excinfo.value)
+
+
def test_set_theme() -> None:
# Save a cmd2, rich-argparse, and rich-specific style.
cmd2_style_key = Cmd2Style.ERROR
argparse_style_key = "argparse.args"
rich_style_key = "inspect.attr"
- orig_cmd2_style = ru.APP_THEME.styles[cmd2_style_key]
- orig_argparse_style = ru.APP_THEME.styles[argparse_style_key]
- orig_rich_style = ru.APP_THEME.styles[rich_style_key]
+ theme = ru.get_theme()
+ orig_cmd2_style = theme.styles[cmd2_style_key]
+ orig_argparse_style = theme.styles[argparse_style_key]
+ orig_rich_style = theme.styles[rich_style_key]
# Overwrite these styles by setting a new theme.
- theme = {
+ new_styles = {
cmd2_style_key: Style(color=Color.CYAN),
argparse_style_key: Style(color=Color.AQUAMARINE3, underline=True),
rich_style_key: Style(color=Color.DARK_GOLDENROD, bold=True),
}
- ru.set_theme(theme)
+ ru.set_theme(new_styles)
# Verify theme styles have changed to our custom values.
- assert ru.APP_THEME.styles[cmd2_style_key] != orig_cmd2_style
- assert ru.APP_THEME.styles[cmd2_style_key] == theme[cmd2_style_key]
-
- assert ru.APP_THEME.styles[argparse_style_key] != orig_argparse_style
- assert ru.APP_THEME.styles[argparse_style_key] == theme[argparse_style_key]
-
- assert ru.APP_THEME.styles[rich_style_key] != orig_rich_style
- assert ru.APP_THEME.styles[rich_style_key] == theme[rich_style_key]
-
-
-def test_from_ansi_wrapper() -> None:
- # Check if we are still patching Text.from_ansi(). If this check fails, then Rich
- # has fixed the bug. Therefore, we can remove this test function and ru._from_ansi_wrapper.
- assert Text.from_ansi.__func__ is ru._from_ansi_wrapper.__func__ # type: ignore[attr-defined]
-
- # Line breaks recognized by str.splitlines().
- # Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines
- line_breaks = {
- "\n", # Line Feed
- "\r", # Carriage Return
- "\r\n", # Carriage Return + Line Feed
- "\v", # Vertical Tab
- "\f", # Form Feed
- "\x1c", # File Separator
- "\x1d", # Group Separator
- "\x1e", # Record Separator
- "\x85", # Next Line (NEL)
- "\u2028", # Line Separator
- "\u2029", # Paragraph Separator
- }
+ assert theme.styles[cmd2_style_key] != orig_cmd2_style
+ assert theme.styles[cmd2_style_key] == new_styles[cmd2_style_key]
+
+ assert theme.styles[argparse_style_key] != orig_argparse_style
+ assert theme.styles[argparse_style_key] == new_styles[argparse_style_key]
+
+ assert theme.styles[rich_style_key] != orig_rich_style
+ assert theme.styles[rich_style_key] == new_styles[rich_style_key]
+
+
+def test_cmd2_base_console_print(mocker: MockerFixture) -> None:
+ """Test that Cmd2BaseConsole.print() calls prepare_objects_for_rendering()."""
+ # Mock prepare_objects_for_rendering to return a specific value
+ prepared_val = ("prepared",)
+ mock_prepare = mocker.patch("cmd2.rich_utils.prepare_objects_for_rendering", return_value=prepared_val)
+
+ # Mock the superclass print() method
+ mock_super_print = mocker.patch("rich.console.Console.print")
+
+ console = ru.Cmd2BaseConsole()
+ console.print("hello")
+
+ # Verify that prepare_objects_for_rendering() was called with the input objects
+ mock_prepare.assert_called_once_with("hello")
+
+ # Verify that the superclass print() method was called with the prepared objects
+ args, _ = mock_super_print.call_args
+ assert args == prepared_val
+
+
+def test_cmd2_base_console_log(mocker: MockerFixture) -> None:
+ """Test that Cmd2BaseConsole.log() calls prepare_objects_for_rendering() and increments _stack_offset."""
+ # Mock prepare_objects_for_rendering to return a specific value
+ prepared_val = ("prepared",)
+ mock_prepare = mocker.patch("cmd2.rich_utils.prepare_objects_for_rendering", return_value=prepared_val)
+
+ # Mock the superclass log() method
+ mock_super_log = mocker.patch("rich.console.Console.log")
+
+ console = ru.Cmd2BaseConsole()
+ console.log("test", _stack_offset=2)
+
+ # Verify that prepare_objects_for_rendering() was called with the input objects
+ mock_prepare.assert_called_once_with("test")
+
+ # Verify that the superclass log() method was called with the prepared objects
+ # and that the stack offset was correctly incremented.
+ args, kwargs = mock_super_log.call_args
+ assert args == prepared_val
+ assert kwargs["_stack_offset"] == 3
+
+
+@with_ansi_style(ru.AllowStyle.ALWAYS)
+def test_cmd2_base_console_init_always_interactive_true() -> None:
+ """Test Cmd2BaseConsole initialization when ALLOW_STYLE is ALWAYS and is_interactive is True."""
+ with (
+ mock.patch("rich.console.Console.__init__", return_value=None) as mock_base_init,
+ mock.patch("cmd2.rich_utils.Console", autospec=True) as mock_detect_console_class,
+ ):
+ mock_detect_console = mock_detect_console_class.return_value
+ mock_detect_console.is_interactive = True
+
+ ru.Cmd2BaseConsole()
+
+ # Verify arguments passed to super().__init__
+ _, kwargs = mock_base_init.call_args
+ assert kwargs["color_system"] == "truecolor"
+ assert kwargs["force_terminal"] is True
+ assert kwargs["force_interactive"] is True
+
+
+@with_ansi_style(ru.AllowStyle.ALWAYS)
+def test_cmd2_base_console_init_always_interactive_false() -> None:
+ """Test Cmd2BaseConsole initialization when ALLOW_STYLE is ALWAYS and is_interactive is False."""
+ with (
+ mock.patch("rich.console.Console.__init__", return_value=None) as mock_base_init,
+ mock.patch("cmd2.rich_utils.Console", autospec=True) as mock_detect_console_class,
+ ):
+ mock_detect_console = mock_detect_console_class.return_value
+ mock_detect_console.is_interactive = False
+
+ ru.Cmd2BaseConsole()
+
+ _, kwargs = mock_base_init.call_args
+ assert kwargs["color_system"] == "truecolor"
+ assert kwargs["force_terminal"] is True
+ assert kwargs["force_interactive"] is False
+
+
+@with_ansi_style(ru.AllowStyle.TERMINAL)
+def test_cmd2_base_console_init_terminal_true() -> None:
+ """Test Cmd2BaseConsole initialization when ALLOW_STYLE is TERMINAL and it is a terminal."""
+ with (
+ mock.patch("rich.console.Console.__init__", return_value=None) as mock_base_init,
+ mock.patch("cmd2.rich_utils.Console", autospec=True) as mock_detect_console_class,
+ ):
+ mock_detect_console = mock_detect_console_class.return_value
+ mock_detect_console.is_terminal = True
+
+ ru.Cmd2BaseConsole()
+
+ _, kwargs = mock_base_init.call_args
+ assert kwargs["color_system"] == "truecolor"
+ assert kwargs["force_terminal"] is None
+ assert kwargs["force_interactive"] is None
+
+
+@with_ansi_style(ru.AllowStyle.TERMINAL)
+def test_cmd2_base_console_init_terminal_false() -> None:
+ """Test Cmd2BaseConsole initialization when ALLOW_STYLE is TERMINAL and it is not a terminal."""
+ with (
+ mock.patch("rich.console.Console.__init__", return_value=None) as mock_base_init,
+ mock.patch("cmd2.rich_utils.Console", autospec=True) as mock_detect_console_class,
+ ):
+ mock_detect_console = mock_detect_console_class.return_value
+ mock_detect_console.is_terminal = False
+
+ ru.Cmd2BaseConsole()
+
+ _, kwargs = mock_base_init.call_args
+ assert kwargs["color_system"] is None
+ assert kwargs["force_terminal"] is None
+ assert kwargs["force_interactive"] is None
+
+
+@with_ansi_style(ru.AllowStyle.NEVER)
+def test_cmd2_base_console_init_never() -> None:
+ """Test Cmd2BaseConsole initialization when ALLOW_STYLE is NEVER."""
+ with mock.patch("rich.console.Console.__init__", return_value=None) as mock_base_init:
+ ru.Cmd2BaseConsole()
+
+ _, kwargs = mock_base_init.call_args
+ assert kwargs["color_system"] is None
+ assert kwargs["force_terminal"] is False
+ assert kwargs["force_interactive"] is None
+
+
+def test_text_group_direct_cmd2() -> None:
+ """Print a TextGroup directly using a Cmd2RichArgparseConsole."""
+ title = "Notes"
+ content = "Some text"
+ text_group = ru.TextGroup(title, content)
+ console = ru.Cmd2RichArgparseConsole()
+ with console.capture() as capture:
+ console.print(text_group)
+ output = capture.get()
+ assert "Notes:" in output
+ assert " Some text" in output
+
+
+def test_text_group_direct_plain() -> None:
+ """Print a TextGroup directly not using a Cmd2RichArgparseConsole."""
+ title = "Notes"
+ content = "Some text"
+ text_group = ru.TextGroup(title, content)
+ console = Console()
+ with console.capture() as capture:
+ console.print(text_group)
+ output = capture.get()
+ assert "Notes:" in output
+ assert " Some text" in output
+
+
+def test_text_group_in_parser_cmd2(capsys: pytest.CaptureFixture[str]) -> None:
+ """Print a TextGroup with argparse using a Cmd2RichArgparseConsole."""
+ parser = Cmd2ArgumentParser(prog="test")
+ parser.epilog = ru.TextGroup("Notes", "Some text")
+
+ # Render help
+ parser.print_help()
+ out, _ = capsys.readouterr()
+
+ assert "Notes:" in out
+ assert " Some text" in out
+
+
+def test_text_group_in_parser_plain(capsys: pytest.CaptureFixture[str]) -> None:
+ """Print a TextGroup with argparse not using a Cmd2RichArgparseConsole."""
+
+ class CustomParser(Cmd2ArgumentParser):
+ def _get_formatter(self, **kwargs: Any) -> ru.Cmd2HelpFormatter:
+ """Overwrite the formatter's console with a plain one."""
+ formatter = super()._get_formatter(**kwargs)
+ formatter.console = Console() # type: ignore[assignment]
+ return formatter
+
+ parser = CustomParser(prog="test")
+ parser.epilog = ru.TextGroup("Notes", "Some text")
+
+ # Render help
+ parser.print_help()
+ out, _ = capsys.readouterr()
+
+ assert "Notes:" in out
+ assert " Some text" in out
+
+
+def test_formatter_console() -> None:
+ # self._console = console (inside console.setter)
+ formatter = ru.Cmd2HelpFormatter(prog="test")
+ new_console = ru.Cmd2RichArgparseConsole()
+ formatter.console = new_console
+ assert formatter._console is new_console
+
+
+@pytest.mark.skipif(
+ sys.version_info < (3, 14),
+ reason="Argparse didn't support color until Python 3.14",
+)
+def test_formatter_set_color(mocker: MockerFixture) -> None:
+ formatter = ru.Cmd2HelpFormatter(prog="test")
+
+ # return (inside _set_color if sys.version_info < (3, 14))
+ mocker.patch("cmd2.argparse_utils.sys.version_info", (3, 13, 0))
+ # This should return early without calling super()._set_color
+ mock_set_color = mocker.patch("rich_argparse.RichHelpFormatter._set_color")
+ formatter._set_color(True)
+ mock_set_color.assert_not_called()
+
+ # except TypeError and super()._set_color(color)
+ mocker.patch("cmd2.argparse_utils.sys.version_info", (3, 15, 0))
+
+ # Reset mock and make it raise TypeError when called with kwargs
+ mock_set_color.reset_mock()
+
+ def side_effect(color: bool, **kwargs: Any) -> None:
+ if kwargs:
+ raise TypeError("unexpected keyword argument 'file'")
+ return
- # Test all line breaks
- for lb in line_breaks:
- input_string = f"Text{lb}"
- expected_output = input_string.replace(lb, "\n")
- assert Text.from_ansi(input_string).plain == expected_output
+ mock_set_color.side_effect = side_effect
- # Test string without trailing line break
- input_string = "No trailing\nline break"
- assert Text.from_ansi(input_string).plain == input_string
+ # This call should trigger the TypeError and then the fallback call
+ formatter._set_color(True, file=sys.stdout)
- # Test empty string
- input_string = ""
- assert Text.from_ansi(input_string).plain == input_string
+ # It should have been called twice: once with kwargs (failed) and once without (fallback)
+ assert mock_set_color.call_count == 2
+ mock_set_color.assert_any_call(True, file=sys.stdout)
+ mock_set_color.assert_any_call(True)
diff --git a/tests/test_run_pyscript.py b/tests/test_run_pyscript.py
index b41c9a060..e19f77c59 100644
--- a/tests/test_run_pyscript.py
+++ b/tests/test_run_pyscript.py
@@ -1,6 +1,5 @@
"""Unit/functional testing for run_pytest in cmd2"""
-import builtins
import os
from unittest import (
mock,
@@ -18,8 +17,8 @@
def test_run_pyscript(base_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
- python_script = os.path.join(test_dir, 'script.py')
- expected = 'This is a python script running ...'
+ python_script = os.path.join(test_dir, "script.py")
+ expected = "This is a python script running ..."
out, _err = run_cmd(base_app, f"run_pyscript {python_script}")
assert expected in out
@@ -28,8 +27,8 @@ def test_run_pyscript(base_app, request) -> None:
def test_run_pyscript_recursive_not_allowed(base_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
- python_script = os.path.join(test_dir, 'pyscript', 'recursive.py')
- expected = 'Recursively entering interactive Python shells is not allowed'
+ python_script = os.path.join(test_dir, "pyscript", "recursive.py")
+ expected = "Recursively entering interactive Python shells is not allowed"
_out, err = run_cmd(base_app, f"run_pyscript {python_script}")
assert err[0] == expected
@@ -37,43 +36,44 @@ def test_run_pyscript_recursive_not_allowed(base_app, request) -> None:
def test_run_pyscript_with_nonexist_file(base_app) -> None:
- python_script = 'does_not_exist.py'
+ python_script = "does_not_exist.py"
_out, err = run_cmd(base_app, f"run_pyscript {python_script}")
assert "Error reading script file" in err[0]
assert base_app.last_result is False
-def test_run_pyscript_with_non_python_file(base_app, request) -> None:
- m = mock.MagicMock(name='input', return_value='2')
- builtins.input = m
+def test_run_pyscript_with_non_python_file(base_app, request, monkeypatch) -> None:
+ # Mock out the read_input call so we don't actually wait for a user's response on stdin
+ read_input_mock = mock.MagicMock(name="read_input", return_value="2")
+ monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
test_dir = os.path.dirname(request.module.__file__)
- filename = os.path.join(test_dir, 'scripts', 'help.txt')
- _out, err = run_cmd(base_app, f'run_pyscript {filename}')
+ filename = os.path.join(test_dir, "scripts", "help.txt")
+ _out, err = run_cmd(base_app, f"run_pyscript {filename}")
assert "does not have a .py extension" in err[0]
assert base_app.last_result is False
-@pytest.mark.parametrize('python_script', odd_file_names)
-def test_run_pyscript_with_odd_file_names(base_app, python_script) -> None:
+@pytest.mark.parametrize("python_script", odd_file_names)
+def test_run_pyscript_with_odd_file_names(base_app, python_script, monkeypatch) -> None:
"""Pass in file names with various patterns. Since these files don't exist, we will rely
on the error text to make sure the file names were processed correctly.
"""
- # Mock input to get us passed the warning about not ending in .py
- input_mock = mock.MagicMock(name='input', return_value='1')
- builtins.input = input_mock
+ # Mock read_input to get us passed the warning about not ending in .py
+ read_input_mock = mock.MagicMock(name="read_input", return_value="1")
+ monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
_out, err = run_cmd(base_app, f"run_pyscript {quote(python_script)}")
- err = ''.join(err)
+ err = "".join(err)
assert f"Error reading script file '{python_script}'" in err
assert base_app.last_result is False
def test_run_pyscript_with_exception(base_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
- python_script = os.path.join(test_dir, 'pyscript', 'raises_exception.py')
+ python_script = os.path.join(test_dir, "pyscript", "raises_exception.py")
_out, err = run_cmd(base_app, f"run_pyscript {python_script}")
- assert err[0].startswith('Traceback')
+ assert err[0].startswith("Traceback")
assert "TypeError: unsupported operand type(s) for +: 'int' and 'str'" in err[-1]
assert base_app.last_result is True
@@ -86,17 +86,17 @@ def test_run_pyscript_requires_an_argument(base_app) -> None:
def test_run_pyscript_help(base_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
- python_script = os.path.join(test_dir, 'pyscript', 'help.py')
- out1, _err1 = run_cmd(base_app, 'help')
- out2, _err2 = run_cmd(base_app, f'run_pyscript {python_script}')
+ python_script = os.path.join(test_dir, "pyscript", "help.py")
+ out1, _err1 = run_cmd(base_app, "help")
+ out2, _err2 = run_cmd(base_app, f"run_pyscript {python_script}")
assert out1
assert out1 == out2
def test_scripts_add_to_history(base_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
- python_script = os.path.join(test_dir, 'pyscript', 'help.py')
- command = f'run_pyscript {python_script}'
+ python_script = os.path.join(test_dir, "pyscript", "help.py")
+ command = f"run_pyscript {python_script}"
# Add to history
base_app.scripts_add_to_history = True
@@ -104,7 +104,7 @@ def test_scripts_add_to_history(base_app, request) -> None:
run_cmd(base_app, command)
assert len(base_app.history) == 2
assert base_app.history.get(1).raw == command
- assert base_app.history.get(2).raw == 'help'
+ assert base_app.history.get(2).raw == "help"
# Do not add to history
base_app.scripts_add_to_history = False
@@ -116,17 +116,17 @@ def test_scripts_add_to_history(base_app, request) -> None:
def test_run_pyscript_dir(base_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
- python_script = os.path.join(test_dir, 'pyscript', 'pyscript_dir.py')
+ python_script = os.path.join(test_dir, "pyscript", "pyscript_dir.py")
- out, _err = run_cmd(base_app, f'run_pyscript {python_script}')
+ out, _err = run_cmd(base_app, f"run_pyscript {python_script}")
assert out[0] == "['cmd_echo']"
def test_run_pyscript_capture(base_app, request) -> None:
base_app.self_in_py = True
test_dir = os.path.dirname(request.module.__file__)
- python_script = os.path.join(test_dir, 'pyscript', 'stdout_capture.py')
- out, _err = run_cmd(base_app, f'run_pyscript {python_script}')
+ python_script = os.path.join(test_dir, "pyscript", "stdout_capture.py")
+ out, _err = run_cmd(base_app, f"run_pyscript {python_script}")
assert out[0] == "print"
assert out[1] == "poutput"
@@ -140,8 +140,8 @@ def test_run_pyscript_capture_custom_stdout(base_app, request) -> None:
base_app.self_in_py = True
test_dir = os.path.dirname(request.module.__file__)
- python_script = os.path.join(test_dir, 'pyscript', 'stdout_capture.py')
- out, _err = run_cmd(base_app, f'run_pyscript {python_script}')
+ python_script = os.path.join(test_dir, "pyscript", "stdout_capture.py")
+ out, _err = run_cmd(base_app, f"run_pyscript {python_script}")
assert "print" not in out
assert out[0] == "poutput"
@@ -152,64 +152,64 @@ def test_run_pyscript_stop(base_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
# help.py doesn't run any commands that return True for stop
- python_script = os.path.join(test_dir, 'pyscript', 'help.py')
- stop = base_app.onecmd_plus_hooks(f'run_pyscript {python_script}')
+ python_script = os.path.join(test_dir, "pyscript", "help.py")
+ stop = base_app.onecmd_plus_hooks(f"run_pyscript {python_script}")
assert not stop
# stop.py runs the quit command which does return True for stop
- python_script = os.path.join(test_dir, 'pyscript', 'stop.py')
- stop = base_app.onecmd_plus_hooks(f'run_pyscript {python_script}')
+ python_script = os.path.join(test_dir, "pyscript", "stop.py")
+ stop = base_app.onecmd_plus_hooks(f"run_pyscript {python_script}")
assert stop
def test_run_pyscript_environment(base_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
- python_script = os.path.join(test_dir, 'pyscript', 'environment.py')
- out, _err = run_cmd(base_app, f'run_pyscript {python_script}')
+ python_script = os.path.join(test_dir, "pyscript", "environment.py")
+ out, _err = run_cmd(base_app, f"run_pyscript {python_script}")
assert out[0] == "PASSED"
def test_run_pyscript_self_in_py(base_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
- python_script = os.path.join(test_dir, 'pyscript', 'self_in_py.py')
+ python_script = os.path.join(test_dir, "pyscript", "self_in_py.py")
# Set self_in_py to True and make sure we see self
base_app.self_in_py = True
- out, _err = run_cmd(base_app, f'run_pyscript {python_script}')
- assert 'I see self' in out[0]
+ out, _err = run_cmd(base_app, f"run_pyscript {python_script}")
+ assert "I see self" in out[0]
# Set self_in_py to False and make sure we can't see self
base_app.self_in_py = False
- out, _err = run_cmd(base_app, f'run_pyscript {python_script}')
- assert 'I do not see self' in out[0]
+ out, _err = run_cmd(base_app, f"run_pyscript {python_script}")
+ assert "I do not see self" in out[0]
def test_run_pyscript_py_locals(base_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
- python_script = os.path.join(test_dir, 'pyscript', 'py_locals.py')
+ python_script = os.path.join(test_dir, "pyscript", "py_locals.py")
# Make sure pyscripts can't edit Cmd.py_locals. It used to be that cmd2 was passing its py_locals
# dictionary to the py environment instead of a shallow copy.
- base_app.py_locals['test_var'] = 5
+ base_app.py_locals["test_var"] = 5
# Place an editable object in py_locals. Since we make a shallow copy of py_locals,
# this object should be editable from the py environment.
- base_app.py_locals['my_list'] = []
+ base_app.py_locals["my_list"] = []
- run_cmd(base_app, f'run_pyscript {python_script}')
+ run_cmd(base_app, f"run_pyscript {python_script}")
# test_var should still exist
- assert base_app.py_locals['test_var'] == 5
+ assert base_app.py_locals["test_var"] == 5
# my_list should be edited
- assert base_app.py_locals['my_list'][0] == 2
+ assert base_app.py_locals["my_list"][0] == 2
def test_run_pyscript_app_echo(base_app, request) -> None:
test_dir = os.path.dirname(request.module.__file__)
- python_script = os.path.join(test_dir, 'pyscript', 'echo.py')
- out, _err = run_cmd(base_app, f'run_pyscript {python_script}')
+ python_script = os.path.join(test_dir, "pyscript", "echo.py")
+ out, _err = run_cmd(base_app, f"run_pyscript {python_script}")
# Only the edit help text should have been echoed to pytest's stdout
assert out[0] == "Usage: edit [-h] [file_path]"
diff --git a/tests/test_string_utils.py b/tests/test_string_utils.py
index 7e1aa5f78..ae0a8cd72 100644
--- a/tests/test_string_utils.py
+++ b/tests/test_string_utils.py
@@ -6,28 +6,28 @@
from cmd2 import rich_utils as ru
from cmd2 import string_utils as su
-HELLO_WORLD = 'Hello, world!'
+HELLO_WORLD = "Hello, world!"
def test_align_blank() -> None:
- text = ''
- character = '-'
+ text = ""
+ character = "-"
width = 5
aligned = su.align(text, "left", width=width, character=character)
assert aligned == character * width
def test_align_wider_than_width() -> None:
- text = 'long text field'
- character = '-'
+ text = "long text field"
+ character = "-"
width = 8
aligned = su.align(text, "left", width=width, character=character)
assert aligned == text[:width]
def test_align_term_width() -> None:
- text = 'foo'
- character = ' '
+ text = "foo"
+ character = " "
term_width = ru.console_width()
expected_padding = (term_width - su.str_width(text)) * character
@@ -37,25 +37,25 @@ def test_align_term_width() -> None:
def test_align_left() -> None:
- text = 'foo'
- character = '-'
+ text = "foo"
+ character = "-"
width = 5
aligned = su.align_left(text, width=width, character=character)
assert aligned == text + character * 2
def test_align_left_wide_text() -> None:
- text = '苹'
- character = '-'
+ text = "苹"
+ character = "-"
width = 4
aligned = su.align_left(text, width=width, character=character)
assert aligned == text + character * 2
def test_align_left_with_style() -> None:
- character = '-'
+ character = "-"
- styled_text = su.stylize('table', style=Color.BRIGHT_BLUE)
+ styled_text = su.stylize("table", style=Color.BRIGHT_BLUE)
width = 8
aligned = su.align_left(styled_text, width=width, character=character)
@@ -63,25 +63,25 @@ def test_align_left_with_style() -> None:
def test_align_center() -> None:
- text = 'foo'
- character = '-'
+ text = "foo"
+ character = "-"
width = 5
aligned = su.align_center(text, width=width, character=character)
assert aligned == character + text + character
def test_align_center_wide_text() -> None:
- text = '苹'
- character = '-'
+ text = "苹"
+ character = "-"
width = 4
aligned = su.align_center(text, width=width, character=character)
assert aligned == character + text + character
def test_align_center_with_style() -> None:
- character = '-'
+ character = "-"
- styled_text = su.stylize('table', style=Color.BRIGHT_BLUE)
+ styled_text = su.stylize("table", style=Color.BRIGHT_BLUE)
width = 8
aligned = su.align_center(styled_text, width=width, character=character)
@@ -89,25 +89,25 @@ def test_align_center_with_style() -> None:
def test_align_right() -> None:
- text = 'foo'
- character = '-'
+ text = "foo"
+ character = "-"
width = 5
aligned = su.align_right(text, width=width, character=character)
assert aligned == character * 2 + text
def test_align_right_wide_text() -> None:
- text = '苹'
- character = '-'
+ text = "苹"
+ character = "-"
width = 4
aligned = su.align_right(text, width=width, character=character)
assert aligned == character * 2 + text
def test_align_right_with_style() -> None:
- character = '-'
+ character = "-"
- styled_text = su.stylize('table', style=Color.BRIGHT_BLUE)
+ styled_text = su.stylize("table", style=Color.BRIGHT_BLUE)
width = 8
aligned = su.align_right(styled_text, width=width, character=character)
@@ -126,11 +126,44 @@ def test_stylize() -> None:
assert restyled_string == "\x1b[1;4;9;32;44mHello, world!\x1b[0m"
-def test_strip_style() -> None:
- base_str = HELLO_WORLD
- styled_str = su.stylize(base_str, style=Color.GREEN)
- assert base_str != styled_str
- assert base_str == su.strip_style(styled_str)
+def test_strip_basic_styles() -> None:
+ # Test bold, colors, and resets
+ assert su.strip_style("\x1b[1mBold\x1b[0m") == "Bold"
+ assert su.strip_style("\x1b[31mRed\x1b[0m") == "Red"
+ assert su.strip_style("\x1b[4;32mUnderline Green\x1b[0m") == "Underline Green"
+
+
+def test_strip_complex_colors() -> None:
+ # Test 256-color and RGB (TrueColor) sequences
+ # These use semicolons as separators and end in 'm'
+ assert su.strip_style("\x1b[38;5;208mOrange\x1b[0m") == "Orange"
+ assert su.strip_style("\x1b[38;2;255;87;51mCustom RGB\x1b[0m") == "Custom RGB"
+
+
+def test_preserve_non_style_csi() -> None:
+ # Test that cursor movements and other CSI codes are not stripped
+ # Cursor Up (\x1b[A) and Cursor Position (\x1b[10;5H)
+ cursor_up = "\x1b[A"
+ cursor_pos = "\x1b[10;5H"
+ assert su.strip_style(cursor_up) == cursor_up
+ assert su.strip_style(cursor_pos) == cursor_pos
+
+
+def test_preserve_private_modes() -> None:
+ # Test that DEC Private Modes (containing '?') are not stripped
+ # Hide cursor (\x1b[?25l) and Show cursor (\x1b[?25h)
+ hide_cursor = "\x1b[?25l"
+ show_cursor = "\x1b[?25h"
+ assert su.strip_style(hide_cursor) == hide_cursor
+ assert su.strip_style(show_cursor) == show_cursor
+
+
+def test_mixed_content() -> None:
+ # Test a string that has both style and control sequences
+ # Red text + Hide Cursor
+ mixed = "\x1b[31mRed Text\x1b[0m\x1b[?25l"
+ # Only the red style should be removed
+ assert su.strip_style(mixed) == "Red Text\x1b[?25l"
def test_str_width() -> None:
@@ -142,7 +175,7 @@ def test_str_width() -> None:
def test_is_quoted_short() -> None:
- my_str = ''
+ my_str = ""
assert not su.is_quoted(my_str)
your_str = '"'
assert not su.is_quoted(your_str)
@@ -202,14 +235,40 @@ def test_strip_quotes_with_quotes() -> None:
def test_unicode_normalization() -> None:
- s1 = 'café'
- s2 = 'cafe\u0301'
+ s1 = "café"
+ s2 = "cafe\u0301"
assert s1 != s2
assert su.norm_fold(s1) == su.norm_fold(s2)
def test_unicode_casefold() -> None:
- micro = 'µ'
+ micro = "µ"
micro_cf = micro.casefold()
assert micro != micro_cf
assert su.norm_fold(micro) == su.norm_fold(micro_cf)
+
+
+def test_common_prefix() -> None:
+ # Empty list
+ assert su.common_prefix([]) == ""
+
+ # Single item
+ assert su.common_prefix(["abc"]) == "abc"
+
+ # Common prefix exists
+ assert su.common_prefix(["abcdef", "abcde", "abcd"]) == "abcd"
+
+ # No common prefix
+ assert su.common_prefix(["abc", "def"]) == ""
+
+ # One is a prefix of another
+ assert su.common_prefix(["apple", "app"]) == "app"
+
+ # Identical strings
+ assert su.common_prefix(["test", "test"]) == "test"
+
+ # Case sensitivity (matches os.path.commonprefix behavior)
+ assert su.common_prefix(["Apple", "apple"]) == ""
+
+ # Empty string in list
+ assert su.common_prefix(["abc", ""]) == ""
diff --git a/tests/test_terminal_utils.py b/tests/test_terminal_utils.py
deleted file mode 100644
index c7d8a22f3..000000000
--- a/tests/test_terminal_utils.py
+++ /dev/null
@@ -1,81 +0,0 @@
-"""Unit testing for cmd2/terminal_utils.py module"""
-
-import pytest
-
-from cmd2 import (
- Color,
-)
-from cmd2 import string_utils as su
-from cmd2 import terminal_utils as tu
-
-
-def test_set_title() -> None:
- title = "Hello, world!"
- assert tu.set_title_str(title) == tu.OSC + '2;' + title + tu.BEL
-
-
-@pytest.mark.parametrize(
- ('cols', 'prompt', 'line', 'cursor', 'msg', 'expected'),
- [
- (
- 127,
- '(Cmd) ',
- 'help his',
- 12,
- su.stylize('Hello World!', style=Color.MAGENTA),
- '\x1b[2K\r\x1b[35mHello World!\x1b[0m',
- ),
- (127, '\n(Cmd) ', 'help ', 5, 'foo', '\x1b[2K\x1b[1A\x1b[2K\rfoo'),
- (
- 10,
- '(Cmd) ',
- 'help history of the american republic',
- 4,
- 'boo',
- '\x1b[3B\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\rboo',
- ),
- ],
-)
-def test_async_alert_str(cols, prompt, line, cursor, msg, expected) -> None:
- alert_str = tu.async_alert_str(terminal_columns=cols, prompt=prompt, line=line, cursor_offset=cursor, alert_msg=msg)
- assert alert_str == expected
-
-
-def test_clear_screen() -> None:
- clear_type = 2
- assert tu.clear_screen_str(clear_type) == f"{tu.CSI}{clear_type}J"
-
- clear_type = -1
- expected_err = "clear_type must in an integer from 0 to 3"
- with pytest.raises(ValueError, match=expected_err):
- tu.clear_screen_str(clear_type)
-
- clear_type = 4
- with pytest.raises(ValueError, match=expected_err):
- tu.clear_screen_str(clear_type)
-
-
-def test_clear_line() -> None:
- clear_type = 2
- assert tu.clear_line_str(clear_type) == f"{tu.CSI}{clear_type}K"
-
- clear_type = -1
- expected_err = "clear_type must in an integer from 0 to 2"
- with pytest.raises(ValueError, match=expected_err):
- tu.clear_line_str(clear_type)
-
- clear_type = 3
- with pytest.raises(ValueError, match=expected_err):
- tu.clear_line_str(clear_type)
-
-
-def test_cursor() -> None:
- count = 1
- assert tu.Cursor.UP(count) == f"{tu.CSI}{count}A"
- assert tu.Cursor.DOWN(count) == f"{tu.CSI}{count}B"
- assert tu.Cursor.FORWARD(count) == f"{tu.CSI}{count}C"
- assert tu.Cursor.BACK(count) == f"{tu.CSI}{count}D"
-
- x = 4
- y = 5
- assert tu.Cursor.SET_POS(x, y) == f"{tu.CSI}{y};{x}H"
diff --git a/tests/test_transcript.py b/tests/test_transcript.py
deleted file mode 100644
index dc4f91f9d..000000000
--- a/tests/test_transcript.py
+++ /dev/null
@@ -1,328 +0,0 @@
-"""Cmd2 functional testing based on transcript"""
-
-import os
-import random
-import re
-import sys
-import tempfile
-from typing import NoReturn
-from unittest import (
- mock,
-)
-
-import pytest
-
-import cmd2
-from cmd2 import (
- transcript,
-)
-from cmd2.utils import (
- Settable,
- StdSim,
-)
-
-from .conftest import (
- run_cmd,
- verify_help_text,
-)
-
-
-class CmdLineApp(cmd2.Cmd):
- MUMBLES = ('like', '...', 'um', 'er', 'hmmm', 'ahh')
- MUMBLE_FIRST = ('so', 'like', 'well')
- MUMBLE_LAST = ('right?',)
-
- def __init__(self, *args, **kwargs) -> None:
- self.maxrepeats = 3
-
- super().__init__(*args, multiline_commands=['orate'], **kwargs)
-
- # Make maxrepeats settable at runtime
- self.add_settable(Settable('maxrepeats', int, 'Max number of `--repeat`s allowed', self))
-
- self.intro = 'This is an intro banner ...'
-
- speak_parser = cmd2.Cmd2ArgumentParser()
- speak_parser.add_argument('-p', '--piglatin', action="store_true", help="atinLay")
- speak_parser.add_argument('-s', '--shout', action="store_true", help="N00B EMULATION MODE")
- speak_parser.add_argument('-r', '--repeat', type=int, help="output [n] times")
-
- @cmd2.with_argparser(speak_parser, with_unknown_args=True)
- def do_speak(self, opts, arg) -> None:
- """Repeats what you tell me to."""
- arg = ' '.join(arg)
- if opts.piglatin:
- arg = f'{arg[1:]}{arg[0]}ay'
- if opts.shout:
- arg = arg.upper()
- repetitions = opts.repeat or 1
- for _ in range(min(repetitions, self.maxrepeats)):
- self.poutput(arg)
- # recommend using the poutput function instead of
- # self.stdout.write or "print", because Cmd allows the user
- # to redirect output
-
- do_say = do_speak # now "say" is a synonym for "speak"
- do_orate = do_speak # another synonym, but this one takes multi-line input
-
- mumble_parser = cmd2.Cmd2ArgumentParser()
- mumble_parser.add_argument('-r', '--repeat', type=int, help="output [n] times")
-
- @cmd2.with_argparser(mumble_parser, with_unknown_args=True)
- def do_mumble(self, opts, arg) -> None:
- """Mumbles what you tell me to."""
- repetitions = opts.repeat or 1
- for _ in range(min(repetitions, self.maxrepeats)):
- output = []
- if random.random() < 0.33:
- output.append(random.choice(self.MUMBLE_FIRST))
- for word in arg:
- if random.random() < 0.40:
- output.append(random.choice(self.MUMBLES))
- output.append(word)
- if random.random() < 0.25:
- output.append(random.choice(self.MUMBLE_LAST))
- self.poutput(' '.join(output))
-
- def do_nothing(self, statement) -> None:
- """Do nothing and output nothing"""
-
- def do_keyboard_interrupt(self, _) -> NoReturn:
- raise KeyboardInterrupt('Interrupting this command')
-
-
-def test_commands_at_invocation() -> None:
- testargs = ["prog", "say hello", "say Gracie", "quit"]
- expected = "This is an intro banner ...\nhello\nGracie\n"
- with mock.patch.object(sys, 'argv', testargs):
- app = CmdLineApp()
-
- app.stdout = StdSim(app.stdout)
- app.cmdloop()
- out = app.stdout.getvalue()
- assert out == expected
-
-
-@pytest.mark.parametrize(
- ('filename', 'feedback_to_output'),
- [
- ('bol_eol.txt', False),
- ('characterclass.txt', False),
- ('dotstar.txt', False),
- ('extension_notation.txt', False),
- ('from_cmdloop.txt', True),
- ('multiline_no_regex.txt', False),
- ('multiline_regex.txt', False),
- ('no_output.txt', False),
- ('no_output_last.txt', False),
- ('singleslash.txt', False),
- ('slashes_escaped.txt', False),
- ('slashslash.txt', False),
- ('spaces.txt', False),
- ('word_boundaries.txt', False),
- ],
-)
-def test_transcript(request, capsys, filename, feedback_to_output) -> None:
- # Get location of the transcript
- test_dir = os.path.dirname(request.module.__file__)
- transcript_file = os.path.join(test_dir, 'transcripts', filename)
-
- # Need to patch sys.argv so cmd2 doesn't think it was called with
- # arguments equal to the py.test args
- testargs = ['prog', '-t', transcript_file]
- with mock.patch.object(sys, 'argv', testargs):
- # Create a cmd2.Cmd() instance and make sure basic settings are
- # like we want for test
- app = CmdLineApp()
-
- app.feedback_to_output = feedback_to_output
-
- # Run the command loop
- sys_exit_code = app.cmdloop()
- assert sys_exit_code == 0
-
- # Check for the unittest "OK" condition for the 1 test which ran
- expected_start = ".\n----------------------------------------------------------------------\nRan 1 test in"
- expected_end = "s\n\nOK\n"
- _, err = capsys.readouterr()
- assert err.startswith(expected_start)
- assert err.endswith(expected_end)
-
-
-def test_history_transcript() -> None:
- app = CmdLineApp()
- app.stdout = StdSim(app.stdout)
- run_cmd(app, 'orate this is\na /multiline/\ncommand;\n')
- run_cmd(app, 'speak /tmp/file.txt is not a regex')
-
- expected = r"""(Cmd) orate this is
-> a /multiline/
-> command;
-this is a \/multiline\/ command
-(Cmd) speak /tmp/file.txt is not a regex
-\/tmp\/file.txt is not a regex
-"""
-
- # make a tmp file
- fd, history_fname = tempfile.mkstemp(prefix='', suffix='.txt')
- os.close(fd)
-
- # tell the history command to create a transcript
- run_cmd(app, f'history -t "{history_fname}"')
-
- # read in the transcript created by the history command
- with open(history_fname) as f:
- xscript = f.read()
-
- assert xscript == expected
-
-
-def test_history_transcript_bad_path(mocker) -> None:
- app = CmdLineApp()
- app.stdout = StdSim(app.stdout)
- run_cmd(app, 'orate this is\na /multiline/\ncommand;\n')
- run_cmd(app, 'speak /tmp/file.txt is not a regex')
-
- # Bad directory
- history_fname = '~/fakedir/this_does_not_exist.txt'
- _out, err = run_cmd(app, f'history -t "{history_fname}"')
- assert "is not a directory" in err[0]
-
- # Cause os.open to fail and make sure error gets printed
- mock_remove = mocker.patch('builtins.open')
- mock_remove.side_effect = OSError
-
- history_fname = 'outfile.txt'
- _out, err = run_cmd(app, f'history -t "{history_fname}"')
- assert "Error saving transcript file" in err[0]
-
-
-def test_run_script_record_transcript(base_app, request) -> None:
- test_dir = os.path.dirname(request.module.__file__)
- filename = os.path.join(test_dir, 'scripts', 'help.txt')
-
- assert base_app._script_dir == []
- assert base_app._current_script_dir is None
-
- # make a tmp file to use as a transcript
- fd, transcript_fname = tempfile.mkstemp(prefix='', suffix='.trn')
- os.close(fd)
-
- # Execute the run_script command with the -t option to generate a transcript
- run_cmd(base_app, f'run_script {filename} -t {transcript_fname}')
-
- assert base_app._script_dir == []
- assert base_app._current_script_dir is None
-
- # read in the transcript created by the history command
- with open(transcript_fname) as f:
- xscript = f.read()
-
- assert xscript.startswith('(Cmd) help -v\n')
- verify_help_text(base_app, xscript)
-
-
-def test_generate_transcript_stop(capsys) -> None:
- # Verify transcript generation stops when a command returns True for stop
- app = CmdLineApp()
-
- # Make a tmp file to use as a transcript
- fd, transcript_fname = tempfile.mkstemp(prefix='', suffix='.trn')
- os.close(fd)
-
- # This should run all commands
- commands = ['help', 'set']
- app._generate_transcript(commands, transcript_fname)
- _, err = capsys.readouterr()
- assert err.startswith("2 commands")
-
- # Since quit returns True for stop, only the first 2 commands will run
- commands = ['help', 'quit', 'set']
- app._generate_transcript(commands, transcript_fname)
- _, err = capsys.readouterr()
- assert err.startswith("Command 2 triggered a stop")
-
- # keyboard_interrupt command should stop the loop and not run the third command
- commands = ['help', 'keyboard_interrupt', 'set']
- app._generate_transcript(commands, transcript_fname)
- _, err = capsys.readouterr()
- assert err.startswith("Interrupting this command\nCommand 2 triggered a stop")
-
-
-@pytest.mark.parametrize(
- ('expected', 'transformed'),
- [
- # strings with zero or one slash or with escaped slashes means no regular
- # expression present, so the result should just be what re.escape returns.
- # we don't use static strings in these tests because re.escape behaves
- # differently in python 3.7+ than in prior versions
- ('text with no slashes', re.escape('text with no slashes')),
- ('specials .*', re.escape('specials .*')),
- ('use 2/3 cup', re.escape('use 2/3 cup')),
- ('/tmp is nice', re.escape('/tmp is nice')),
- ('slash at end/', re.escape('slash at end/')),
- # escaped slashes
- (r'not this slash\/ or this one\/', re.escape('not this slash/ or this one/')),
- # regexes
- ('/.*/', '.*'),
- ('specials ^ and + /[0-9]+/', re.escape('specials ^ and + ') + '[0-9]+'),
- (r'/a{6}/ but not \/a{6} with /.*?/ more', 'a{6}' + re.escape(' but not /a{6} with ') + '.*?' + re.escape(' more')),
- (r'not \/, use /\|?/, not \/', re.escape('not /, use ') + r'\|?' + re.escape(', not /')),
- # inception: slashes in our regex. backslashed on input, bare on output
- (r'not \/, use /\/?/, not \/', re.escape('not /, use ') + '/?' + re.escape(', not /')),
- (r'lots /\/?/ more /.*/ stuff', re.escape('lots ') + '/?' + re.escape(' more ') + '.*' + re.escape(' stuff')),
- ],
-)
-def test_parse_transcript_expected(expected, transformed) -> None:
- app = CmdLineApp()
-
- class TestMyAppCase(transcript.Cmd2TestCase):
- cmdapp = app
-
- testcase = TestMyAppCase()
- assert testcase._transform_transcript_expected(expected) == transformed
-
-
-def test_transcript_failure(request, capsys) -> None:
- # Get location of the transcript
- test_dir = os.path.dirname(request.module.__file__)
- transcript_file = os.path.join(test_dir, 'transcripts', 'failure.txt')
-
- # Need to patch sys.argv so cmd2 doesn't think it was called with
- # arguments equal to the py.test args
- testargs = ['prog', '-t', transcript_file]
- with mock.patch.object(sys, 'argv', testargs):
- # Create a cmd2.Cmd() instance and make sure basic settings are
- # like we want for test
- app = CmdLineApp()
-
- app.feedback_to_output = False
-
- # Run the command loop
- sys_exit_code = app.cmdloop()
- assert sys_exit_code != 0
-
- expected_start = "File "
- expected_end = "s\n\nFAILED (failures=1)\n\n"
- _, err = capsys.readouterr()
- assert err.startswith(expected_start)
- assert err.endswith(expected_end)
-
-
-def test_transcript_no_file(request, capsys) -> None:
- # Need to patch sys.argv so cmd2 doesn't think it was called with
- # arguments equal to the py.test args
- testargs = ['prog', '-t']
- with mock.patch.object(sys, 'argv', testargs):
- app = CmdLineApp()
-
- app.feedback_to_output = False
-
- # Run the command loop
- sys_exit_code = app.cmdloop()
- assert sys_exit_code != 0
-
- # Check for the unittest "OK" condition for the 1 test which ran
- expected = 'No test files found - nothing to test\n'
- _, err = capsys.readouterr()
- assert err == expected
diff --git a/tests/test_utils.py b/tests/test_utils.py
index a5a83ba13..bcdc6a98e 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -12,7 +12,7 @@
import cmd2.utils as cu
-HELLO_WORLD = 'Hello, world!'
+HELLO_WORLD = "Hello, world!"
def test_remove_duplicates_no_duplicates() -> None:
@@ -26,37 +26,37 @@ def test_remove_duplicates_with_duplicates() -> None:
def test_alphabetical_sort() -> None:
- my_list = ['café', 'µ', 'A', 'micro', 'unity', 'cafeteria']
- assert cu.alphabetical_sort(my_list) == ['A', 'cafeteria', 'café', 'micro', 'unity', 'µ']
- my_list = ['a3', 'a22', 'A2', 'A11', 'a1']
- assert cu.alphabetical_sort(my_list) == ['a1', 'A11', 'A2', 'a22', 'a3']
+ my_list = ["café", "µ", "A", "micro", "unity", "cafeteria"]
+ assert cu.alphabetical_sort(my_list) == ["A", "cafeteria", "café", "micro", "unity", "µ"]
+ my_list = ["a3", "a22", "A2", "A11", "a1"]
+ assert cu.alphabetical_sort(my_list) == ["a1", "A11", "A2", "a22", "a3"]
def test_try_int_or_force_to_lower_case() -> None:
- str1 = '17'
+ str1 = "17"
assert cu.try_int_or_force_to_lower_case(str1) == 17
- str1 = 'ABC'
- assert cu.try_int_or_force_to_lower_case(str1) == 'abc'
- str1 = 'X19'
- assert cu.try_int_or_force_to_lower_case(str1) == 'x19'
- str1 = ''
- assert cu.try_int_or_force_to_lower_case(str1) == ''
+ str1 = "ABC"
+ assert cu.try_int_or_force_to_lower_case(str1) == "abc"
+ str1 = "X19"
+ assert cu.try_int_or_force_to_lower_case(str1) == "x19"
+ str1 = ""
+ assert cu.try_int_or_force_to_lower_case(str1) == ""
def test_natural_keys() -> None:
- my_list = ['café', 'µ', 'A', 'micro', 'unity', 'x1', 'X2', 'X11', 'X0', 'x22']
+ my_list = ["café", "µ", "A", "micro", "unity", "x1", "X2", "X11", "X0", "x22"]
my_list.sort(key=cu.natural_keys)
- assert my_list == ['A', 'café', 'micro', 'unity', 'X0', 'x1', 'X2', 'X11', 'x22', 'µ']
- my_list = ['a3', 'a22', 'A2', 'A11', 'a1']
+ assert my_list == ["A", "café", "micro", "unity", "X0", "x1", "X2", "X11", "x22", "µ"]
+ my_list = ["a3", "a22", "A2", "A11", "a1"]
my_list.sort(key=cu.natural_keys)
- assert my_list == ['a1', 'A2', 'a3', 'A11', 'a22']
+ assert my_list == ["a1", "A2", "a3", "A11", "a22"]
def test_natural_sort() -> None:
- my_list = ['café', 'µ', 'A', 'micro', 'unity', 'x1', 'X2', 'X11', 'X0', 'x22']
- assert cu.natural_sort(my_list) == ['A', 'café', 'micro', 'unity', 'X0', 'x1', 'X2', 'X11', 'x22', 'µ']
- my_list = ['a3', 'a22', 'A2', 'A11', 'a1']
- assert cu.natural_sort(my_list) == ['a1', 'A2', 'a3', 'A11', 'a22']
+ my_list = ["café", "µ", "A", "micro", "unity", "x1", "X2", "X11", "X0", "x22"]
+ assert cu.natural_sort(my_list) == ["A", "café", "micro", "unity", "X0", "x1", "X2", "X11", "x22", "µ"]
+ my_list = ["a3", "a22", "A2", "A11", "a1"]
+ assert cu.natural_sort(my_list) == ["a1", "A2", "a3", "A11", "a22"]
@pytest.fixture
@@ -65,38 +65,38 @@ def stdout_sim():
def test_stdsim_write_str(stdout_sim) -> None:
- my_str = 'Hello World'
+ my_str = "Hello World"
stdout_sim.write(my_str)
assert stdout_sim.getvalue() == my_str
def test_stdsim_write_bytes(stdout_sim) -> None:
- b_str = b'Hello World'
+ b_str = b"Hello World"
with pytest.raises(TypeError):
stdout_sim.write(b_str)
def test_stdsim_buffer_write_bytes(stdout_sim) -> None:
- b_str = b'Hello World'
+ b_str = b"Hello World"
stdout_sim.buffer.write(b_str)
assert stdout_sim.getvalue() == b_str.decode()
assert stdout_sim.getbytes() == b_str
def test_stdsim_buffer_write_str(stdout_sim) -> None:
- my_str = 'Hello World'
+ my_str = "Hello World"
with pytest.raises(TypeError):
stdout_sim.buffer.write(my_str)
def test_stdsim_read(stdout_sim) -> None:
- my_str = 'Hello World'
+ my_str = "Hello World"
stdout_sim.write(my_str)
# getvalue() returns the value and leaves it unaffected internally
assert stdout_sim.getvalue() == my_str
# read() returns the value and then clears the internal buffer
assert stdout_sim.read() == my_str
- assert stdout_sim.getvalue() == ''
+ assert stdout_sim.getvalue() == ""
stdout_sim.write(my_str)
@@ -106,26 +106,26 @@ def test_stdsim_read(stdout_sim) -> None:
def test_stdsim_read_bytes(stdout_sim) -> None:
- b_str = b'Hello World'
+ b_str = b"Hello World"
stdout_sim.buffer.write(b_str)
# getbytes() returns the value and leaves it unaffected internally
assert stdout_sim.getbytes() == b_str
# read_bytes() returns the value and then clears the internal buffer
assert stdout_sim.readbytes() == b_str
- assert stdout_sim.getbytes() == b''
+ assert stdout_sim.getbytes() == b""
def test_stdsim_clear(stdout_sim) -> None:
- my_str = 'Hello World'
+ my_str = "Hello World"
stdout_sim.write(my_str)
assert stdout_sim.getvalue() == my_str
stdout_sim.clear()
- assert stdout_sim.getvalue() == ''
+ assert stdout_sim.getvalue() == ""
def test_stdsim_getattr_exist(stdout_sim) -> None:
# Here the StdSim getattr is allowing us to access methods within StdSim
- my_str = 'Hello World'
+ my_str = "Hello World"
stdout_sim.write(my_str)
val_func = stdout_sim.getvalue
assert val_func() == my_str
@@ -138,7 +138,7 @@ def test_stdsim_getattr_noexist(stdout_sim) -> None:
def test_stdsim_pause_storage(stdout_sim) -> None:
# Test pausing storage for string data
- my_str = 'Hello World'
+ my_str = "Hello World"
stdout_sim.pause_storage = False
stdout_sim.write(my_str)
@@ -146,10 +146,10 @@ def test_stdsim_pause_storage(stdout_sim) -> None:
stdout_sim.pause_storage = True
stdout_sim.write(my_str)
- assert stdout_sim.read() == ''
+ assert stdout_sim.read() == ""
# Test pausing storage for binary data
- b_str = b'Hello World'
+ b_str = b"Hello World"
stdout_sim.pause_storage = False
stdout_sim.buffer.write(b_str)
@@ -157,7 +157,7 @@ def test_stdsim_pause_storage(stdout_sim) -> None:
stdout_sim.pause_storage = True
stdout_sim.buffer.write(b_str)
- assert stdout_sim.getbytes() == b''
+ assert stdout_sim.getbytes() == b""
def test_stdsim_line_buffering(base_app) -> None:
@@ -166,18 +166,18 @@ def test_stdsim_line_buffering(base_app) -> None:
import os
import tempfile
- with tempfile.NamedTemporaryFile(mode='wt') as file:
+ with tempfile.NamedTemporaryFile(mode="wt") as file:
file.line_buffering = True
stdsim = cu.StdSim(file, echo=True)
saved_size = os.path.getsize(file.name)
- bytes_to_write = b'hello\n'
+ bytes_to_write = b"hello\n"
stdsim.buffer.write(bytes_to_write)
assert os.path.getsize(file.name) == saved_size + len(bytes_to_write)
saved_size = os.path.getsize(file.name)
- bytes_to_write = b'hello\r'
+ bytes_to_write = b"hello\r"
stdsim.buffer.write(bytes_to_write)
assert os.path.getsize(file.name) == saved_size + len(bytes_to_write)
@@ -189,12 +189,12 @@ def pr_none():
# Start a long running process so we have time to run tests on it before it finishes
# Put the new process into a separate group so its signal are isolated from ours
kwargs = {}
- if sys.platform.startswith('win'):
- command = 'timeout -t 5 /nobreak'
- kwargs['creationflags'] = subprocess.CREATE_NEW_PROCESS_GROUP
+ if sys.platform.startswith("win"):
+ command = "timeout -t 5 /nobreak"
+ kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
else:
- command = 'sleep 5'
- kwargs['start_new_session'] = True
+ command = "sleep 5"
+ kwargs["start_new_session"] = True
proc = subprocess.Popen(command, shell=True, **kwargs)
return cu.ProcReader(proc, None, None)
@@ -207,7 +207,7 @@ def test_proc_reader_send_sigint(pr_none) -> None:
ret_code = pr_none._proc.poll()
# Make sure a SIGINT killed the process
- if sys.platform.startswith('win'):
+ if sys.platform.startswith("win"):
assert ret_code is not None
else:
assert ret_code == -signal.SIGINT
@@ -226,7 +226,7 @@ def test_proc_reader_terminate(pr_none) -> None:
assert wait_finish - wait_start < 3
ret_code = pr_none._proc.poll()
- if sys.platform.startswith('win'):
+ if sys.platform.startswith("win"):
assert ret_code is not None
else:
assert ret_code == -signal.SIGTERM
@@ -249,22 +249,22 @@ def test_context_flag_exit_err(context_flag) -> None:
def test_to_bool_str_true() -> None:
- assert cu.to_bool('true')
- assert cu.to_bool('True')
- assert cu.to_bool('TRUE')
- assert cu.to_bool('tRuE')
+ assert cu.to_bool("true")
+ assert cu.to_bool("True")
+ assert cu.to_bool("TRUE")
+ assert cu.to_bool("tRuE")
def test_to_bool_str_false() -> None:
- assert not cu.to_bool('false')
- assert not cu.to_bool('False')
- assert not cu.to_bool('FALSE')
- assert not cu.to_bool('fAlSe')
+ assert not cu.to_bool("false")
+ assert not cu.to_bool("False")
+ assert not cu.to_bool("FALSE")
+ assert not cu.to_bool("fAlSe")
def test_to_bool_str_invalid() -> None:
with pytest.raises(ValueError): # noqa: PT011
- cu.to_bool('other')
+ cu.to_bool("other")
def test_to_bool_bool() -> None:
@@ -286,8 +286,8 @@ def test_to_bool_float() -> None:
def test_find_editor_specified() -> None:
- expected_editor = os.path.join('fake_dir', 'editor')
- with mock.patch.dict(os.environ, {'EDITOR': expected_editor}):
+ expected_editor = os.path.join("fake_dir", "editor")
+ with mock.patch.dict(os.environ, {"EDITOR": expected_editor}):
editor = cu.find_editor()
assert editor == expected_editor
@@ -298,7 +298,7 @@ def test_find_editor_not_specified() -> None:
assert editor
# Overwrite path env setting with invalid path, clear all other env vars so no editor should be found.
- with mock.patch.dict(os.environ, {'PATH': 'fake_dir'}, clear=True):
+ with mock.patch.dict(os.environ, {"PATH": "fake_dir"}, clear=True):
editor = cu.find_editor()
assert editor is None
@@ -320,16 +320,16 @@ def test_similarity_without_good_canididates() -> None:
def test_similarity_overwrite_function() -> None:
options = ["history", "test"]
suggested_command = cu.suggest_similar("test", options)
- assert suggested_command == 'test'
+ assert suggested_command == "test"
def custom_similarity_function(s1, s2) -> float:
- return 1.0 if 'history' in (s1, s2) else 0.0
+ return 1.0 if "history" in (s1, s2) else 0.0
suggested_command = cu.suggest_similar("test", options, similarity_function_to_use=custom_similarity_function)
- assert suggested_command == 'history'
+ assert suggested_command == "history"
suggested_command = cu.suggest_similar("history", options, similarity_function_to_use=custom_similarity_function)
- assert suggested_command == 'history'
+ assert suggested_command == "history"
suggested_command = cu.suggest_similar("test", ["test"], similarity_function_to_use=custom_similarity_function)
assert suggested_command is None
@@ -357,7 +357,7 @@ def foo(x: int) -> str:
param_ann, ret_ann = cu.get_types(foo)
assert ret_ann is str
param_name, param_value = next(iter(param_ann.items()))
- assert param_name == 'x'
+ assert param_name == "x"
assert param_value is int
@@ -372,5 +372,5 @@ def bar(self, x: bool) -> None:
assert ret_ann is None
assert len(param_ann) == 1
param_name, param_value = next(iter(param_ann.items()))
- assert param_name == 'x'
+ assert param_name == "x"
assert param_value is bool
diff --git a/tests/transcripts/bol_eol.txt b/tests/transcripts/bol_eol.txt
deleted file mode 100644
index da21ac86f..000000000
--- a/tests/transcripts/bol_eol.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-# match the text with regular expressions and the newlines as literal text
-
-(Cmd) say -r 3 -s yabba dabba do
-/^Y.*?$/
-/^Y.*?$/
-/^Y.*?$/
diff --git a/tests/transcripts/characterclass.txt b/tests/transcripts/characterclass.txt
deleted file mode 100644
index 756044ea6..000000000
--- a/tests/transcripts/characterclass.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-# match using character classes and special sequence for digits (\d)
-
-(Cmd) say 555-1212
-/[0-9]{3}-[0-9]{4}/
-(Cmd) say 555-1212
-/\d{3}-\d{4}/
diff --git a/tests/transcripts/dotstar.txt b/tests/transcripts/dotstar.txt
deleted file mode 100644
index 55c15b759..000000000
--- a/tests/transcripts/dotstar.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-# ensure the old standby .* works. We use the non-greedy flavor
-
-(Cmd) say Adopt the pace of nature: her secret is patience.
-Adopt the pace of /.*?/ is patience.
diff --git a/tests/transcripts/extension_notation.txt b/tests/transcripts/extension_notation.txt
deleted file mode 100644
index 68e728ca3..000000000
--- a/tests/transcripts/extension_notation.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-# inception: a regular expression that matches itself
-
-(Cmd) say (?:fred)
-/(?:\(\?:fred\))/
diff --git a/tests/transcripts/failure.txt b/tests/transcripts/failure.txt
deleted file mode 100644
index 4ef56e722..000000000
--- a/tests/transcripts/failure.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-# This is an example of a transcript test which will fail
-
-(Cmd) say -r 3 -s yabba dabba do
-foo bar baz
diff --git a/tests/transcripts/from_cmdloop.txt b/tests/transcripts/from_cmdloop.txt
deleted file mode 100644
index da5363831..000000000
--- a/tests/transcripts/from_cmdloop.txt
+++ /dev/null
@@ -1,44 +0,0 @@
-# responses with trailing spaces have been matched with a regex
-# so you can see where they are.
-
-(Cmd) help say
-Usage: speak [-h] [-p] [-s] [-r REPEAT]/ */
-
-Repeats what you tell me to./ */
-
-Optional Arguments:/ */
- -h, --help show this help message and exit/ */
- -p, --piglatin atinLay/ */
- -s, --shout N00B EMULATION MODE/ */
- -r, --repeat REPEAT output [n] times/ */
-
-(Cmd) say goodnight, Gracie
-goodnight, Gracie
-(Cmd) say -ps --repeat=5 goodnight, Gracie
-OODNIGHT, GRACIEGAY
-OODNIGHT, GRACIEGAY
-OODNIGHT, GRACIEGAY
-(Cmd) set maxrepeats 5
-maxrepeats - was: 3
-now: 5
-(Cmd) say -ps --repeat=5 goodnight, Gracie
-OODNIGHT, GRACIEGAY
-OODNIGHT, GRACIEGAY
-OODNIGHT, GRACIEGAY
-OODNIGHT, GRACIEGAY
-OODNIGHT, GRACIEGAY
-(Cmd) history
- 1 help say
- 2 say goodnight, Gracie
- 3 say -ps --repeat=5 goodnight, Gracie
- 4 set maxrepeats 5
- 5 say -ps --repeat=5 goodnight, Gracie
-(Cmd) history -r 3
-OODNIGHT, GRACIEGAY
-OODNIGHT, GRACIEGAY
-OODNIGHT, GRACIEGAY
-OODNIGHT, GRACIEGAY
-OODNIGHT, GRACIEGAY
-(Cmd) set debug True
-debug - was: False/ */
-now: True/ */
diff --git a/tests/transcripts/multiline_no_regex.txt b/tests/transcripts/multiline_no_regex.txt
deleted file mode 100644
index 490870cf1..000000000
--- a/tests/transcripts/multiline_no_regex.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-# test a multi-line command
-
-(Cmd) orate This is a test
-> of the
-> emergency broadcast system
-This is a test of the emergency broadcast system
diff --git a/tests/transcripts/multiline_regex.txt b/tests/transcripts/multiline_regex.txt
deleted file mode 100644
index 3487335ff..000000000
--- a/tests/transcripts/multiline_regex.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-# these regular expressions match multiple lines of text
-
-(Cmd) say -r 3 -s yabba dabba do
-/\A(YA.*?DO\n?){3}/
-(Cmd) say -r 5 -s yabba dabba do
-/\A([A-Z\s]*$){3}/
diff --git a/tests/transcripts/no_output.txt b/tests/transcripts/no_output.txt
deleted file mode 100644
index 6b84e8e76..000000000
--- a/tests/transcripts/no_output.txt
+++ /dev/null
@@ -1,7 +0,0 @@
-# ensure the transcript can play a command with no output from a command somewhere in the middle
-
-(Cmd) say something
-something
-(Cmd) nothing
-(Cmd) say something else
-something else
diff --git a/tests/transcripts/no_output_last.txt b/tests/transcripts/no_output_last.txt
deleted file mode 100644
index c75d7e7fe..000000000
--- a/tests/transcripts/no_output_last.txt
+++ /dev/null
@@ -1,7 +0,0 @@
-# ensure the transcript can play a command with no output from the last command
-
-(Cmd) say something
-something
-(Cmd) say something else
-something else
-(Cmd) nothing
diff --git a/tests/transcripts/singleslash.txt b/tests/transcripts/singleslash.txt
deleted file mode 100644
index f3b291f91..000000000
--- a/tests/transcripts/singleslash.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-# even if you only have a single slash, you have
-# to escape it
-
-(Cmd) say use 2/3 cup of sugar
-use 2\/3 cup of sugar
diff --git a/tests/transcripts/slashes_escaped.txt b/tests/transcripts/slashes_escaped.txt
deleted file mode 100644
index 09bbe3bb2..000000000
--- a/tests/transcripts/slashes_escaped.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-# escape those slashes
-
-(Cmd) say /some/unix/path
-\/some\/unix\/path
-(Cmd) say mix 2/3 c. sugar, 1/2 c. butter, and 1/2 tsp. salt
-mix 2\/3 c. sugar, 1\/2 c. butter, and 1\/2 tsp. salt
diff --git a/tests/transcripts/slashslash.txt b/tests/transcripts/slashslash.txt
deleted file mode 100644
index 2504b0baa..000000000
--- a/tests/transcripts/slashslash.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-# ensure consecutive slashes are parsed correctly
-
-(Cmd) say //
-\/\/
diff --git a/tests/transcripts/spaces.txt b/tests/transcripts/spaces.txt
deleted file mode 100644
index 615fcbd7f..000000000
--- a/tests/transcripts/spaces.txt
+++ /dev/null
@@ -1,8 +0,0 @@
-# check spaces in all their forms
-
-(Cmd) say how many spaces
-how many spaces
-(Cmd) say how many spaces
-how/\s{1}/many/\s{1}/spaces
-(Cmd) say "how many spaces"
-how/\s+/many/\s+/spaces
diff --git a/tests/transcripts/word_boundaries.txt b/tests/transcripts/word_boundaries.txt
deleted file mode 100644
index e79cfc4fc..000000000
--- a/tests/transcripts/word_boundaries.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-# use word boundaries to check for key words in the output
-
-(Cmd) mumble maybe we could go to lunch
-/.*\bmaybe\b.*\bcould\b.*\blunch\b.*/
-(Cmd) mumble maybe we could go to lunch
-/.*\bmaybe\b.*\bcould\b.*\blunch\b.*/