Skip to content

Commit

Permalink
Merge pull request #188 from DanCardin/dc/show-default
Browse files Browse the repository at this point in the history
feat: Add DefaultFormatter as optional input to `show_default`.
  • Loading branch information
DanCardin authored Nov 27, 2024
2 parents 5b5c94b + 31755ea commit a49bce4
Show file tree
Hide file tree
Showing 6 changed files with 109 additions and 9 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- fix: Skip non-init fields in dataclasses.
- fix: Required positional arguments in the native parser.
- fix: Infer num_args on unbounded sequence options (e.g. `list[list[str]]` annotation on an option).
- feat: Add DefaultFormatter as optional input to `show_default`.

## 0.25

Expand Down
30 changes: 30 additions & 0 deletions docs/source/arg.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,36 @@ As noted above, a value produced by `ValueFrom` **does not** invoke the `Arg.par
because the called function is programmer-supplied and can/should just return the correct end
value.
## `Arg.show_default`
Defaults to `True` (e.g. `DefaultFormatter(format='{default}', show=True)`). This field controls **both**:
* Whether to render the argument's default for helptext
* The formatting for the default value itself.
`show_default` can be provided as any of:
* `bool`: Implies the `DefaultFormatter.show` value (e.g. `DefaultFormatter(show=<the bool>)`).
* `str`: Implies the `DefaultFormatter.format` value (e.g. `DefaultFormatter(format=<the str>, show=True)`).
* `DefaultFormatter`: Is taken as-is.
`DefaultFormatter.show` enables/disables the display of the given `Arg.default`. While
the literal default value is formatted through `DefaultFormatter.format`.
```{info}
The `str.format` context is provided as named `default` format arg. That is to say, the literal
default value can be templated into the string through the named `{default}` syntax.
Normal format specifiers can be used to control formatting of the value, for example `{default:.2f}`
to limit the precision of a default float value.
```
```{note}
The `format` value **can** simply be a static string, to avoid taking the literal default value. An
reasonable example of this might be `source: Annotated[BinaryIO, Arg(show_default='<stdin>')] = '-'`.
```
(arg-group)=
## `Arg.group`: Groups (and Mutual Exclusion)
Expand Down
15 changes: 12 additions & 3 deletions src/cappa/arg.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
Confirm,
ConfirmType,
Default,
DefaultFormatter,
Prompt,
PromptType,
ValueFrom,
Expand Down Expand Up @@ -194,7 +195,7 @@ def __hash__(self):
required: bool | None = None
field_name: str | EmptyType = Empty
deprecated: bool | str = False
show_default: bool = True
show_default: bool | str | DefaultFormatter = True
propagate: bool = False

destructured: Destructured | None = None
Expand Down Expand Up @@ -290,6 +291,7 @@ def normalize(
"`Arg.propagate` requires a non-positional named option (`short` or `long`)."
)

show_default = DefaultFormatter.from_unknown(self.show_default)
return dataclasses.replace(
self,
default=default,
Expand All @@ -307,6 +309,7 @@ def normalize(
group=group,
has_value=has_value,
type_view=type_view,
show_default=show_default,
)

@classmethod
Expand Down Expand Up @@ -682,17 +685,23 @@ def explode_negated_bool_args(args: typing.Sequence[Arg]) -> typing.Iterable[Arg
if negatives and positives:
assert isinstance(arg.default, Default)

not_required = not arg.required
show_default = arg.show_default
default_is_true = arg.default.default is True and not_required
default_is_false = arg.default.default is False and not_required
disabled = DefaultFormatter.disabled()

positive_arg = dataclasses.replace(
arg,
long=positives,
action=ArgAction.store_true,
show_default=arg.default.default is True,
show_default=show_default if default_is_true else disabled,
)
negative_arg = dataclasses.replace(
arg,
long=negatives,
action=ArgAction.store_false,
show_default=arg.default.default is False,
show_default=show_default if default_is_false else disabled,
)

yield positive_arg
Expand Down
33 changes: 31 additions & 2 deletions src/cappa/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@

import os
from dataclasses import dataclass
from typing import Any, Callable, ClassVar, TextIO, Union
from typing import Any, Callable, ClassVar, Generic, TextIO, Union

import rich.prompt
from typing_extensions import Self, TypeAlias
from typing_extensions import Self, TypeAlias, TypeVar

from cappa.state import State
from cappa.type_view import Empty, EmptyType

T = TypeVar("T")


@dataclass(frozen=True)
class Default:
Expand Down Expand Up @@ -200,6 +202,33 @@ def __call__(self, state: State | None = None):
return self.callable(**kwargs)


@dataclass
class DefaultFormatter(Generic[T]):
format: str = "{default}"
show: bool = True

@classmethod
def disabled(cls):
return cls(show=False)

@classmethod
def from_unknown(cls, value: str | bool | Self) -> Self:
if isinstance(value, cls):
return value

if isinstance(value, str):
return cls(format=value)

return cls(show=bool(value))

def format_default(self, default: Default, default_format: str = "") -> str:
if not self.show or default.default in (None, Empty):
return ""

default_value = self.format.format(default=default.default)
return default_format.format(default=default_value)


PromptType = rich.prompt.Prompt
ConfirmType = rich.prompt.Confirm
default_types = (rich.prompt.Prompt, rich.prompt.Confirm, Env, ValueFrom)
Expand Down
10 changes: 6 additions & 4 deletions src/cappa/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from typing_extensions import Self, TypeAlias

from cappa.arg import Arg, ArgAction, Group
from cappa.default import Default
from cappa.default import Default, DefaultFormatter
from cappa.output import Displayable
from cappa.subcommand import Subcommand
from cappa.type_view import Empty
Expand Down Expand Up @@ -163,10 +163,12 @@ def format_arg(help_formatter: HelpFormatter, arg: Arg) -> str:

segments = []
for format_segment in arg_format:
default = ""
assert isinstance(arg.default, Default)
if arg.show_default and arg.default.default not in (None, Empty):
default = help_formatter.default_format.format(default=arg.default.default)
assert isinstance(arg.show_default, DefaultFormatter)

default = arg.show_default.format_default(
arg.default, help_formatter.default_format
)

choices = ""
if arg.choices:
Expand Down
29 changes: 29 additions & 0 deletions tests/arg/test_show_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing_extensions import Annotated

import cappa
from cappa.default import DefaultFormatter
from tests.utils import CapsysOutput, backends, parse


Expand Down Expand Up @@ -69,3 +70,31 @@ class Command:
stdout = CapsysOutput.from_capsys(capsys).stdout.replace(" ", "")
assert "[--foo](Default:True)" in stdout
assert "[--no-foo](Default:False)" not in stdout


@backends
def test_show_default_string(backend, capsys):
@dataclass
class Command:
foo: Annotated[str, cappa.Arg(show_default="~{default}~")] = "asdf"

with pytest.raises(cappa.HelpExit):
parse(Command, "--help", backend=backend)

stdout = CapsysOutput.from_capsys(capsys).stdout.replace(" ", "")
assert "[FOO](Default:~asdf~)" in stdout


@backends
def test_show_default_explicit(backend, capsys):
@dataclass
class Command:
foo: Annotated[str, cappa.Arg(show_default=DefaultFormatter("!{default}!"))] = (
"asdf"
)

with pytest.raises(cappa.HelpExit):
parse(Command, "--help", backend=backend)

stdout = CapsysOutput.from_capsys(capsys).stdout.replace(" ", "")
assert "[FOO](Default:!asdf!)" in stdout

0 comments on commit a49bce4

Please sign in to comment.