Repository: ipython/traitlets
Pull request #774 (2022-09-09 09:24:16)

Cover more Union parsing scenarios
Follow-up after #773 with more test cases and added notes in the documentation.


Patch:
diff --git a/traitlets/traitlets.py b/traitlets/traitlets.py
index 4ec17da..cbbaad2 100644
--- a/traitlets/traitlets.py
+++ b/traitlets/traitlets.py
@@ -2131,11 +2131,22 @@ class Union(TraitType):
         ----------
         trait_types : sequence
             The list of trait types of length at least 1.
+        **kwargs
+            Extra kwargs passed to `TraitType`
 
         Notes
         -----
         Union([Float(), Bool(), Int()]) attempts to validate the provided values
         with the validation function of Float, then Bool, and finally Int.
+
+        Parsing from string is ambiguous for container types which accept other
+        collection-like literals (e.g. List accepting both `[]` and `()`
+        precludes Union from ever parsing ``Union([List(), Tuple()])`` as a tuple;
+        you can modify behaviour of too permissive container traits by overriding
+        ``_literal_from_string_pairs`` in subclasses.
+        Similarly, parsing unions of numeric types is only unambiguous if
+        types are provided in order of increasing permissiveness, e.g.
+        ``Union([Int(), Float()])`` (since floats accept integer-looking values).
         """
         self.trait_types = list(trait_types)
         self.info_text = " or ".join([tt.info() for tt in self.trait_types])
@@ -2184,9 +2195,9 @@ class Union(TraitType):
             try:
                 v = trait_type.from_string(s)
                 return trait_type.validate(None, v)
-            except TraitError:
+            except (TraitError, ValueError):
                 continue
-        self.error(None, s)
+        return super().from_string(s)
 
 
 # -----------------------------------------------------------------------------


Test patch:
diff --git a/traitlets/tests/test_traitlets.py b/traitlets/tests/test_traitlets.py
index 5735627..d752dd7 100644
--- a/traitlets/tests/test_traitlets.py
+++ b/traitlets/tests/test_traitlets.py
@@ -82,6 +82,10 @@ class HasTraitsStub(HasTraits):
         self._notify_type = change["type"]
 
 
+class CrossValidationStub(HasTraits):
+    _cross_validation_lock = False
+
+
 # -----------------------------------------------------------------------------
 # Test classes
 # -----------------------------------------------------------------------------
@@ -2924,7 +2928,7 @@ def _from_string_test(traittype, s, expected):
     if type(expected) is type and issubclass(expected, Exception):
         with pytest.raises(expected):
             value = cast(s)
-            trait.validate(None, value)
+            trait.validate(CrossValidationStub(), value)
     else:
         value = cast(s)
         assert value == expected
@@ -3144,6 +3148,22 @@ def test_union_of_list_and_unicode_from_string(s, expected):
     _from_string_test(Union([List(), Unicode()]), s, expected)
 
 
+@pytest.mark.parametrize(
+    "s, expected",
+    [("1", 1), ("1.5", 1.5)],
+)
+def test_union_of_int_and_float_from_string(s, expected):
+    _from_string_test(Union([Int(), Float()]), s, expected)
+
+
+@pytest.mark.parametrize(
+    "s, expected, allow_none",
+    [("[]", [], False), ("{}", {}, False), ("None", TraitError, False), ("None", None, True)],
+)
+def test_union_of_list_and_dict_from_string(s, expected, allow_none):
+    _from_string_test(Union([List(), Dict()], allow_none=allow_none), s, expected)
+
+
 def test_all_attribute():
     """Verify all trait types are added to `traitlets.__all__`"""
     names = dir(traitlets)