Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 76 additions & 1 deletion 77 tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import pytest
from typing_extensions import Literal

from typed_argparse import Parser, TypedArgs, arg
from typed_argparse import SUPPRESS, Parser, TypedArgs, arg
from typed_argparse.parser import Bindings

from ._testing_utils import argparse_error, compare_verbose, pre_python_10
Expand Down Expand Up @@ -781,6 +781,27 @@ class Args(TypedArgs):
)


@pre_python_10
def test_defaults_in_help_text__requires_formatter(capsys: pytest.CaptureFixture[str]) -> None:
class Args(TypedArgs):
epsilon: float = arg(help="Some epsilon", default=0.1)

parser = Parser(Args, formatter_class=None)
with pytest.raises(SystemExit):
parser.parse_args(["-h"])

captured = capsys.readouterr()
assert captured.out == textwrap.dedent(
"""\
usage: pytest [-h] [--epsilon EPSILON]

optional arguments:
-h, --help show this help message and exit
--epsilon EPSILON Some epsilon
"""
)


@pre_python_10
def test_defaults_in_help_text__off_if_desired(capsys: pytest.CaptureFixture[str]) -> None:
class Args(TypedArgs):
Expand All @@ -802,6 +823,31 @@ class Args(TypedArgs):
)


@pre_python_10
def test_defaults_in_help_text__off_for_booleans(capsys: pytest.CaptureFixture[str]) -> None:
class Args(TypedArgs):
implicit: bool = arg(help="Some implicit 'on' switch")
enable: bool = arg(help="Some 'on' switch", default=False)
disable: bool = arg(help="Some 'off' switch", default=True)

parser = Parser(Args)
with pytest.raises(SystemExit):
parser.parse_args(["-h"])

captured = capsys.readouterr()
assert captured.out == textwrap.dedent(
"""\
usage: pytest [-h] [--implicit] [--enable] [--disable]

optional arguments:
-h, --help show this help message and exit
--implicit Some implicit 'on' switch
--enable Some 'on' switch
--disable Some 'off' switch
"""
)


# Support of formatter class in help texts


Expand Down Expand Up @@ -843,6 +889,35 @@ class Args(TypedArgs):
)


# Supression of help text


@pre_python_10
@pytest.mark.parametrize("auto_default_help", (True, False))
def test_supression_of_help_text(
capsys: pytest.CaptureFixture[str],
auto_default_help: bool,
) -> None:
class Args(TypedArgs):
epsilon: float = arg(help="Some epsilon", default=0.1)
hidden: float = arg(help=SUPPRESS, default=0.2, auto_default_help=auto_default_help)

parser = Parser(Args)
with pytest.raises(SystemExit):
parser.parse_args(["-h"])

captured = capsys.readouterr()
assert captured.out == textwrap.dedent(
"""\
usage: pytest [-h] [--epsilon EPSILON]

optional arguments:
-h, --help show this help message and exit
--epsilon EPSILON Some epsilon [default: 0.1]
"""
)


# Misc


Expand Down
5 changes: 5 additions & 0 deletions 5 typed_argparse/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from argparse import SUPPRESS

from .arg import Arg, arg
from .choices import Choices, get_choices_from, get_choices_from_class
from .exceptions import SubParserConflict
from .formatter import DefaultHelpFormatter
from .parser import Binding, Bindings, Parser, SubParser, SubParserGroup
from .typed_args import TypedArgs, WithUnionType, validate_type_union

Expand All @@ -12,12 +15,14 @@
"Binding",
"Bindings",
"Choices",
"DefaultHelpFormatter",
"get_choices_from_class",
"get_choices_from",
"Parser",
"SubParser",
"SubParserConflict",
"SubParserGroup",
"SUPPRESS",
"TypedArgs",
"validate_type_union",
"WithUnionType",
Expand Down
27 changes: 27 additions & 0 deletions 27 typed_argparse/formatter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import argparse
from typing import Optional


class DefaultHelpFormatter(argparse.HelpFormatter):
class Unformatted(str):
pass

def _get_help_string(self, action: argparse.Action) -> Optional[str]:
if (
not isinstance(action.help, DefaultHelpFormatter.Unformatted)
and action.help is not None
and action.default is not None
and action.default is not True
and action.default is not False
# Setting default=SUPPRESS is not supported by typed-argparse, but is used
# by argparse for the help (-h/--help) and version (-v/--version) actions.
and action.default is not argparse.SUPPRESS

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a few things that I don't understand here yet:

  1. It looks like we are going into the else branch if help is DefaultHelpFormatter.Unformatted or if it is None, right? Doesn't that mean that DefaultHelpFormatter.Unformatted is unnecessary? I.e., could the user achieve the same by setting help to None anyway?

  2. What is the purpose of comparing action.default to argparse.SUPPRESS? In general it seems that argparse.SUPPRESS has multiple roles in argparse:

  • It can be used as a value for the help argument to suppress the help text.
  • It also can be used for the argument_default and default arguments (for the parser or add_argument). This usage has a special meaning that the field will be omitted from the argparse.Namespace instance.

The latter usage is problematic from the typed_argparse perspective, because we rely on arguments always being generated in the Namespace instance. Using argparse.SUPPRESS in the default position actually breaks argument parsing currently:

import argparse
from typing import Optional

import typed_argparse as tap


class Args(tap.TypedArgs):
    foo: Optional[str] = tap.arg(default=argparse.SUPPRESS)


def run(args: Args):
    print(args)


if __name__ == "__main__":
    tap.Parser(Args).bind(run).run()

Fortunately it doesn't type check for other types than str, because its a string based sentinel value. I'll probably have to catch that, i.e., the default value '==SUPPRESS==' for strings is kinda forbidden 😕

This makes we wonder why to compare with action.default here though. I would have understood if there is a comparison like action.help == argparse.SUPPRESS but surprisingly that doesn't seem necessary - so the filtering actually happens internally in argparse? If so, shouldn't we remove action.default is not argparse.SUPPRESS from the condition, if we cannot support it in the default position anyway?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't worry finding the time for this. There's no hurry.

  1. DefaultHelpFormatter.Unformatted is a bit of a hack to support the auto_default_help option and is only used if help is not None (see https://github.com/MikkelSchubert/typed-argparse/blob/8a76abd4166a1bcbdd16554ce56851516bcb42be/typed_argparse/parser.py#L480). The problem is that auto_default_help isn't available to the HelpFormatter instance, so instead I convert the (not None) help string to an instance of a string subclass. This allows me to check whether or not the user set auto_default_help=True without breaking stuff in argparse, where help is expected to be a string. There may be a better way of doing this, but I couldn't figure one out.

  2. I agree with all your points regarding argparse.SUPPRESS, but it needs to be handled here simply because argparse makes use of it even if we don't. I should probably add a comment about that. If you don't check for it in the HelpFormatter then you get output like this from (from an unmodified examples/high_level_api/basic_usage.py):

       -h, --help           show this help message and exit [default: ==SUPPRESS==]
    

):
try:
from yachalk import chalk # pyright: ignore

return f"{action.help} {chalk.gray(f'[default: {action.default}]')}"
except ImportError:
return f"{action.help} [default: {action.default}]"
else:
return action.help
23 changes: 7 additions & 16 deletions 23 typed_argparse/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from .arg import arg as make_arg
from .choices import Choices
from .exceptions import SubParserConflict
from .formatter import DefaultHelpFormatter
from .type_utils import TypeAnnotation, collect_type_annotations
from .typed_args import TypedArgs

Expand Down Expand Up @@ -132,7 +133,7 @@ def __init__(
epilog: Optional[str] = None,
add_help: bool = True,
allow_abbrev: bool = True,
formatter_class: Optional[FormatterClass] = None,
formatter_class: Optional[FormatterClass] = DefaultHelpFormatter,
):
"""
The parser constructor requires one positional argument, which is either
Expand Down Expand Up @@ -475,8 +476,12 @@ def _build_add_argument_args(
arg: Arg,
) -> Tuple[List[str], Dict[str, Any]]:

help = arg.help
if help is not None and help is not argparse.SUPPRESS and not arg.auto_default_help:
help = DefaultHelpFormatter.Unformatted(help)

kwargs: Dict[str, Any] = {
"help": _generate_help_text(arg),
"help": help,
}

# Unwrap optionals
Expand Down Expand Up @@ -594,20 +599,6 @@ def _build_add_argument_args(
return name_or_flags, kwargs


def _generate_help_text(arg: Arg) -> Optional[str]:
if arg.help is not None and arg.default is not None and arg.auto_default_help:

try:
from yachalk import chalk # pyright: ignore

return f"{arg.help} {chalk.gray(f'[default: {arg.default}]')}"

except ImportError:
return f"{arg.help} [default: {arg.default}]"
else:
return arg.help


def _to_string(args_or_group: ArgsOrGroup) -> str:
if isinstance(args_or_group, type):
return args_or_group.__name__
Expand Down
Morty Proxy This is a proxified and sanitized view of the page, visit original site.