Repository: ipython/traitlets
Pull request #371 (2017-01-31 15:22:14)

New FuzzyEnum trait that matches case-insensitive prefixes/substrings
- Roughly it allows to write `-e fi` instead of `-o file`;
- Should be self-explanatory by its attributes;
- ~~Unfortunately it is untested (needs some work for that and have not enough time right now).~~
- TCs checks mostly `FuzzyEnum.validate()`; Ctor must be checked by generic `Enum` or `CaselessStrEnum` TCs (when implemented).
- Since `default_value` is not validated (known limitation as #263, not specific to this PR), it is easy to misconfigure this enum-trait. I have pushed a untracked commit(0a1c789639) to fail in [travis job: 216069261](https://travis-ci.org/ipython/traitlets/jobs/216069261); offending code in TC also commented-out in the TC.

ankostis: It would be nice to be able to use the *ddt* testing lib for running test-cases both on `CaselessStrEnum` and `FuzzyEnum`: http://ddt.readthedocs.io/en/latest/example.html
ankostis: Finally added TCs to this PR, ready to review/merge.
ankostis: @minrk is there anything more I can do on this PR?

Patch:
diff --git a/traitlets/traitlets.py b/traitlets/traitlets.py
index 30d5baf..30f5642 100644
--- a/traitlets/traitlets.py
+++ b/traitlets/traitlets.py
@@ -2203,6 +2203,43 @@ class CaselessStrEnum(Enum):
                 return v
         self.error(obj, value)
 
+
+class FuzzyEnum(Enum):
+    """An case-ignoring enum matching choices by unique prefixes/substrings."""
+
+    case_sensitive = False
+    #: If True, choices match anywhere in the string, otherwise match prefixes.
+    substring_matching = False
+
+    def __init__(self, values, default_value=Undefined,
+                 case_sensitive=False, substring_matching=False, **kwargs):
+        self.case_sensitive = case_sensitive
+        self.substring_matching = substring_matching
+        values = [cast_unicode_py2(value) for value in values]
+        super(FuzzyEnum, self).__init__(values, default_value=default_value, **kwargs)
+
+    def validate(self, obj, value):
+        if isinstance(value, str):
+            value = cast_unicode_py2(value)
+        if not isinstance(value, six.string_types):
+            self.error(obj, value)
+
+        conv_func = (lambda c: c) if self.case_sensitive else lambda c: c.lower()
+        substring_matching = self.substring_matching
+        match_func = ((lambda v, c: v in c)
+                      if substring_matching
+                      else (lambda v, c: c.startswith(v)))
+        value = conv_func(value)
+        choices = self.values
+        matches = [match_func(value, conv_func(c)) for c in choices]
+        if sum(matches) == 1:
+            for v, m in zip(choices, matches):
+                if m:
+                    return v
+
+        self.error(obj, value)
+
+
 class Container(Instance):
     """An instance of a container (list, set, etc.)
 


Test patch:
diff --git a/traitlets/tests/test_traitlets_enum.py b/traitlets/tests/test_traitlets_enum.py
index 82259ae..a015f7c 100644
--- a/traitlets/tests/test_traitlets_enum.py
+++ b/traitlets/tests/test_traitlets_enum.py
@@ -7,12 +7,13 @@ Test the trait-type ``UseEnum``.
 import unittest
 import enum
 from ipython_genutils.py3compat import string_types
-from traitlets import HasTraits, TraitError, UseEnum
+from traitlets import HasTraits, TraitError, UseEnum, FuzzyEnum
 
 
 # -----------------------------------------------------------------------------
 # TEST SUPPORT:
 # -----------------------------------------------------------------------------
+
 class Color(enum.Enum):
     red = 1
     green = 2
@@ -179,3 +180,160 @@ class TestUseEnum(unittest.TestCase):
         with self.assertRaises(TraitError):
             example.color = "BAD_VALUE"
 
+
+# -----------------------------------------------------------------------------
+# TEST SUPPORT:
+# -----------------------------------------------------------------------------
+color_choices = 'red Green  BLUE YeLLoW'.split()
+
+
+# -----------------------------------------------------------------------------
+# TESTSUITE:
+# -----------------------------------------------------------------------------
+
+class TestFuzzyEnum(unittest.TestCase):
+    ## Check mostly `validate()`, Ctor must be checked on generic `Enum`
+    # or `CaselessStrEnum`.
+
+    def test_search_all_prefixes__overwrite(self):
+        class FuzzyExample(HasTraits):
+            color = FuzzyEnum(color_choices, help="Color enum")
+
+        example = FuzzyExample()
+        for color in color_choices:
+            for wlen in range(1, len(color)):
+                value = color[:wlen]
+
+                example.color = value
+                self.assertEqual(example.color, color)
+
+                example.color = value.upper()
+                self.assertEqual(example.color, color)
+
+                example.color = value.lower()
+                self.assertEqual(example.color, color)
+
+    def test_search_all_prefixes__ctor(self):
+        class FuzzyExample(HasTraits):
+            color = FuzzyEnum(color_choices, help="Color enum")
+
+        for color in color_choices:
+            for wlen in range(1, len(color)):
+                value = color[:wlen]
+
+                example = FuzzyExample()
+                example.color = value
+                self.assertEqual(example.color, color)
+
+                example = FuzzyExample()
+                example.color = value.upper()
+                self.assertEqual(example.color, color)
+
+                example = FuzzyExample()
+                example.color = value.lower()
+                self.assertEqual(example.color, color)
+
+    def test_search_substrings__overwrite(self):
+        class FuzzyExample(HasTraits):
+            color = FuzzyEnum(color_choices, help="Color enum",
+                              substring_matching=True)
+
+        example = FuzzyExample()
+        for color in color_choices:
+            for wlen in range(0, 2):
+                value = color[wlen:]
+
+                example.color = value
+                self.assertEqual(example.color, color)
+
+                example.color = value.upper()
+                self.assertEqual(example.color, color)
+
+                example.color = value.lower()
+                self.assertEqual(example.color, color)
+
+    def test_search_substrings__ctor(self):
+        class FuzzyExample(HasTraits):
+            color = FuzzyEnum(color_choices, help="Color enum",
+                              substring_matching=True)
+
+        color = color_choices[-1]  # 'YeLLoW'
+        for end in (-1, len(color)):
+            for start in range(1, len(color) - 2):
+                value = color[start:end]
+
+                example = FuzzyExample()
+                example.color = value
+                self.assertEqual(example.color, color)
+
+                example = FuzzyExample()
+                example.color = value.upper()
+                self.assertEqual(example.color, color)
+
+    def test_assign_other_raises(self):
+        def new_trait_class(case_sensitive, substring_matching):
+            class Example(HasTraits):
+                color = FuzzyEnum(color_choices,
+                                  case_sensitive=case_sensitive,
+                                  substring_matching=substring_matching)
+
+            return Example
+
+        example = new_trait_class(case_sensitive=False, substring_matching=False)()
+        with self.assertRaises(TraitError):
+            example.color = ''
+        with self.assertRaises(TraitError):
+            example.color = 'BAD COLOR'
+        with self.assertRaises(TraitError):
+            example.color = 'ed'
+
+        example = new_trait_class(case_sensitive=True, substring_matching=False)()
+        with self.assertRaises(TraitError):
+            example.color = ''
+        with self.assertRaises(TraitError):
+            example.color = 'Red'  # not 'red'
+
+        example = new_trait_class(case_sensitive=True, substring_matching=True)()
+        with self.assertRaises(TraitError):
+            example.color = ''
+        with self.assertRaises(TraitError):
+            example.color = 'BAD COLOR'
+        with self.assertRaises(TraitError):
+            example.color = 'green'  # not 'Green'
+        with self.assertRaises(TraitError):
+            example.color = 'lue'  # not (b)'LUE'
+        with self.assertRaises(TraitError):
+            example.color = 'lUE'  # not (b)'LUE'
+
+        example = new_trait_class(case_sensitive=False, substring_matching=True)()
+        with self.assertRaises(TraitError):
+            example.color = ''
+        with self.assertRaises(TraitError):
+            example.color = 'BAD COLOR'
+
+    def test_ctor_with_default_value(self):
+        def new_trait_class(default_value,
+                            case_sensitive, substring_matching):
+            class Example(HasTraits):
+                color = FuzzyEnum(color_choices,
+                                  default_value=default_value,
+                                  case_sensitive=case_sensitive,
+                                  substring_matching=substring_matching)
+
+            return Example
+
+        for color in color_choices:
+            example = new_trait_class(color, False, False)()
+            self.assertEqual(example.color, color)
+
+            example = new_trait_class(color.upper(), False, False)()
+            self.assertEqual(example.color, color)
+
+        color = color_choices[-1]  # 'YeLLoW'
+        example = new_trait_class(color, True, False)()
+        self.assertEqual(example.color, color)
+
+        ## FIXME: default value not validated!
+        #with self.assertRaises(TraitError):
+        #    example = new_trait_class(color.lower(), True, False)
+