Skip to content
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
20 changes: 19 additions & 1 deletion simple_parsing/wrappers/field_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ def enum_to_str(e):
# NOTE: also pass the prefix to the boolean optional action, because it needs to add it
# to the generated negative flags as well.
_arg_options["action"] = BooleanOptionalAction
_arg_options["_conflict_prefix"] = self.prefix
_arg_options["_conflict_prefix"] = self.negative_option_prefix

else:
# "Plain" / simple argument.
Expand Down Expand Up @@ -659,6 +659,24 @@ def add_args(dash: str, candidates: list[str]) -> None:
# def prefix(self) -> str:
# return self._prefix

@property
def negative_option_prefix(self) -> str:
"""Prefix to add to an explicit `negative_option` so it doesn't conflict.

In `ArgumentGenerationMode.FLAT`, conflicts are resolved by setting `self.prefix`, so that
is the prefix the negative option needs. In `ArgumentGenerationMode.NESTED`, the option
strings are built from `self.dest` instead of `self.prefix`, which stays empty, so the
prefix has to be recovered from the generated (dotted) option string, otherwise two fields
of the same dataclass type generate the same negative flag and argparse raises a conflict.
"""
if self.prefix:
return self.prefix
for option_string in self.option_strings:
name = option_string.lstrip("-")
if "." in name:
return name.rsplit(".", 1)[0] + "."
return ""

@property
def aliases(self) -> list[str]:
return self.field.metadata.get("alias", [])
Expand Down
31 changes: 31 additions & 0 deletions test/test_bools.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from simple_parsing import helpers
from simple_parsing.helpers.fields import field, flag
from simple_parsing.wrappers.field_wrapper import ArgumentGenerationMode

from .testutils import (
TestSetup,
Expand Down Expand Up @@ -307,6 +308,36 @@ class Config(TestSetup):
assert "--silent" not in help_text


@pytest.mark.parametrize("default_value", [True, False])
def test_nested_bool_field_negative_option_conflict_nested_mode(default_value: bool):
"""The negative option also needs a prefix with `ArgumentGenerationMode.NESTED`.

Reproduces https://github.com/lebrice/SimpleParsing/issues/364, where adding the same
dataclass twice raised `argparse.ArgumentError: conflicting option string: --silent`.
"""

@dataclass
class Options:
verbose: bool = field(default=default_value, negative_option="silent")

@dataclass
class Config(TestSetup):
train: Options = field(default_factory=Options)
valid: Options = field(default_factory=Options)

help_text = Config.get_help_text(
argument_generation_mode=ArgumentGenerationMode.NESTED,
)
assert "--config.train.silent" in help_text
assert "--config.valid.silent" in help_text

config = Config.setup(
"--config.train.silent", argument_generation_mode=ArgumentGenerationMode.NESTED
)
assert config.train.verbose is False
assert config.valid.verbose is default_value


@pytest.mark.xfail(
reason=(
"TODO: When a field has a single letter name, we usually add `-{name}` and `--{name}`. "
Expand Down