From 26baeb364d3ad15109b685ee8889ac43ee65b60f Mon Sep 17 00:00:00 2001 From: Apoorv Darshan Date: Thu, 9 Jul 2026 22:14:05 +0530 Subject: [PATCH] Fix container from_string collation for single value with allow_none Container.from_string substituted None for a value that failed literal_eval, then passed it to validate. Since PR #885 added an allow_none short-circuit to Instance.validate, that None is now accepted for allow_none containers instead of raising, so a single --Class.trait=value (e.g. List(allow_none=True)) resolves to None instead of ['value']. Validate the original string on parse failure instead of None. A bare string is not a container, so validation raises as it did before #885, letting DeferredConfig.get_value fall back to collating the value into a single-item list. Fixes #908 --- tests/test_traitlets.py | 35 +++++++++++++++++++++++++++++++++++ traitlets/traitlets.py | 6 +++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/test_traitlets.py b/tests/test_traitlets.py index 665422cd..56e0da1b 100644 --- a/tests/test_traitlets.py +++ b/tests/test_traitlets.py @@ -3040,6 +3040,41 @@ def test_list_items_from_string(s, expected, value_trait): _from_string_test(List(value_trait), s, expected) +@pytest.mark.parametrize("container", [List, Set, Tuple]) +def test_container_from_string_single_value_allow_none(container): + """Regression test for https://github.com/ipython/traitlets/issues/908. + + ``from_string`` receives a single, non-literal string. It must not + silently coerce it to ``None`` on ``allow_none`` containers: a bare + string is not a container, so validation must reject it. This lets the + config loader fall back to collating the value into a single-item list + (e.g. ``--Class.trait=value`` -> ``['value']``) instead of ``None``. + """ + trait = container(allow_none=True) + trait.name = "a_trait" + with pytest.raises(TraitError): + trait.from_string("a_value") + + +def test_list_allow_none_single_cli_value_is_collated(): + """End-to-end regression for issue #908. + + ``--MyApp.a_list=a_value`` with ``a_list = List(allow_none=True)`` must + yield ``['a_value']``, not ``None`` (regression introduced in #885). + """ + from traitlets.config import Application, Config + from traitlets.config.loader import DeferredConfigString + + class MyApp(Application): + a_list = List(allow_none=True).tag(config=True) + + config = Config() + config.MyApp.a_list = DeferredConfigString("a_value") + app = MyApp() + app.update_config(config) + assert app.a_list == ["a_value"] + + @pytest.mark.parametrize( "s, expected", [ diff --git a/traitlets/traitlets.py b/traitlets/traitlets.py index 4aa08cf3..3b58c95d 100644 --- a/traitlets/traitlets.py +++ b/traitlets/traitlets.py @@ -3530,7 +3530,11 @@ def from_string(self, s: str) -> T | None: try: test = literal_eval(s) except Exception: - test = None + # Not a literal; validate the original string so it is rejected + # (a bare string is not a container) and callers can fall back to + # collating it into a single-item list. Substituting ``None`` here + # would be wrongly accepted by ``allow_none`` containers. + test = s return self.validate(None, test) def from_string_list(self, s_list: list[str]) -> T | None: