Repository: numba/numba

Issue:
Numba 0.51: avoid subclassing NamedTuple in LiteralStrKeyDict
<!--

Thanks for opening an issue! To help the Numba team handle your information
efficiently, please first ensure that there is no other issue present that
already describes the issue you have
(search at https://github.com/numba/numba/issues?&q=is%3Aissue).

-->

## Reporting a bug

<!--

Before submitting a bug report please ensure that you can check off these boxes:

-->

- [x] I have tried using the latest ~released~ release candidate version of Numba (most recent is
 visible in the change log (https://github.com/numba/numba/blob/master/CHANGE_LOG).
- [ ] I have included below a minimal working reproducer (if you are unsure how
 to write one see http://matthewrocklin.com/blog/work/2018/02/28/minimal-bug-reports).

<!--

Please include details of the bug here, including, if applicable, what you
expected to happen!

-->

`LiteralStrKeyDict` type is a dictionary and semantically different than a Tuple. Subclassing `NamedTuple` can cause issues, e.g. breaking all the `instance(t, types.BaseTuple)` calls. I understand that this approach makes implementation (e.g. lowering) easier, but is there a cleaner way to avoid breaking existing code?

Discussion:

Patch:
diff --git a/numba/core/types/containers.py b/numba/core/types/containers.py
index 87f4258c9..6191852af 100644
--- a/numba/core/types/containers.py
+++ b/numba/core/types/containers.py
@@ -468,19 +468,44 @@ class List(MutableSequence, InitialValue):
                     initial_value=None)
 
 
-class LiteralList(Literal, _HeterogeneousTuple):
+class LiteralList(Literal, ConstSized, Hashable):
     """A heterogeneous immutable list (basically a tuple with list semantics).
     """
 
     mutable = False
 
     def __init__(self, literal_value):
-        _HeterogeneousTuple.is_types_iterable(literal_value)
+        self.is_types_iterable(literal_value)
         self._literal_init(list(literal_value))
         self.types = tuple(literal_value)
         self.count = len(self.types)
         self.name = "LiteralList({})".format(literal_value)
 
+    def __getitem__(self, i):
+        """
+        Return element at position i
+        """
+        return self.types[i]
+
+    def __len__(self):
+        return len(self.types)
+
+    def __iter__(self):
+        return iter(self.types)
+
+    @classmethod
+    def from_types(cls, tys):
+        return LiteralList(tys)
+
+    @staticmethod
+    def is_types_iterable(types):
+        if not isinstance(types, Iterable):
+            raise TypingError("Argument 'types' is not iterable")
+
+    @property
+    def iterator_type(self):
+        return ListIter(self)
+
     def __unliteral__(self):
         return Poison(self)
 
@@ -732,7 +757,7 @@ class DictType(IterableType, InitialValue):
         return DictType(self.key_type, self.value_type)
 
 
-class LiteralStrKeyDict(Literal, NamedTuple):
+class LiteralStrKeyDict(Literal, ConstSized, Hashable):
     """A Dictionary of string keys to heterogeneous values (basically a
     namedtuple with dict semantics).
     """
@@ -745,7 +770,10 @@ class LiteralStrKeyDict(Literal, NamedTuple):
         strkeys = [x.literal_value for x in literal_value.keys()]
         self.tuple_ty = namedtuple("_ntclazz", " ".join(strkeys))
         tys = [x for x in literal_value.values()]
-        NamedTuple.__init__(self, tys, self.tuple_ty)
+        self.types = tuple(tys)
+        self.count = len(self.types)
+        self.fields = tuple(self.tuple_ty._fields)
+        self.instance_class = self.tuple_ty
         self.name = "LiteralStrKey[Dict]({})".format(literal_value)
 
     def __unliteral__(self):
@@ -768,6 +796,12 @@ class LiteralStrKeyDict(Literal, NamedTuple):
                     d = {k: v for k, v in zip(self.literal_value.keys(), tys)}
                     return LiteralStrKeyDict(d)
 
+    def __len__(self):
+        return len(self.types)
+
+    def __iter__(self):
+        return iter(self.types)
+
     @property
     def key(self):
         # use the namedtuple fields not the namedtuple itself as it's created
diff --git a/numba/core/types/functions.py b/numba/core/types/functions.py
index 7acff943c..bdf285fca 100644
--- a/numba/core/types/functions.py
+++ b/numba/core/types/functions.py
@@ -230,7 +230,7 @@ def _unlit_non_poison(ty):
     out = unliteral(ty)
     if isinstance(out, types.Poison):
         m = f"Poison type used in arguments; got {out}"
-        raise TypingError(m)
+        raise errors.TypingError(m)
     return out
 
 
diff --git a/numba/core/typing/builtins.py b/numba/core/typing/builtins.py
index d0e443419..dc743821c 100644
--- a/numba/core/typing/builtins.py
+++ b/numba/core/typing/builtins.py
@@ -613,15 +613,43 @@ class StaticGetItemTuple(AbstractTemplate):
             ret = tup.types[idx]
         elif isinstance(idx, slice):
             ret = types.BaseTuple.from_types(tup.types[idx])
-        elif isinstance(tup, types.LiteralStrKeyDict):
-            if isinstance(idx, str):
-                lookup = tup.fields.index(idx)
-                ret = tup.types[lookup]
         if ret is not None:
             sig = signature(ret, *args)
             return sig
 
 
+@infer
+class StaticGetItemLiteralList(AbstractTemplate):
+    key = "static_getitem"
+
+    def generic(self, args, kws):
+        tup, idx = args
+        ret = None
+        if not isinstance(tup, types.LiteralList):
+            return
+        if isinstance(idx, int):
+            ret = tup.types[idx]
+        if ret is not None:
+            sig = signature(ret, *args)
+            return sig
+
+
+@infer
+class StaticGetItemLiteralStrKeyDict(AbstractTemplate):
+    key = "static_getitem"
+
+    def generic(self, args, kws):
+        tup, idx = args
+        ret = None
+        if not isinstance(tup, types.LiteralStrKeyDict):
+            return
+        if isinstance(idx, str):
+            lookup = tup.fields.index(idx)
+            ret = tup.types[lookup]
+        if ret is not None:
+            sig = signature(ret, *args)
+            return sig
+
 # Generic implementation for "not in"
 
 @infer
diff --git a/numba/core/untyped_passes.py b/numba/core/untyped_passes.py
index 673df10fc..7e90b58aa 100644
--- a/numba/core/untyped_passes.py
+++ b/numba/core/untyped_passes.py
@@ -617,7 +617,7 @@ class TransformLiteralUnrollConstListToTuple(FunctionPass):
     """
     _name = "transform_literal_unroll_const_list_to_tuple"
 
-    _accepted_types = (types.BaseTuple,)
+    _accepted_types = (types.BaseTuple, types.LiteralList)
 
     def __init__(self):
         FunctionPass.__init__(self)
@@ -740,7 +740,7 @@ class MixedContainerUnroller(FunctionPass):
 
     _DEBUG = False
 
-    _accepted_types = (types.BaseTuple,)
+    _accepted_types = (types.BaseTuple, types.LiteralList)
 
     def __init__(self):
         FunctionPass.__init__(self)
@@ -1264,7 +1264,7 @@ class IterLoopCanonicalization(FunctionPass):
     _DEBUG = False
 
     # if partial typing info is available it will only look at these types
-    _accepted_types = (types.BaseTuple,)
+    _accepted_types = (types.BaseTuple, types.LiteralList)
     _accepted_calls = (literal_unroll,)
 
     def __init__(self):
diff --git a/numba/cpython/listobj.py b/numba/cpython/listobj.py
index 003aa5908..ad3d20416 100644
--- a/numba/cpython/listobj.py
+++ b/numba/cpython/listobj.py
@@ -1215,6 +1215,13 @@ def literal_list_getitem(lst, *args):
            "statically determined.")
     raise errors.TypingError(msg)
 
+@overload(len)
+def literal_list_len(lst):
+    if not isinstance(lst, types.LiteralList):
+        return
+    l = lst.count
+    return lambda lst: l
+
 @overload(operator.contains)
 def literal_list_contains(lst, item):
     if isinstance(lst, types.LiteralList):
@@ -1224,3 +1231,14 @@ def literal_list_contains(lst, item):
                     return True
             return False
         return impl
+
+@lower_cast(types.LiteralList, types.LiteralList)
+def literallist_to_literallist(context, builder, fromty, toty, val):
+    if len(fromty) != len(toty):
+        # Disallowed by typing layer
+        raise NotImplementedError
+
+    olditems = cgutils.unpack_tuple(builder, val, len(fromty))
+    items = [context.cast(builder, v, f, t)
+             for v, f, t in zip(olditems, fromty, toty)]
+    return context.make_tuple(builder, toty, items)
diff --git a/numba/cpython/tupleobj.py b/numba/cpython/tupleobj.py
index 34739623f..131c8cfba 100644
--- a/numba/cpython/tupleobj.py
+++ b/numba/cpython/tupleobj.py
@@ -346,6 +346,8 @@ def getitem_unituple(context, builder, sig, args):
 
 
 @lower_builtin('static_getitem', types.LiteralStrKeyDict, types.StringLiteral)
+@lower_builtin('static_getitem', types.LiteralList, types.IntegerLiteral)
+@lower_builtin('static_getitem', types.LiteralList, types.SliceLiteral)
 @lower_builtin('static_getitem', types.BaseTuple, types.IntegerLiteral)
 @lower_builtin('static_getitem', types.BaseTuple, types.SliceLiteral)
 def static_getitem_tuple(context, builder, sig, args):
diff --git a/numba/typed/dictobject.py b/numba/typed/dictobject.py
index febc6ca73..9fd6ec4f1 100644
--- a/numba/typed/dictobject.py
+++ b/numba/typed/dictobject.py
@@ -1247,6 +1247,14 @@ def literalstrkeydict_impl_contains(d, k):
     return impl
 
 
+@overload(len)
+def literalstrkeydict_impl_len(d):
+    if not isinstance(d, types.LiteralStrKeyDict):
+        return
+    l = d.count
+    return lambda d: l
+
+
 @overload(operator.setitem)
 def literalstrkeydict_banned_impl_setitem(d, key, value):
     if not isinstance(d, types.LiteralStrKeyDict):


Test patch:
diff --git a/numba/tests/test_dictobject.py b/numba/tests/test_dictobject.py
index e4a3bdd5a..5e7cba0b5 100644
--- a/numba/tests/test_dictobject.py
+++ b/numba/tests/test_dictobject.py
@@ -2149,6 +2149,25 @@ class TestLiteralStrKeyDict(MemoryLeakMixin, TestCase):
 
         np.testing.assert_allclose(foo(), np.ones(3) * 10)
 
+    def test_tuple_not_in_mro(self):
+        # Related to #6094, make sure that LiteralStrKey does not inherit from
+        # types.BaseTuple as this breaks isinstance checks.
+        def bar(x):
+            pass
+
+        @overload(bar)
+        def ol_bar(x):
+            self.assertFalse(isinstance(x, types.BaseTuple))
+            self.assertTrue(isinstance(x, types.LiteralStrKeyDict))
+            return lambda x: ...
+
+        @njit
+        def foo():
+            d = {'a': 1, 'b': 'c'}
+            bar(d)
+
+        foo()
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/numba/tests/test_lists.py b/numba/tests/test_lists.py
index 0ae2bb366..9b5af95cd 100644
--- a/numba/tests/test_lists.py
+++ b/numba/tests/test_lists.py
@@ -1735,6 +1735,19 @@ class TestLiteralLists(MemoryLeakMixin, TestCase):
 
         self.assertEqual(foo.py_func(), foo())
 
+    def test_staticgetitem_slice(self):
+        # this is forbidden by typing as there's no way to serialize a list of
+        # any kind as required by returning a (static) slice of a LiteralList
+        @njit
+        def foo():
+            l = ['a', 'b', 1]
+            return l[:2]
+
+        with self.assertRaises(errors.TypingError) as raises:
+            foo()
+        expect = "Cannot __getitem__ on a literal list"
+        self.assertIn(expect, str(raises.exception))
+
     def test_setitem(self):
 
         @njit
@@ -1794,6 +1807,25 @@ class TestLiteralLists(MemoryLeakMixin, TestCase):
 
         self.assertEqual(foo(), foo.py_func())
 
+    def test_tuple_not_in_mro(self):
+        # Related to #6094, make sure that LiteralList does not inherit from
+        # types.BaseTuple as this breaks isinstance checks.
+        def bar(x):
+            pass
+
+        @overload(bar)
+        def ol_bar(x):
+            self.assertFalse(isinstance(x, types.BaseTuple))
+            self.assertTrue(isinstance(x, types.LiteralList))
+            return lambda x: ...
+
+        @njit
+        def foo():
+            l = ['a', 1]
+            bar(l)
+
+        foo()
+
 
 if __name__ == '__main__':
     unittest.main()