From 76ea36a648b6ee9dea3f00b41c1b0ab14425bcba Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Wed, 5 Aug 2026 06:07:45 -0700 Subject: [PATCH] Fix negative_option conflict in NESTED argument generation mode With `argument_generation_mode=ArgumentGenerationMode.NESTED`, option strings are built from the field's `dest` rather than from `prefix`, so `prefix` stays empty and the explicit `negative_option` passed to `BooleanOptionalAction` never received a disambiguating prefix. Adding the same dataclass twice then made both fields generate the identical negative flag and argparse raised `conflicting option string`. `FieldWrapper.negative_option_prefix` now falls back to the dotted portion of the generated option string when `prefix` is empty, so the negative flags get the same prefix as the positive ones. FLAT mode behaviour is unchanged. Closes #364 --- simple_parsing/wrappers/field_wrapper.py | 20 ++++++++++++++- test/test_bools.py | 31 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/simple_parsing/wrappers/field_wrapper.py b/simple_parsing/wrappers/field_wrapper.py index afae7558..cd0be763 100644 --- a/simple_parsing/wrappers/field_wrapper.py +++ b/simple_parsing/wrappers/field_wrapper.py @@ -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. @@ -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", []) diff --git a/test/test_bools.py b/test/test_bools.py index 4aced279..5173e31b 100644 --- a/test/test_bools.py +++ b/test/test_bools.py @@ -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, @@ -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}`. "