Repository: python-cmd2/cmd2

Issue:
poutput() and ansi styles.
in ansi.py we have: `style_success`, `style_warning`, and `style_error` defined. It looks like `pwarning()` and `perror()` use these style but `poutput()` doesn't.

I think one of these two changes should be mode:

* Option 1: Add a new `psuccess()` that uses `style_success` and define a new `style_output` that `poutput()` uses.
* Option 2: Change `poutput()` to use `style_success`

Discussion:
codecov[bot]: ## [Codecov](https://codecov.io/gh/python-cmd2/cmd2/pull/1269?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2) Report
Patch coverage: **`92.85`**% and project coverage change: **`-0.63`** :warning:
> Comparison is base [(`fbabd9b`)](https://codecov.io/gh/python-cmd2/cmd2/commit/fbabd9bff4c66766f74d50e3c05eaf58e5a6f6de?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2) 98.57% compared to head [(`f706024`)](https://codecov.io/gh/python-cmd2/cmd2/pull/1269?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2) 97.95%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #1269      +/-   ##
==========================================
- Coverage   98.57%   97.95%   -0.63%     
==========================================
  Files          22       22              
  Lines        5700     5710      +10     
==========================================
- Hits         5619     5593      -26     
- Misses         81      117      +36     
```


| [Impacted Files](https://codecov.io/gh/python-cmd2/cmd2/pull/1269?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2) | Coverage Δ | |
|---|---|---|
| [cmd2/cmd2.py](https://codecov.io/gh/python-cmd2/cmd2/pull/1269?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2#diff-Y21kMi9jbWQyLnB5) | `97.60% <91.66%> (-0.74%)` | :arrow_down: |
| [cmd2/ansi.py](https://codecov.io/gh/python-cmd2/cmd2/pull/1269?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2#diff-Y21kMi9hbnNpLnB5) | `100.00% <100.00%> (ø)` | |

... and [2 files with indirect coverage changes](https://codecov.io/gh/python-cmd2/cmd2/pull/1269/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2)


</details>

[:umbrella: View full report in Codecov by Sentry](https://codecov.io/gh/python-cmd2/cmd2/pull/1269?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2).

kmvanbrunt: I like the idea of adding a `psuccess()` method. However, I have reservations about applying style in `poutput()` for the following reasons.

1. The docstring for `ansi.style_output` says it "colors text for normal output." That implies all output will have this style. However, async alerts, tab completions, prompts, and argparse help text aren't printed with `poutput()`. A developer would need to edit other functions to acheive a matching style.

2. Adding `apply_style` to `ppaged()` and `pfeedback()` is confusing since style is only applied when the `msg` text is sent by these functions to `poutput()`.

3. Adding the `apply_style` argument to `poutput()` breaks any existing code which prints already styled text since it defaults to `True`. In practice developers won't notice a visible change since `style_output` defaults to nothing. However, their code is still wrong since they aren't setting `apply_style` to `False` when they call `poutput`.

@tleonhardt What are your thoughts?
anselor: I feel part of the issue is our current print methods conflate where to send output with how to send output. I've made some adjustments that move in the direction of keeping things separate.
anselor: @tleonhardt @kmvanbrunt ?

Patch:
diff --git a/cmd2/ansi.py b/cmd2/ansi.py
index 576689da..09d040b4 100644
--- a/cmd2/ansi.py
+++ b/cmd2/ansi.py
@@ -1042,6 +1042,9 @@ def style(
 # Default styles for printing strings of various types.
 # These can be altered to suit an application's needs and only need to be a
 # function with the following structure: func(str) -> str
+style_output = functools.partial(style)
+"""Partial function supplying arguments to :meth:`cmd2.ansi.style()` which colors text for normal output"""
+
 style_success = functools.partial(style, fg=Fg.GREEN)
 """Partial function supplying arguments to :meth:`cmd2.ansi.style()` which colors text to signify success"""
 
diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py
index c2e98b75..2371fa54 100644
--- a/cmd2/argparse_custom.py
+++ b/cmd2/argparse_custom.py
@@ -1118,7 +1118,7 @@ class Cmd2HelpFormatter(argparse.RawTextHelpFormatter):
 
             # build full usage string
             format = self._format_actions_usage
-            action_usage = format(required_options + optionals + positionals, groups)
+            action_usage = format(required_options + optionals + positionals, groups)  # type: ignore[arg-type]
             usage = ' '.join([s for s in [prog, action_usage] if s])
 
             # wrap the usage parts if it's too long
@@ -1128,9 +1128,9 @@ class Cmd2HelpFormatter(argparse.RawTextHelpFormatter):
 
                 # break usage into wrappable parts
                 part_regexp = r'\(.*?\)+|\[.*?\]+|\S+'
-                req_usage = format(required_options, groups)
-                opt_usage = format(optionals, groups)
-                pos_usage = format(positionals, groups)
+                req_usage = format(required_options, groups)  # type: ignore[arg-type]
+                opt_usage = format(optionals, groups)  # type: ignore[arg-type]
+                pos_usage = format(positionals, groups)  # type: ignore[arg-type]
                 req_parts = re.findall(part_regexp, req_usage)
                 opt_parts = re.findall(part_regexp, opt_usage)
                 pos_parts = re.findall(part_regexp, pos_usage)
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index bdabee9c..d60e7668 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -54,6 +54,7 @@ from types import (
     ModuleType,
 )
 from typing import (
+    IO,
     Any,
     Callable,
     Dict,
@@ -601,11 +602,14 @@ class Cmd(cmd.Cmd):
                     raise CommandSetRegistrationError(f'Duplicate settable {key} is already registered')
 
         cmdset.on_register(self)
-        methods = inspect.getmembers(
-            cmdset,
-            predicate=lambda meth: isinstance(meth, Callable)  # type: ignore[arg-type]
-            and hasattr(meth, '__name__')
-            and meth.__name__.startswith(COMMAND_FUNC_PREFIX),
+        methods = cast(
+            List[Tuple[str, Callable[..., Any]]],
+            inspect.getmembers(
+                cmdset,
+                predicate=lambda meth: isinstance(meth, Callable)  # type: ignore[arg-type]
+                and hasattr(meth, '__name__')
+                and meth.__name__.startswith(COMMAND_FUNC_PREFIX),
+            ),
         )
 
         default_category = getattr(cmdset, CLASS_ATTR_DEFAULT_HELP_CATEGORY, None)
@@ -1056,7 +1060,40 @@ class Cmd(cmd.Cmd):
         """
         return ansi.strip_style(self.prompt)
 
-    def poutput(self, msg: Any = '', *, end: str = '\n') -> None:
+    def print_to(
+        self,
+        dest: Union[TextIO, IO[str]],
+        msg: Any,
+        *,
+        end: str = '\n',
+        style: Optional[Callable[[str], str]] = None,
+        paged: bool = False,
+        chop: bool = False,
+    ) -> None:
+        final_msg = style(msg) if style is not None else msg
+        if paged:
+            self.ppaged(final_msg, end=end, chop=chop, dest=dest)
+        else:
+            try:
+                ansi.style_aware_write(dest, f'{final_msg}{end}')
+            except BrokenPipeError:
+                # This occurs if a command's output is being piped to another
+                # process and that process closes before the command is
+                # finished. If you would like your application to print a
+                # warning message, then set the broken_pipe_warning attribute
+                # to the message you want printed.
+                if self.broken_pipe_warning:
+                    sys.stderr.write(self.broken_pipe_warning)
+
+    def poutput(
+        self,
+        msg: Any = '',
+        *,
+        end: str = '\n',
+        apply_style: bool = True,
+        paged: bool = False,
+        chop: bool = False,
+    ) -> None:
         """Print message to self.stdout and appends a newline by default
 
         Also handles BrokenPipeError exceptions for when a command's output has
@@ -1065,44 +1102,91 @@ class Cmd(cmd.Cmd):
 
         :param msg: object to print
         :param end: string appended after the end of the message, default a newline
+        :param apply_style: If True, then ansi.style_output will be applied to the message text. Set to False in cases
+                            where the message text already has the desired style. Defaults to True.
+        :param paged: If True, pass the output through the configured pager.
+        :param chop: If paged is True, True to truncate long lines or False to wrap long lines.
         """
-        try:
-            ansi.style_aware_write(self.stdout, f"{msg}{end}")
-        except BrokenPipeError:
-            # This occurs if a command's output is being piped to another
-            # process and that process closes before the command is
-            # finished. If you would like your application to print a
-            # warning message, then set the broken_pipe_warning attribute
-            # to the message you want printed.
-            if self.broken_pipe_warning:
-                sys.stderr.write(self.broken_pipe_warning)
+        self.print_to(self.stdout, msg, end=end, style=ansi.style_output if apply_style else None, paged=paged, chop=chop)
 
     # noinspection PyMethodMayBeStatic
-    def perror(self, msg: Any = '', *, end: str = '\n', apply_style: bool = True) -> None:
+    def perror(
+        self,
+        msg: Any = '',
+        *,
+        end: str = '\n',
+        apply_style: bool = True,
+        paged: bool = False,
+        chop: bool = False,
+    ) -> None:
         """Print message to sys.stderr
 
         :param msg: object to print
         :param end: string appended after the end of the message, default a newline
         :param apply_style: If True, then ansi.style_error will be applied to the message text. Set to False in cases
                             where the message text already has the desired style. Defaults to True.
+        :param paged: If True, pass the output through the configured pager.
+        :param chop: If paged is True, True to truncate long lines or False to wrap long lines.
         """
-        if apply_style:
-            final_msg = ansi.style_error(msg)
-        else:
-            final_msg = str(msg)
-        ansi.style_aware_write(sys.stderr, final_msg + end)
+        self.print_to(sys.stderr, msg, end=end, style=ansi.style_error if apply_style else None, paged=paged, chop=chop)
+
+    def psuccess(
+        self,
+        msg: Any = '',
+        *,
+        end: str = '\n',
+        paged: bool = False,
+        chop: bool = False,
+    ) -> None:
+        """Writes to stdout applying ansi.style_success by default
+
+        :param msg: object to print
+        :param end: string appended after the end of the message, default a newline
+        :param paged: If True, pass the output through the configured pager.
+        :param chop: If paged is True, True to truncate long lines or False to wrap long lines.
+        """
+        self.print_to(self.stdout, msg, end=end, style=ansi.style_success, paged=paged, chop=chop)
 
-    def pwarning(self, msg: Any = '', *, end: str = '\n', apply_style: bool = True) -> None:
+    def pwarning(
+        self,
+        msg: Any = '',
+        *,
+        end: str = '\n',
+        apply_style: bool = True,
+        paged: bool = False,
+        chop: bool = False,
+    ) -> None:
         """Wraps perror, but applies ansi.style_warning by default
 
         :param msg: object to print
         :param end: string appended after the end of the message, default a newline
-        :param apply_style: If True, then ansi.style_warning will be applied to the message text. Set to False in cases
-                            where the message text already has the desired style. Defaults to True.
+        :param apply_style:
+            If True, then ansi.style_warning will be applied to the message text. Set to False in cases
+            where the message text already has the desired style. Defaults to True.
+
+            .. deprecated: 2.4.4
+                Use :meth:`~cmd2.Cmd.print_to` instead to print to stderr without style applied.
+        :param paged: If True, pass the output through the configured pager.
+        :param chop: If paged is True, True to truncate long lines or False to wrap long lines.
         """
-        if apply_style:
-            msg = ansi.style_warning(msg)
-        self.perror(msg, end=end, apply_style=False)
+        self.print_to(sys.stderr, msg, end=end, style=ansi.style_warning if apply_style else None, paged=paged, chop=chop)
+
+    def pfailure(
+        self,
+        msg: Any = '',
+        *,
+        end: str = '\n',
+        paged: bool = False,
+        chop: bool = False,
+    ) -> None:
+        """Writes to stderr applying ansi.style_error by default
+
+        :param msg: object to print
+        :param end: string appended after the end of the message, default a newline
+        :param paged: If True, pass the output through the configured pager.
+        :param chop: If paged is True, True to truncate long lines or False to wrap long lines.
+        """
+        self.print_to(sys.stderr, msg, end=end, style=ansi.style_error, paged=paged, chop=chop)
 
     def pexcept(self, msg: Any, *, end: str = '\n', apply_style: bool = True) -> None:
         """Print Exception message to sys.stderr. If debug is true, print exception traceback if one exists.
@@ -1131,23 +1215,39 @@ class Cmd(cmd.Cmd):
 
         self.perror(final_msg, end=end, apply_style=False)
 
-    def pfeedback(self, msg: Any, *, end: str = '\n') -> None:
+    def pfeedback(
+        self,
+        msg: Any,
+        *,
+        end: str = '\n',
+        apply_style: bool = True,
+        paged: bool = False,
+        chop: bool = False,
+    ) -> None:
         """For printing nonessential feedback.  Can be silenced with `quiet`.
         Inclusion in redirected output is controlled by `feedback_to_output`.
 
         :param msg: object to print
         :param end: string appended after the end of the message, default a newline
+        :param apply_style: If True, then ansi.style_output will be applied to the message text. Set to False in cases
+                            where the message text already has the desired style. Defaults to True.
+        :param paged: If True, pass the output through the configured pager.
+        :param chop: If paged is True, True to truncate long lines or False to wrap long lines.
         """
         if not self.quiet:
-            if self.feedback_to_output:
-                self.poutput(msg, end=end)
-            else:
-                self.perror(msg, end=end, apply_style=False)
+            self.print_to(
+                self.stdout if self.feedback_to_output else sys.stderr,
+                msg,
+                end=end,
+                style=ansi.style_output if apply_style else None,
+                paged=paged,
+                chop=chop,
+            )
 
-    def ppaged(self, msg: Any, *, end: str = '\n', chop: bool = False) -> None:
+    def ppaged(self, msg: Any, *, end: str = '\n', chop: bool = False, dest: Optional[Union[TextIO, IO[str]]] = None) -> None:
         """Print output using a pager if it would go off screen and stdout isn't currently being redirected.
 
-        Never uses a pager inside of a script (Python or text) or when output is being redirected or piped or when
+        Never uses a pager inside a script (Python or text) or when output is being redirected or piped or when
         stdout or stdin are not a fully functional terminal.
 
         :param msg: object to print
@@ -1157,11 +1257,13 @@ class Cmd(cmd.Cmd):
                               - chopping is ideal for displaying wide tabular data as is done in utilities like pgcli
                      False -> causes lines longer than the screen width to wrap to the next line
                               - wrapping is ideal when you want to keep users from having to use horizontal scrolling
+        :param dest: Optionally specify the destination stream to write to. If unspecified, defaults to self.stdout
 
         WARNING: On Windows, the text always wraps regardless of what the chop argument is set to
         """
         # msg can be any type, so convert to string before checking if it's blank
         msg_str = str(msg)
+        dest = self.stdout if dest is None else dest
 
         # Consider None to be no data to print
         if msg is None or msg_str == '':
@@ -1195,7 +1297,7 @@ class Cmd(cmd.Cmd):
                     pipe_proc = subprocess.Popen(pager, shell=True, stdin=subprocess.PIPE)
                     pipe_proc.communicate(msg_str.encode('utf-8', 'replace'))
             else:
-                self.poutput(msg_str, end=end)
+                ansi.style_aware_write(dest, f'{msg_str}{end}')
         except BrokenPipeError:
             # This occurs if a command's output is being piped to another process and that process closes before the
             # command is finished. If you would like your application to print a warning message, then set the
@@ -4985,8 +5087,8 @@ class Cmd(cmd.Cmd):
         if test_results.wasSuccessful():
             ansi.style_aware_write(sys.stderr, stream.read())
             finish_msg = f' {num_transcripts} transcript{plural} passed in {execution_time:.3f} seconds '
-            finish_msg = ansi.style_success(utils.align_center(finish_msg, fill_char='='))
-            self.poutput(finish_msg)
+            finish_msg = utils.align_center(finish_msg, fill_char='=')
+            self.psuccess(finish_msg)
         else:
             # Strip off the initial traceback which isn't particularly useful for end users
             error_str = stream.read()
diff --git a/docs/conf.py b/docs/conf.py
index e5ecb562..ff64e04b 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -164,7 +164,9 @@ texinfo_documents = [
 
 # -- Options for Extensions  -------------------------------------------
 # Example configuration for intersphinx: refer to the Python standard library.
-intersphinx_mapping = {'http://docs.python.org/': None}
+intersphinx_mapping = {
+    'python 3': ('https://docs.python.org/3/', None),
+}
 
 # options for autodoc
 autodoc_default_options = {'member-order': 'bysource'}
diff --git a/examples/colors.py b/examples/colors.py
index 34f16da2..3f81d18c 100755
--- a/examples/colors.py
+++ b/examples/colors.py
@@ -77,7 +77,7 @@ class CmdLineApp(cmd2.Cmd):
 
         for _ in range(min(repetitions, self.maxrepeats)):
             # .poutput handles newlines, and accommodates output redirection too
-            self.poutput(output_str)
+            self.poutput(output_str, apply_style=False)
 
     def do_timetravel(self, _):
         """A command which always generates an error message, to demonstrate custom error colors"""
diff --git a/examples/initialization.py b/examples/initialization.py
index cb5afa35..292756d9 100755
--- a/examples/initialization.py
+++ b/examples/initialization.py
@@ -64,7 +64,7 @@ class BasicApp(cmd2.Cmd):
     def do_echo(self, arg):
         """Example of a multiline command"""
         fg_color = Fg[self.foreground_color.upper()]
-        self.poutput(style(arg, fg=fg_color))
+        self.poutput(style(arg, fg=fg_color), apply_style=False)
 
 
 if __name__ == '__main__':
diff --git a/examples/pirate.py b/examples/pirate.py
index dd90f849..a58766ae 100755
--- a/examples/pirate.py
+++ b/examples/pirate.py
@@ -76,7 +76,7 @@ class Pirate(cmd2.Cmd):
 
     def do_sing(self, arg):
         """Sing a colorful song."""
-        self.poutput(cmd2.ansi.style(arg, fg=Fg[self.songcolor.upper()]))
+        self.poutput(cmd2.ansi.style(arg, fg=Fg[self.songcolor.upper()]), apply_style=False)
 
     yo_parser = cmd2.Cmd2ArgumentParser()
     yo_parser.add_argument('--ho', type=int, default=2, help="How often to chant 'ho'")


Test patch:
diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py
index 2270a907..e5d7db64 100755
--- a/tests/test_cmd2.py
+++ b/tests/test_cmd2.py
@@ -1854,7 +1854,7 @@ def test_poutput_none(outsim_app):
 def test_poutput_ansi_always(outsim_app):
     msg = 'Hello World'
     colored_msg = ansi.style(msg, fg=ansi.Fg.CYAN)
-    outsim_app.poutput(colored_msg)
+    outsim_app.poutput(colored_msg, apply_style=False)
     out = outsim_app.stdout.getvalue()
     expected = colored_msg + '\n'
     assert colored_msg != msg
@@ -1865,7 +1865,7 @@ def test_poutput_ansi_always(outsim_app):
 def test_poutput_ansi_never(outsim_app):
     msg = 'Hello World'
     colored_msg = ansi.style(msg, fg=ansi.Fg.CYAN)
-    outsim_app.poutput(colored_msg)
+    outsim_app.poutput(colored_msg, apply_style=False)
     out = outsim_app.stdout.getvalue()
     expected = msg + '\n'
     assert colored_msg != msg