Repository: havok2063/fuzzy_types

Issue:
improve custom override of fuzzy mapper and choices attributes 
Currently the `mapper` attribute is set to default return `str(item)` and the `choices` property only iterates over string entries.  This fails for lists of objects where you want to fuzzy match on a string attribute of the object.  This should be improved so mapper can be more easily customized, without also overriding `choices`.  

Discussion:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 98ca4c7..1eaa744 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -4,6 +4,8 @@
 Change Log
 ==========
 
+* :release:`0.1.3 <unreleased>`
+* :feature:`3` updating FuzzyList to allow for a list of objects
 * :release:`0.1.2 <2021-06-26>`
 * :feature:`1` switch core library to rapidfuzz
 * :release:`0.1.1 <2020-04-17>`
diff --git a/docs/sphinx/conf.py b/docs/sphinx/conf.py
index 39558b4..73f8cb2 100644
--- a/docs/sphinx/conf.py
+++ b/docs/sphinx/conf.py
@@ -120,7 +120,8 @@ todo_include_todos = False
 # Intersphinx mappings
 intersphinx_mapping = {'python': ('https://docs.python.org/3.6', None),
                        'astropy': ('http://docs.astropy.org/en/latest', None),
-                       'numpy': ('http://docs.scipy.org/doc/numpy/', None)}
+                       'numpy': ('http://docs.scipy.org/doc/numpy/', None),
+                       'rapidfuzz': ('https://maxbachmann.github.io/RapidFuzz/', None)}
 
 autodoc_mock_imports = ['_tkinter']
 autodoc_member_order = 'groupwise'
diff --git a/docs/sphinx/intro.rst b/docs/sphinx/intro.rst
index a9f8d09..974eb22 100644
--- a/docs/sphinx/intro.rst
+++ b/docs/sphinx/intro.rst
@@ -124,4 +124,92 @@ You can convert a ``Fuzzy`` object back to its original type with `to_original`
     >>> type(old)
     list
 
+Fuzzy Customizations
+--------------------
+
+All ``fuzzy_types`` use `~fuzzy_types.utils.get_best_fuzzy` to perform fuzzy matching, which 
+itself wraps the `.rapidfuzz.process.extract` function.  The default fuzzy-matching function can be 
+overridden in any ``fuzzy_types`` object, by passing a new callable function into the ``use_fuzzy`` 
+keyword argument.  
+
+The `.rapidfuzz.process.extract` function used by the default `~fuzzy_types.utils.get_best_fuzzy` 
+accepts as input a list of "choices", any iterable list of string used for comparison during fuzzy
+matching.  To see the list of choices used by any ``fuzzy_types``, access the ``choices`` property, 
+e.g. `.fuzzy_types.fuzzy.FuzzyList.choices` or `.fuzzy_types.fuzzy.FuzzyBaseDict.choices`.
+::
+
+    >>> from fuzzy_types import FuzzyList
+    >>> ll = FuzzyList(['apple', 'banana', 'orange', 'pear'])
+    >>>
+    >>> # look at the list of choices passed to rapidfuzz
+    >>> ll.choices
+    ['apple', 'banana', 'orange', 'pear']
+
+The mapping between the items in a ``FuzzyList`` or ``FuzzyDict``, and the list of ``choices`` passed 
+to ``rapidfuzz``, is controlled by the base `.fuzzy_types.fuzzy.FuzzyBase.mapper` method.  By default, 
+the ``mapper`` returns an explicit string of a list's items or dict's keys, i.e. ``str(item)``.  This
+means that by default, ``FuzzyList`` operates as a list of strings. 
+  
+The ``mapper`` method can be overridden to customize how the items in your input list or dict 
+get converted into a list of fuzzy options, e.g. creating a fuzzy list of objects where you want to
+perform fuzzy matching using an attribute of the instance.  Suppose we have a class representing a
+type of toy
+::
+
+    class Toy(object):
+        """ class representing toy objects """
+        def __init__(self, name='toy'):
+            self.name = name
+            
+        def __repr__(self):
+            return f'<Toy(name="{self.name}")>'
+
+And we have a list of toys that we want to convert to a fuzzy list. 
+::
+    
+    >>> # create a list of toy objects
+    >>> toys = [Toy(n) for n in ['car', 'truck', 'top', 'ball', 'rag', 'doll']]
+    >>> toys
+    [<Toy(name="car")>, <Toy(name="truck")>, <Toy(name="top")>, <Toy(name="ball")>, <Toy(name="rag")>, <Toy(name="doll")>]
+
+    >>> create a fuzzy list of toys
+    >>> tt = FuzzyList(toys)
+
+By default ``fuzzy_types`` will map each instance's string ``repr`` to the list of choices.  This may 
+not be ideal, or even desired, for complex reprs and could confuse the fuzzy matching.
+::
+
+    >>> # view the default choices passed to rapidfuzz
+    >>> tt.choices
+    ['<Toy(name="car")>', '<Toy(name="truck")>', '<Toy(name="top")>', '<Toy(name="ball")>', '<Toy(name="rag")>', '<Toy(name="doll")>']
+
+To instead customize the ``FuzzyList`` of ``Toys`` to use the ``name`` attribute for all fuzzy matching, 
+we can create a new custom ``FuzzyList`` class overriding the default ``mapper`` method.
+::
+
+    class FuzzyToy(FuzzyList):
+        """ custom fuzzy list for toys """
+
+        @staticmethod
+        def mapper(item):
+            """ overridden mapper to return the toy's name """
+            return str(item.name)
+
+Now we create a new list of ``FuzzyToy`` where the ``choices`` are mapped to the correct attribute 
+::
+
+    >>> # create a new fuzzy list of toys
+    >>> tt = FuzzyToy(toys)
+    >>> tt
+    [<Toy(name="car")>, <Toy(name="truck")>, <Toy(name="top")>, <Toy(name="ball")>, <Toy(name="rag")>, <Toy(name="doll")>]
+
+    >>> # view the fuzzy choices passed to rapidfuzz
+    >>> tt.choices
+    ['car', 'truck', 'top', 'ball', 'rag', 'doll']
+
+Overriding the ``mapper`` also controls what is shown as a dottable attribute.
+::
 
+    >>> # each Toy.name is added as a dottable attribute
+    >>> tt.doll
+    <Toy(name="doll")>
\ No newline at end of file
diff --git a/fuzzy_types/configuration.py b/fuzzy_types/configuration.py
index 1af5a0a..d3acf53 100644
--- a/fuzzy_types/configuration.py
+++ b/fuzzy_types/configuration.py
@@ -10,12 +10,13 @@ import inspect
 import os
 import pathlib
 import yaml
+from typing import Union
 
 
 __all__ = ['read_yaml_file', 'merge_config', 'get_config']
 
 
-def read_yaml_file(path):
+def read_yaml_file(path: Union[str, pathlib.Path]) -> dict:
     """Read a YAML file and returns a dictionary."""
 
     if isinstance(path, (str, pathlib.Path)):
@@ -28,7 +29,7 @@ def read_yaml_file(path):
     return config
 
 
-def merge_config(user, default):
+def merge_config(user: dict, default: dict) -> dict:
     """Merges a user configuration with the default one."""
 
     if isinstance(user, dict) and isinstance(default, dict):
@@ -41,8 +42,8 @@ def merge_config(user, default):
     return user
 
 
-def get_config(name, config_file=None, allow_user=True, user_path=None,
-               config_envvar=None, merge_mode='update'):
+def get_config(name: str, config_file: str = None, allow_user: bool = True, 
+               user_path: str = None, config_envvar: str = None, merge_mode: str = 'update') -> dict:
     """Returns a configuration dictionary.
 
     The configuration dictionary is created by merging the default
diff --git a/fuzzy_types/fuzzy.py b/fuzzy_types/fuzzy.py
index 8786dca..aa301e1 100644
--- a/fuzzy_types/fuzzy.py
+++ b/fuzzy_types/fuzzy.py
@@ -15,21 +15,31 @@ from __future__ import print_function, division, absolute_import
 from collections import OrderedDict
 
 import abc
+import inspect
 import six
 from fuzzy_types.utils import get_best_fuzzy
+from typing import Callable, Union, TypeVar
 
-__all__ = ['FuzzyList', 'FuzzyDict', 'FuzzyOrderedDict', 'FuzzyStr']
+__all__ = ['FuzzyBase', 'FuzzyBaseDict', 'FuzzyList', 'FuzzyDict', 'FuzzyOrderedDict', 'FuzzyStr']
 
+# types
+FL = TypeVar('FL', bound='FuzzyList')
+FD = TypeVar('FD', bound='FuzzyDict')
+FOD = TypeVar('FOD', bound='FuzzyOrderedDict')
+FS = TypeVar('FS', bound='FuzzyStr')
+AF = Union[FL, FD, FOD, FS]
 
 class FuzzyBase(abc.ABC):
+    """ Abstract Base Class for all Fuzzy objects """
     _base = None
 
-    def __init__(self, the_items, use_fuzzy=None, dottable=True):
+    def __init__(self, the_items: Union[list, dict], use_fuzzy: Callable = None, 
+                 dottable: bool = True):
         self.use_fuzzy = use_fuzzy or get_best_fuzzy
         self._dottable = dottable
         self._base.__init__(self, the_items)
 
-    def __getattr__(self, value):
+    def __getattr__(self, value: Union[str, int, object]):
         if self._dottable is False:
             raise AttributeError(f"'{self._base.__name__}' object has no attribute '{value}'")
 
@@ -42,19 +52,35 @@ class FuzzyBase(abc.ABC):
         pass
 
     @abc.abstractmethod
-    def __dir__(self):
-        members = object.__dir__(self)
-        return members
+    def __dir__(self) -> list:
+        return object.__dir__(self)
 
     @staticmethod
-    def mapper(item):
+    def mapper(item: Union[str, object]) -> str:
+        """ Mapper between list/dict item and rapidfuzz choices  
+
+        Static method used to map a list's items or dict's keys to a
+        string representation used by ``rapidfuzz`` for.  By default returns an
+        explicit string case of the item.  To see the output, view the ``choices``
+        property.  Can be overridden to customize what is input into ``rapidfuzz``. 
+
+        Parameters
+        ----------
+        item : Union[str, object]
+            Any iterable item, i.e. a list item or dictionary key
+
+        Returns
+        -------
+        str
+            The string representation to use in the choices supplied to ``rapidfuzz``
+        """
         return str(item)
 
     @abc.abstractproperty
     def choices(self):
         pass
 
-    def __contains__(self, value):
+    def __contains__(self, value: Union[str, int, object]) -> bool:
         if not isinstance(value, six.string_types):
             return super(FuzzyBase, self).__contains__(value)
 
@@ -65,103 +91,140 @@ class FuzzyBase(abc.ABC):
 
         return best in self.choices
 
-    def copy(self):
+    def copy(self) -> AF:
+        """ Returns a copy of the fuzzy instance
+
+        Returns
+        -------
+        Union[list, dict]
+            A copy of the fuzzy instance
+        """
         if self._base == OrderedDict:
             kopied = dict(self)
         else:
             kopied = self._base.copy(self)
         return self.__class__(kopied, use_fuzzy=self.use_fuzzy, dottable=self._dottable)
 
-    def to_original(self):
-        ''' Convert fuzzy object back to original Python datatype '''
+    def to_original(self) -> Union[list, dict]:
+        """ Convert fuzzy object back to original Python datatype """
         return self._base(self)
 
 
 class FuzzyBaseDict(FuzzyBase):
 
-    def __init__(self, the_dict, use_fuzzy=None, dottable=True):
+    def __init__(self, the_dict: dict, use_fuzzy: Callable = None, dottable: bool = True):
         super(FuzzyBaseDict, self).__init__(the_dict, use_fuzzy=use_fuzzy, dottable=dottable)
         # in case a value is another dictionary; also make it fuzzy
         for key, val in the_dict.items():
             if isinstance(val, dict):
                 self[key] = self.__class__(val)
 
-    def __getitem__(self, value):
+    def __getitem__(self, value: Union[int, str]):
         if not isinstance(value, six.string_types):
             return self.get(value)
 
         best = self.use_fuzzy(value, self.choices)
         return self._base.__getitem__(self, best)
 
-    def __dir__(self):
+    def __dir__(self) -> list:
         members = super(FuzzyBaseDict, self).__dir__()
         if self._dottable is True:
             members.extend(self.choices)
         return members
 
     @property
-    def choices(self):
-        return [self.mapper(i) for i in self.keys() if isinstance(i, six.string_types)]
+    def choices(self) -> list:
+        """ A list of choices used during fuzzy matching
+
+        The list of choices passes into ``rapidfuzz`` to use 
+        for fuzzy-matching a string.  The list of choices is computed
+        by iterating over the dictionary keys, passing each item through
+        the `~FuzzyBase.mapper` method. 
+
+        Returns
+        -------
+        list
+            The list of options used by ``rapidfuzz`` when fuzzy matching
+        """
+        return [self.mapper(i) for i in self.keys()]
 
 
 class FuzzyDict(FuzzyBaseDict, dict):
-    ''' A dotable dictionary that uses fuzzywuzzy to select the key.
-
-    Parameters:
-        the_items (dict):
-            A dictionary of items to make fuzzy
-        use_fuzzy (func):
-            The function used to perform the fuzzy-matching.
-            Default is :func:`fuzzy_types.utils.get_best_fuzzy`.
-        dottable (bool):
-            If False, turns off dottable attributes.  Default is True.
-
-    Returns:
+    """ A dotable dictionary that uses rapidfuzz to select the key.
+
+    Parameters
+    ----------
+    the_items : dict
+        A dictionary of items to make fuzzy
+    use_fuzzy : Callable 
+        The function used to perform the fuzzy-matching.
+        Default is :func:`fuzzy_types.utils.get_best_fuzzy`.
+    dottable : bool
+        If False, turns off dottable attributes.  Default is True.
+
+    Returns
+    -------
         A python dictionary with fuzzy keys
-    '''
+    """
     _base = dict
 
 
 class FuzzyOrderedDict(FuzzyBaseDict, OrderedDict):
-    ''' A dotable ordered dictionary that uses fuzzywuzzy to select the key.
-
-    Parameters:
-        the_items (dict):
-            A dictionary of items to make fuzzy
-        use_fuzzy (func):
-            The function used to perform the fuzzy-matching.
-            Default is :func:`fuzzy_types.utils.get_best_fuzzy`.
-        dottable (bool):
-            If False, turns off dottable attributes.  Default is True.
-
-    Returns:
+    """ A dotable ordered dictionary that uses rapidfuzz to select the key.
+
+    Parameters
+    ----------
+    the_items : dict
+        A dictionary of items to make fuzzy
+    use_fuzzy : Callable 
+        The function used to perform the fuzzy-matching.
+        Default is :func:`fuzzy_types.utils.get_best_fuzzy`.
+    dottable : bool
+        If False, turns off dottable attributes.  Default is True.
+
+    Returns
+    -------
         A python ordered dictionary with fuzzy keys
 
-    '''
+    """
     _base = OrderedDict
 
 
 class FuzzyList(FuzzyBase, list):
-    ''' A dottable python list that uses fuzzywuzzy to select a string item
-
-    Parameters:
-        the_items (list):
-            A list of items to make fuzzy
-        use_fuzzy (func):
-            The function used to perform the fuzzy-matching.
-            Default is :func:`fuzzy_types.utils.get_best_fuzzy`.
-        dottable (bool):
-            If False, turns off dottable attributes.  Default is True.
-
-    Returns:
+    """ A dottable python list that uses rapidfuzz to select a string item
+
+    Parameters
+    ----------
+    the_items : list
+        A list of items to make fuzzy
+    use_fuzzy : Callable 
+        The function used to perform the fuzzy-matching.
+        Default is :func:`fuzzy_types.utils.get_best_fuzzy`.
+    dottable : bool
+        If False, turns off dottable attributes.  Default is True.
+
+    Returns
+    -------
         A python list with fuzzy items
 
-    '''
+    """
     _base = list
 
     @property
-    def choices(self):
-        return [self.mapper(item) for item in self if isinstance(item, six.string_types)]
+    def choices(self) -> list:
+        """ A list of choices used during fuzzy matching
+
+        The list of choices passes into ``rapidfuzz`` to use 
+        for fuzzy-matching a string.  The list of choices is computed
+        by iterating over the list items, passing each item through
+        the `~FuzzyBase.mapper` method. 
+
+        Returns
+        -------
+        list
+            The list of options used by ``rapidfuzz`` when fuzzy matching
+        """
+        return [self.mapper(item) for item in self]
 
     def __getitem__(self, value):
         if not isinstance(value, six.string_types):
@@ -170,7 +233,7 @@ class FuzzyList(FuzzyBase, list):
         best = self.use_fuzzy(value, self.choices)
         return self[self.choices.index(best)]
 
-    def __dir__(self):
+    def __dir__(self) -> list:
         members = super(FuzzyList, self).__dir__()
         if self._dottable is True:
             members.extend(self.choices)
@@ -178,22 +241,23 @@ class FuzzyList(FuzzyBase, list):
 
 
 class FuzzyStr(str):
-    ''' A fuzzy string that uses fuzzywuzzy for equality checks
-
-    Parameters:
-        the_string (str):
-            A string to make fuzzy
-        use_fuzzy (func):
-            The function used to perform the fuzzy-matching.
-            Default is :func:`fuzzy_types.utils.get_best_fuzzy`.
-    '''
+    """ A fuzzy string that uses rapidfuzz for equality checks
+
+    Parameters
+    ----------
+    the_string : str
+        A string to make fuzzy
+    use_fuzzy : Callable
+        The function used to perform the fuzzy-matching.
+        Default is :func:`fuzzy_types.utils.get_best_fuzzy`.
+    """
     _base = str
 
-    def __init__(self, the_string, use_fuzzy=None):
+    def __init__(self, the_string: str, use_fuzzy: Callable = None):
         self.use_fuzzy = use_fuzzy or get_best_fuzzy
         self._base.__init__(the_string)
 
-    def __eq__(self, value):
+    def __eq__(self, value: str) -> bool:
         try:
             self.use_fuzzy(value, [self])
         except ValueError:
diff --git a/fuzzy_types/utils.py b/fuzzy_types/utils.py
index 29a8e63..32e7b34 100644
--- a/fuzzy_types/utils.py
+++ b/fuzzy_types/utils.py
@@ -16,22 +16,35 @@ import six
 from rapidfuzz import fuzz as fuzz_fuzz
 from rapidfuzz import process as fuzz_proc
 from fuzzy_types import config
+from typing import Callable, Union
 
 
-def get_best_fuzzy(value, choices, min_score=None, scorer=fuzz_fuzz.WRatio, return_score=False):
-    """ Returns the best match in a list of choices using fuzzywuzzy.
-
-    Parameters:
-        value (str):
-            A string to match on
-        choices (list):
-            A list of string choices to match from
-        min_score (int):
-            The score cutoff threshold. The minimum score to consider when matching.
-        scorer (fuzzywuzzy.Ratio):
-            The fuzzywuzzy score ratio to use.  Default is WRatio.
-        return_score (bool):
-            If True, also returns the score value of the match
+def get_best_fuzzy(value: str, choices: list, min_score: int = None, 
+                   scorer: Callable = fuzz_fuzz.WRatio, return_score: bool = False) -> Union[None, str]:
+    """ Returns the best match in a list of choices using rapidfuzz.
+
+    Parameters
+    ----------
+    value : str
+        A string to match on
+    choices : list
+        A list of string choices to match from
+    min_score : int
+        The score cutoff threshold. The minimum score to consider when matching. By default, None
+    scorer : Callable
+        The rapidfuzz score ratio to use.  By default, WRatio.
+    return_score : bool
+        If True, also returns the score value of the match.  By default, False.
+        
+    Returns
+    -------
+    Union[None, str]
+        Either None if no matches found above score, or the best match from list of choices 
+          
+    Raises
+    ------
+    ValueError
+        when rapidfuzz cannot find a single best match
     """
 
     assert isinstance(value, six.string_types), 'Invalid value. Must be a string.'


Test patch:
diff --git a/tests/test_fuzzydict.py b/tests/test_fuzzydict.py
index 1e9e87a..b0f221e 100644
--- a/tests/test_fuzzydict.py
+++ b/tests/test_fuzzydict.py
@@ -72,7 +72,13 @@ class TestDict(object):
         kopy = dd.copy()
         assert isinstance(kopy, kls)
         
-        
+    def test_fuzzy_mix(self):
+        fd = FuzzyDict({1:2, 'adfdf':5, 'stuff':'hello'})
+        assert fd[1] == 2
+        assert fd['afd'] == 5
+        assert fd['stu'] == 'hello'
+        assert fd['stuff'] == 'hello'
+                
 class TestDictFails(object):
     
     @pytest.mark.parametrize('key', [('mandarin'), ('appl')], ids=['nokey', 'fuzzykey'])
diff --git a/tests/test_fuzzylist.py b/tests/test_fuzzylist.py
index 69a3095..6b42de3 100644
--- a/tests/test_fuzzylist.py
+++ b/tests/test_fuzzylist.py
@@ -19,6 +19,24 @@ from fuzzy_types.fuzzy import FuzzyList
 real = ['apple', 'banana', 'orange', 'pear']
 fuzzy = FuzzyList(real)
 
+class Toy(object):
+    """ class representing toy objects """
+    def __init__(self, name='toy'):
+        self.name = name
+        
+    def __repr__(self):
+        return f'<Toy({self.name})>'
+
+# create a fake list of toy objects
+toys = [Toy(n) for n in ['car', 'truck', 'top', 'ball', 'rag', 'doll']]
+
+
+class FuzzyToy(FuzzyList):
+    """ custom fuzzy toy class with overridden mapper method """    
+    @staticmethod
+    def mapper(item):
+        return str(item.name)
+
 
 def assert_exact(dd):
     assert 'apple' in dd
@@ -63,8 +81,22 @@ class TestList(object):
     def test_tooriginal(self):
         orig = fuzzy.to_original()
         assert isinstance(orig, list)
-
-
+        
+    def test_fuzzy_object_default_mapper(self):
+        fd = FuzzyList(toys)
+        assert 'car' in fd
+        assert 'car' not in fd.choices
+        assert fd[3] == toys[3]
+        assert fd.choices[0] == '<Toy(car)>'
+
+    def test_fuzzy_object_custom_mapper(self):
+        fd = FuzzyToy(toys)
+        assert 'car' in fd
+        assert 'car' in fd.choices
+        assert fd['car'] == toys[0]
+        assert fd[3] == toys[3]
+        assert fd.choices[0] == 'car'
+        assert fd['raagg'] == toys[4]
 class TestListFails(object):
 
     @pytest.mark.parametrize('item', [('mandarin'), ('apple')], ids=['noitem', 'fuzzyitem'])