Skip to content

Commit

Permalink
Merge pull request #148 from DanCardin/dc/no-handling
Browse files Browse the repository at this point in the history
refactor: Pull handling of --no-* bool arguments out of the parser.
  • Loading branch information
DanCardin committed Sep 17, 2024
2 parents 0883969 + 6de0b62 commit a9bee72
Show file tree
Hide file tree
Showing 6 changed files with 69 additions and 34 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
### 0.22.5

- fix: Refactor parser combinators into dedicated module, and document the behavior more thoroughly.
- refactor: Pull handling of `--no-*` bool arguments out of the parser
- fix: Only apply `--no-*` handling when there is both a positive and negative variant

### 0.22.4

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "cappa"
version = "0.22.4"
version = "0.22.5"
description = "Declarative CLI argument parser."

urls = {repository = "https://github.com/dancardin/cappa"}
Expand Down
34 changes: 33 additions & 1 deletion src/cappa/arg.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ def value_actions(cls) -> typing.Set[ArgAction]:
def is_custom(cls, action: ArgAction | Callable | None):
return action is not None and not isinstance(action, ArgAction)

@property
def is_bool_action(self):
return self in {self.store_true, self.store_false}


@dataclasses.dataclass(order=True)
class Group:
Expand Down Expand Up @@ -192,7 +196,7 @@ def collect(
)
result.append(normalized_arg)

return result
return list(explode_negated_bool_args(result))

def normalize(
self,
Expand Down Expand Up @@ -606,6 +610,34 @@ def infer_value_name(arg: Arg, field_name: str, num_args: int | None) -> str:
return field_name


def explode_negated_bool_args(args: typing.Sequence[Arg]) -> typing.Iterable[Arg]:
"""Expand `--foo/--no-foo` solo arguments into dual-arguments.
Acts as a transform from `Arg(long='--foo/--no-foo')` to
`Annotated[Arg(long='--foo', action=ArgAction.store_true), Arg(long='--no-foo', action=ArgAction.store_false)]`
"""
for arg in args:
yielded = False
if isinstance(arg.action, ArgAction) and arg.action.is_bool_action and arg.long:
long = typing.cast(typing.List[str], arg.long)

negatives = [item for item in long if "--no-" in item]
positives = [item for item in long if "--no-" not in item]
if negatives and positives:
positive_arg = dataclasses.replace(
arg, long=positives, action=ArgAction.store_true
)
negative_arg = dataclasses.replace(
arg, long=negatives, action=ArgAction.store_false
)
yield positive_arg
yield negative_arg
yielded = True

if not yielded:
yield arg


no_extra_arg_actions = {
ArgAction.store_true,
ArgAction.store_false,
Expand Down
21 changes: 0 additions & 21 deletions src/cappa/argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,22 +93,6 @@ def print_help(self, file=None):
raise HelpExit(self.command.help_formatter(self.command, self.prog))


class BooleanOptionalAction(argparse.Action):
"""Simplified backport of same-named class from 3.9 onward.
We know more about the called context here, and thus need much less of the
logic. Also, we support 3.8, which does not have the original class, so we
couldn't use it anyway.
"""

def __init__(self, **kwargs):
super().__init__(nargs=0, **kwargs)

def __call__(self, parser, namespace, values, option_string=None):
assert isinstance(option_string, str)
setattr(namespace, self.dest, not option_string.startswith("--no-"))


def custom_action(arg: Arg, action: Callable):
class CustomAction(argparse.Action):
def __call__( # type: ignore
Expand Down Expand Up @@ -342,11 +326,6 @@ def join_help(*segments):
def get_action(arg: Arg) -> argparse.Action | type[argparse.Action] | str:
action = arg.action
if isinstance(action, ArgAction):
if action in {ArgAction.store_true, ArgAction.store_false}:
long = assert_type(arg.long, list)
has_no_option = any("--no-" in i for i in long)
if has_no_option:
return BooleanOptionalAction
return action.value

action = typing.cast(Callable, action)
Expand Down
18 changes: 7 additions & 11 deletions src/cappa/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -624,16 +624,12 @@ class Value(typing.Generic[T]):
value: T


def store_bool(val: bool):
def store(arg: Arg, option: RawOption):
long = assert_type(arg.long, list)
has_no_option = any("--no-" in i for i in long)
if has_no_option:
return not option.name.startswith("--no-")
def store_true():
return True

return val

return store
def store_false():
return False


def store_count(context: ParseContext, arg: Arg):
Expand All @@ -650,13 +646,13 @@ def store_append(context: ParseContext, arg: Arg, value: Value[typing.Any]):
return result


process_options = {
process_options: dict[ArgAction, typing.Callable] = {
ArgAction.help: HelpAction.from_context,
ArgAction.version: VersionAction.from_arg,
ArgAction.completion: CompletionAction.from_value,
ArgAction.set: store_set,
ArgAction.store_true: store_bool(True),
ArgAction.store_false: store_bool(False),
ArgAction.store_true: store_true,
ArgAction.store_false: store_false,
ArgAction.count: store_count,
ArgAction.append: store_append,
}
26 changes: 26 additions & 0 deletions tests/arg/test_bool.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,29 @@ class ArgTest:
with patch("os.environ", new={"ENV_DEFAULT": "1"}):
test = parse(ArgTest, "--env-default", backend=backend)
assert test.env_default is True


@backends
def test_sole_no_arg(backend):
@dataclass
class ArgTest:
no_dry_run: bool = False

test = parse(ArgTest, backend=backend)
assert test.no_dry_run is False

test = parse(ArgTest, "--no-dry-run", backend=backend)
assert test.no_dry_run is True


@backends
def test_sole_no_arg_inverted(backend):
@dataclass
class ArgTest:
no_dry_run: bool = True

test = parse(ArgTest, backend=backend)
assert test.no_dry_run is True

test = parse(ArgTest, "--no-dry-run", backend=backend)
assert test.no_dry_run is False

0 comments on commit a9bee72

Please sign in to comment.