From 74cec46c8137876387c637ec660cc44750f52879 Mon Sep 17 00:00:00 2001 From: AryanRG Date: Sat, 17 Oct 2026 18:09:08 +0530 Subject: [PATCH] Fix annotated command help output routing --- CHANGELOG.md | 6 ++++++ cmd2/annotated.py | 11 ++++++----- tests/test_annotated.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e7ccd88..21fa3a869 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 4.3.1 (TBD) + +- Bug Fixes + - Fixed `@with_annotated` command `--help` output bypassing the application's output stream, + leaving redirected help files empty and preventing help from being captured by embedded apps. + ## 4.3.0 (October 13, 2026) - Stabilized features diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 4ae620b8a..eb7a33274 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -3171,11 +3171,12 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> _CommandReturn: namespace = ns_provider(provider_self if provider_self is not None else cmd2_app) try: - if with_unknown_args: - ns, unknown = arg_parser.parse_known_args(parsed_arglist, namespace) - else: - ns = arg_parser.parse_args(parsed_arglist, namespace) - unknown = None + with arg_parser.output_to(cmd2_app.stdout): + if with_unknown_args: + ns, unknown = arg_parser.parse_known_args(parsed_arglist, namespace) + else: + ns = arg_parser.parse_args(parsed_arglist, namespace) + unknown = None except SystemExit as exc: raise Cmd2ArgparseError from exc diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 1571964aa..b8ec247cd 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -2467,6 +2467,41 @@ def test_error_produces_stderr(self, app) -> None: _out, err = run_cmd(app, "greet") assert any("error" in line.lower() or "usage" in line.lower() for line in err) + @pytest.mark.parametrize( + ("app_fixture", "command"), + [ + ("app", "greet"), + ("app", "flex"), + ("subcmd_app", "manage"), + ("subcmd_app", "manage add"), + ("subcmd_app", "manage member add"), + ], + ) + @pytest.mark.parametrize("destination", ["stdout", ">", ">>"]) + def test_help_respects_output_destination(self, request, capsys, tmp_path, app_fixture, command, destination) -> None: + """Help follows app.stdout, including file redirection, without leaking to sys.stdout.""" + app = request.getfixturevalue(app_fixture) + original_stdout = app.stdout + help_file = tmp_path / "help.txt" + line = f"{command} --help" + if destination != "stdout": + help_file.write_text("existing\n", encoding="utf-8") + line += f' {destination} "{help_file}"' + + out, err = run_cmd(app, line) + + assert app.stdout is original_stdout + assert not err + if destination == "stdout": + help_text = "\n".join(out) + else: + assert not out + help_text = help_file.read_text(encoding="utf-8") + assert help_text.startswith("existing\n") == (destination == ">>") + assert f"Usage: {command}" in help_text + assert "--help" in help_text + assert capsys.readouterr() == ("", "") + def test_no_args_raises_type_error(self, app) -> None: with pytest.raises(TypeError, match="Expected arguments"): app.do_greet()