Repository: scikit-hep/uproot5

Issue:
KeyInFileError to show near matches?
`KeyInFileError` currently shows a truncating "Known keys" list. This cool regarding the general format of what to ask for instead, but since we quite commonly have files with a large number of branches one still needs to go back to some kind of `[key for key in f.keys() if 'bla' in key]` to find the correct name.

It would be really cool if this would instead show closest matches first. I guess this could get computationally pricey, i.e. slow, so maybe it would only get computed if the number of keys was not too crazy? 


Discussion:
This is a good idea. Since it's already in an exception, speed is not an issue (especially since the time it would take to sort a list of O(<1000) strings by similarity is not much compared to the other work that's going on).

I looked around for hints on how to do this and found a variety of packages, but we don't want to _any_ new dependencies into Uproot, least of all for this task. (I'm imagining a dialogue: "Why is it so hard to install Uproot in my weird DAQ environment?" "Because you need a fuzz package to sort key mismatches by similarity in KeyErrors.") But I came across this recipe for manually calculating the Levenshtein similarity ratio:

https://www.datacamp.com/community/tutorials/fuzzy-string-python

It's a 24 line function. That's not a significant maintenance burden for what it offers. There's a natural place for it in the `_util` module, which consists of private functions that we can always replace for something else later. Since the output is not programmatic, we don't have to worry that someone's going to depend on the exact order; if we want to change it later, that's still an option. (The exact character strings of an error message is not part of the API.) Also, I wouldn't implement it with a NumPy array as they have it; I'd use a Python list. There are no array-at-a-time function calls here; the NumPy array is nothing but an impediment that might complicate the use of Unicode.

If we were to go with this, I'd still show as many matches as fit on the screen (i.e. the current cut-off), but just sorted by similarity so that the one the user was looking for is unlikely to be after the cut-off.
Yup, that's pretty much exactly the ask.
Just thinking it through and setting parameters (e.g. no new dependencies). I used conditional tense because I don't think I'll be doing it soon, but if it's still open someday when I'm looking for an hour-long project, I'll do it, or if someone wanted to take it up, they could. Actually, I'll label it as a "good first project" because it doesn't require deep knowledge of the machinery: it appears in only one place.

(I do want to move the KeyInFileError (and all code, including `behavior_of`) out of the main `__init__` and just import them there, so that would interfere with this if they were being done at the same time. But that's not hard coordination.)
Just going off the algorithm on wiki, this is pretty straightforward

```
def damerau_levenshtein(a, b):
    M = [[0]*(len(a)+1) for i in range(len(b)+1)]
    
    for i in range(len(a)):
        M[i][0] = i
    for j in range(len(b)):
        M[0][j] = j
        
    for i in range(len(a)):
        for j in range(len(b)):
            if a[i] == b[j]:
                cost = 0
            else:
                cost = 2
            M[i][j] = min(M[i-1][j] + 1,
                          M[i][j-1] + 1,
                          M[i-1][j-1] + cost
                             )
            if i > 1 and j > 1 and a[i] == b[j-1] and a[i-1] == b[j]:
                M[i][j] = min(M[i][j], M[i-2][j-2] + 1)
        
    return M[len(a)-1][len(b)-1]

print(damerau_levenshtein("Beer", "Love"))
print(damerau_levenshtein("Beer", "Bear"))
print(damerau_levenshtein("Beer", "Bee"))
print(damerau_levenshtein("Beer", "Beers"))
print(damerau_levenshtein("Beer", "Cola"))

>>> 3
>>> 2
>>> 1
>>> 1
>>> 4
```

Any concerns about putting this in a PR?

Resolving patch:
diff --git a/uproot4/__init__.py b/uproot4/__init__.py
--- a/uproot4/__init__.py
+++ b/uproot4/__init__.py
@@ -102,6 +102,8 @@
 from uproot4.reading import ReadOnlyFile
 from uproot4.reading import ReadOnlyDirectory
 
+from uproot4.exceptions import KeyInFileError
+
 from uproot4.model import Model
 from uproot4.model import classname_decode
 from uproot4.model import classname_encode
@@ -209,79 +211,4 @@ def behavior_of(classname):
 
 del pkgutil
 
-
-class KeyInFileError(KeyError):
-    """
-    Exception raised by attempts to find ROOT objects in ``TDirectories``
-    or ``TBranches`` in :py:class:`~uproot4.behaviors.TBranch.HasBranches`, which
-    both have a Python ``Mapping`` interface (square bracket syntax to extract
-    items).
-
-    This exception descends from Python's ``KeyError``, so it can be used in
-    the normal way by interfaces that expect a missing item in a ``Mapping``
-    to raise ``KeyError``, but it provides more information, depending on
-    availability:
-
-    * ``because``: an explanatory message
-    * ``cycle``: the ROOT cycle number requested, if any
-    * ``keys``: a list or partial list of keys that *are* in the object, in case
-      of misspelling
-    * ``file_path``: a path (or URL) to the file
-    * ``object_path``: a path to the object within the ROOT file.
-    """
-
-    def __init__(
-        self, key, because="", cycle=None, keys=None, file_path=None, object_path=None
-    ):
-        super(KeyInFileError, self).__init__(key)
-        self.key = key
-        self.because = because
-        self.cycle = cycle
-        self.keys = keys
-        self.file_path = file_path
-        self.object_path = object_path
-
-    def __str__(self):
-        if self.because == "":
-            because = ""
-        else:
-            because = " because " + self.because
-
-        with_keys = ""
-        if self.keys is not None:
-            to_show = None
-            for key in self.keys:
-                if to_show is None:
-                    to_show = repr(key)
-                else:
-                    to_show += ", " + repr(key)
-                if len(to_show) > 200:
-                    to_show += "..."
-                    break
-            if to_show is None:
-                to_show = "(none!)"
-            with_keys = "\n\n    Known keys: {0}\n".format(to_show)
-
-        in_file = ""
-        if self.file_path is not None:
-            in_file = "\nin file {0}".format(self.file_path)
-
-        in_object = ""
-        if self.object_path is not None:
-            in_object = "\nin object {0}".format(self.object_path)
-
-        if self.cycle == "any":
-            return """not found: {0} (with any cycle number){1}{2}{3}{4}""".format(
-                repr(self.key), because, with_keys, in_file, in_object
-            )
-        elif self.cycle is None:
-            return """not found: {0}{1}{2}{3}{4}""".format(
-                repr(self.key), because, with_keys, in_file, in_object
-            )
-        else:
-            return """not found: {0} with cycle {1}{2}{3}{4}{5}""".format(
-                repr(self.key), self.cycle, because, with_keys, in_file, in_object
-            )
-
-
 from uproot4._util import no_filter
diff --git a/uproot4/_util.py b/uproot4/_util.py
--- a/uproot4/_util.py
+++ b/uproot4/_util.py
@@ -635,3 +635,48 @@ def awkward_form_of_iter(awkward1, form):
         )
     else:
         raise RuntimeError("unrecognized form: {0}".format(type(form)))
+
+
+def damerau_levenshtein(a, b, ratio=False):
+    # Modified Damerau-Levenshtein distance. Adds a middling penalty
+    # for capitalization.
+    # https://en.wikipedia.org/wiki/Damerau%E2%80%93Levenshtein_distance
+    M = [[0] * (len(b) + 1) for i in range(len(a) + 1)]
+
+    for i in range(len(a) + 1):
+        M[i][0] = i
+    for j in range(len(b) + 1):
+        M[0][j] = j
+
+    for i in range(1, len(a) + 1):
+        for j in range(1, len(b) + 1):
+            if a[i - 1] == b[j - 1]:  # Same char
+                cost = 0
+            elif a[i - 1].lower() == b[j - 1].lower():  # Same if lowered
+                cost = 0.5
+            else:  # Different char
+                cost = 2
+            M[i][j] = min(
+                M[i - 1][j] + 1,  # Addition
+                M[i][j - 1] + 1,  # Removal
+                M[i - 1][j - 1] + cost,  # Substitution
+            )
+
+            # Transposition
+            if (
+                i > 1
+                and j > 1
+                and a[i - 1].lower() == b[j - 2].lower()
+                and a[i - 2].lower() == b[j - 2].lower()
+            ):
+                if a[i - 1] == b[j - 2] and a[i - 2] == b[j - 1]:
+                    # Transpose only
+                    M[i][j] = min(M[i][j], M[i - 2][j - 2] + 1)
+                else:
+                    # Traspose and capitalization
+                    M[i][j] = min(M[i][j], M[i - 2][j - 2] + 1.5)
+
+    if not ratio:
+        return M[len(a)][len(b)]
+    else:
+        return (len(a) + len(b)) - M[len(a)][len(b)] / (len(a) + len(b))
diff --git a/uproot4/exceptions.py b/uproot4/exceptions.py
new file mode 100644
--- /dev/null
+++ b/uproot4/exceptions.py
@@ -0,0 +1,76 @@
+from uproot4._util import damerau_levenshtein
+
+
+class KeyInFileError(KeyError):
+    """
+    Exception raised by attempts to find ROOT objects in ``TDirectories``
+    or ``TBranches`` in :py:class:`~uproot4.behaviors.TBranch.HasBranches`, which
+    both have a Python ``Mapping`` interface (square bracket syntax to extract
+    items).
+
+    This exception descends from Python's ``KeyError``, so it can be used in
+    the normal way by interfaces that expect a missing item in a ``Mapping``
+    to raise ``KeyError``, but it provides more information, depending on
+    availability:
+
+    * ``because``: an explanatory message
+    * ``cycle``: the ROOT cycle number requested, if any
+    * ``keys``: a list or partial list of keys that *are* in the object, in case
+      of misspelling
+    * ``file_path``: a path (or URL) to the file
+    * ``object_path``: a path to the object within the ROOT file.
+    """
+
+    def __init__(
+        self, key, because="", cycle=None, keys=None, file_path=None, object_path=None
+    ):
+        super(KeyInFileError, self).__init__(key)
+        self.key = key
+        self.because = because
+        self.cycle = cycle
+        self.keys = keys
+        self.file_path = file_path
+        self.object_path = object_path
+
+    def __str__(self):
+        if self.because == "":
+            because = ""
+        else:
+            because = " because " + self.because
+
+        with_keys = ""
+        if self.keys is not None:
+            to_show = None
+            sorted_keys = sorted(self.keys, key=lambda x: damerau_levenshtein(self.key, x))
+            for key in sorted_keys:
+                if to_show is None:
+                    to_show = repr(key)
+                else:
+                    to_show += ", " + repr(key)
+                if len(to_show) > 200:
+                    to_show += "..."
+                    break
+            if to_show is None:
+                to_show = "(none!)"
+            with_keys = "\n\n    Available keys: {0}\n".format(to_show)
+
+        in_file = ""
+        if self.file_path is not None:
+            in_file = "\nin file {0}".format(self.file_path)
+
+        in_object = ""
+        if self.object_path is not None:
+            in_object = "\nin object {0}".format(self.object_path)
+
+        if self.cycle == "any":
+            return """not found: {0} (with any cycle number){1}{2}{3}{4}""".format(
+                repr(self.key), because, with_keys, in_file, in_object
+            )
+        elif self.cycle is None:
+            return """not found: {0}{1}{2}{3}{4}""".format(
+                repr(self.key), because, with_keys, in_file, in_object
+            )
+        else:
+            return """not found: {0} with cycle {1}{2}{3}{4}{5}""".format(
+                repr(self.key), self.cycle, because, with_keys, in_file, in_object
+            )


Test patch: