Skip to content

Commit 697a335

Browse files
committed
Removed parse_args wrappers and made context manager public.
1 parent 6c3accd commit 697a335

4 files changed

Lines changed: 25 additions & 83 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ prompt is displayed.
145145
- Added `traceback_kwargs` attribute to allow customization of Rich-based tracebacks.
146146
- The `print()` function available in a `pyscript` writes to `self.stdout` and respects the
147147
`allow_style` setting. It also supports printing `Rich` objects.
148+
- Added `Cmd2ArgumentParser.output_to()` context manager to temporarily set the output stream.
149+
This is helpful for redirecting output from functions like `parse_args()`, which default to
150+
`sys.stdout` and lack a `file` argument.
148151

149152
## 3.5.1 (April 24, 2026)
150153

cmd2/argparse_utils.py

Lines changed: 12 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -557,7 +557,7 @@ class _ParserThreadLocals(threading.local):
557557
# pass the destination stream to the formatter factory, this transient value
558558
# provides the context needed to synchronize Rich's rendering with the specific
559559
# capabilities of the destination file descriptor. It is managed via the
560-
# _set_output_file() context manager.
560+
# output_to() context manager.
561561
current_output_file: IO[str] | None = None
562562

563563

@@ -568,8 +568,14 @@ class Cmd2ArgumentParser(argparse.ArgumentParser):
568568
_thread_locals: ClassVar[_ParserThreadLocals] = _ParserThreadLocals()
569569

570570
@contextlib.contextmanager
571-
def _set_output_file(self, file: IO[str] | None) -> Iterator[None]:
572-
"""Context manager to temporarily set the current output file."""
571+
def output_to(self, file: IO[str] | None) -> Iterator[None]:
572+
"""Context manager to temporarily set the output stream during argparse operations.
573+
574+
This is helpful for redirecting output from functions like `parse_args()`, which
575+
default to `sys.stdout` and lack a `file` argument.
576+
577+
:param file: the file stream to use for output
578+
"""
573579
previous = self._thread_locals.current_output_file
574580
self._thread_locals.current_output_file = file
575581
try:
@@ -635,58 +641,20 @@ def __init__(
635641
self.description: HelpContent | None # type: ignore[assignment]
636642
self.epilog: HelpContent | None # type: ignore[assignment]
637643

638-
def parse_args_custom_stdout(
639-
self,
640-
stdout: IO[str],
641-
args: list[str] | None = None,
642-
namespace: argparse.Namespace | None = None,
643-
) -> argparse.Namespace:
644-
"""Parse arguments while directing help and usage output to a custom stdout stream.
645-
646-
This method is particularly useful when you need to capture help output without
647-
globally redirecting sys.stdout.
648-
649-
:param stdout: the stream to use for help and usage output
650-
:param args: optional list of arguments to parse. If None, uses sys.argv[1:].
651-
:param namespace: optional namespace to populate. If None, a new Namespace is created.
652-
:return: the parsed namespace
653-
"""
654-
with self._set_output_file(stdout):
655-
return self.parse_args(args, namespace)
656-
657-
def parse_known_args_custom_stdout(
658-
self,
659-
stdout: IO[str],
660-
args: list[str] | None = None,
661-
namespace: argparse.Namespace | None = None,
662-
) -> tuple[argparse.Namespace, list[str]]:
663-
"""Parse known arguments while directing help and usage output to a custom stdout stream.
664-
665-
This method is particularly useful when you need to capture help output without
666-
globally redirecting sys.stdout.
667-
668-
:param stdout: the stream to use for help and usage output
669-
:param args: optional list of arguments to parse. If None, uses sys.argv[1:].
670-
:param namespace: optional namespace to populate. If None, a new Namespace is created.
671-
:return: a tuple containing the parsed namespace and a list of unknown arguments
672-
"""
673-
with self._set_output_file(stdout):
674-
return self.parse_known_args(args, namespace)
675-
676644
def print_usage(self, file: IO[str] | None = None) -> None: # type:ignore[override]
677645
"""Override to ensure the formatter is aware of the target file."""
678646
if file is None:
679647
file = self._thread_locals.current_output_file
680648

681-
with self._set_output_file(file):
649+
with self.output_to(file):
682650
super().print_usage(file)
683651

684652
def print_help(self, file: IO[str] | None = None) -> None: # type:ignore[override]
685653
"""Override to ensure the formatter is aware of the target file."""
686654
if file is None:
687655
file = self._thread_locals.current_output_file
688656

689-
with self._set_output_file(file):
657+
with self.output_to(file):
690658
super().print_help(file)
691659

692660
def get_subparsers_action(self) -> "argparse._SubParsersAction[Cmd2ArgumentParser]":
@@ -891,7 +859,7 @@ def error(self, message: str) -> NoReturn:
891859
else:
892860
formatted_message += "\n " + line
893861

894-
with self._set_output_file(sys.stderr):
862+
with self.output_to(sys.stderr):
895863
self.print_usage(sys.stderr)
896864

897865
# Use console to add style since it will respect ALLOW_STYLE's value.

cmd2/decorators.py

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -303,20 +303,11 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
303303

304304
try:
305305
parsing_results: tuple[argparse.Namespace] | tuple[argparse.Namespace, list[str]]
306-
if with_unknown_args:
307-
parsing_results = arg_parser.parse_known_args_custom_stdout(
308-
cmd2_app.stdout,
309-
command_arg_list,
310-
initial_namespace,
311-
)
312-
else:
313-
parsing_results = (
314-
arg_parser.parse_args_custom_stdout(
315-
cmd2_app.stdout,
316-
command_arg_list,
317-
initial_namespace,
318-
),
319-
)
306+
with arg_parser.output_to(cmd2_app.stdout):
307+
if with_unknown_args:
308+
parsing_results = arg_parser.parse_known_args(command_arg_list, initial_namespace)
309+
else:
310+
parsing_results = (arg_parser.parse_args(command_arg_list, initial_namespace),)
320311
except SystemExit as exc:
321312
raise Cmd2ArgparseError from exc
322313

tests/test_argparse_utils.py

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -620,8 +620,8 @@ def test_update_prog() -> None:
620620
assert action.choices["alias1"].prog == sub1.prog
621621

622622

623-
def test_parser_set_output_file_context_manager() -> None:
624-
"""Test that _set_output_file correctly shadows and restores current_output_file."""
623+
def test_parser_output_to_context_manager() -> None:
624+
"""Test that output_to() correctly shadows and restores current_output_file."""
625625
import io
626626

627627
parser = Cmd2ArgumentParser()
@@ -630,9 +630,9 @@ def test_parser_set_output_file_context_manager() -> None:
630630

631631
assert parser._thread_locals.current_output_file is None
632632

633-
with parser._set_output_file(buf1):
633+
with parser.output_to(buf1):
634634
assert parser._thread_locals.current_output_file is buf1
635-
with parser._set_output_file(buf2): # type: ignore[unreachable]
635+
with parser.output_to(buf2): # type: ignore[unreachable]
636636
assert parser._thread_locals.current_output_file is buf2
637637
assert parser._thread_locals.current_output_file is buf1
638638

@@ -699,26 +699,6 @@ def test_parser_error_redirection(mocker: MockerFixture) -> None:
699699
assert kwargs["style"] == argparse_utils.Cmd2Style.ERROR
700700

701701

702-
def test_parser_custom_stdout_methods(mocker: MockerFixture) -> None:
703-
"""Test parse_args_custom_stdout() and parse_known_args_custom_stdout()."""
704-
import io
705-
706-
parser = Cmd2ArgumentParser()
707-
buf = io.StringIO()
708-
709-
# Mock parse_args and parse_known_args
710-
mock_parse = mocker.patch.object(parser, "parse_args")
711-
mock_parse_known = mocker.patch.object(parser, "parse_known_args")
712-
713-
parser.parse_args_custom_stdout(buf, ["arg"])
714-
assert parser._thread_locals.current_output_file is None
715-
mock_parse.assert_called_once_with(["arg"], None)
716-
717-
parser.parse_known_args_custom_stdout(buf, ["arg"])
718-
assert parser._thread_locals.current_output_file is None
719-
mock_parse_known.assert_called_once_with(["arg"], None)
720-
721-
722702
def test_parser_implicit_output_redirection(mocker: MockerFixture) -> None:
723703
"""Test that print_help() and print_usage() use thread-local context when no file is provided."""
724704
import io
@@ -731,7 +711,7 @@ def test_parser_implicit_output_redirection(mocker: MockerFixture) -> None:
731711
mock_formatter_class.return_value.format_help.return_value = "Help/Usage Text"
732712

733713
# Shadow the output file
734-
with parser._set_output_file(buf):
714+
with parser.output_to(buf):
735715
# Call print_help without a file argument
736716
parser.print_help()
737717
# Verify Cmd2HelpFormatter was instantiated with file=buf (from thread-local)

0 commit comments

Comments
 (0)