diff --git a/.pylintrc-local.yml b/.pylintrc-local.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d0095f0e8d86afb8db0f898e9c9e721249da5592
--- /dev/null
+++ b/.pylintrc-local.yml
@@ -0,0 +1,2 @@
+- arg: extension-pkg-whitelist
+  val: mayavi
diff --git a/examples/curve-pot.py b/examples/curve-pot.py
index 8d1a91a21d5bf543393942839e56d8f2e6332262..023bd7e19bb69b619db9003c2f30111ec23e80ba 100644
--- a/examples/curve-pot.py
+++ b/examples/curve-pot.py
@@ -25,11 +25,11 @@ def process_kernel(knl, what_operator):
     if what_operator == "S":
         pass
     elif what_operator == "S0":
-        from sumpy.kernel import TargetDerivative
-        target_knl = TargetDerivative(0, knl)
+        from sumpy.kernel import AxisTargetDerivative
+        target_knl = AxisTargetDerivative(0, knl)
     elif what_operator == "S1":
-        from sumpy.kernel import TargetDerivative
-        target_knl = TargetDerivative(1, knl)
+        from sumpy.kernel import AxisTargetDerivative
+        target_knl = AxisTargetDerivative(1, knl)
     elif what_operator == "D":
         from sumpy.kernel import DirectionalSourceDerivative
         source_knl = DirectionalSourceDerivative(knl)
diff --git a/sumpy/codegen.py b/sumpy/codegen.py
index ef7e059a10397e58819761e62473b858b1280695..147dc4992b81873b4ddb089b3f3c7a6ecce58cc9 100644
--- a/sumpy/codegen.py
+++ b/sumpy/codegen.py
@@ -667,8 +667,6 @@ def to_loopy_insns(assignments, vector_names=frozenset(), pymbolic_expr_maps=(),
     bik = BigIntegerKiller()
     cmr = ComplexRewriter(complex_dtype)
 
-    cmb_mapper = combine_mappers(bdr, btog, vcr, pwr, ssg, bik, cmr)
-
     if 0:
         # https://github.com/inducer/sumpy/pull/40#issuecomment-852635444
         cmb_mapper = combine_mappers(bdr, btog, vcr, pwr, ssg, bik, cmr)
diff --git a/sumpy/e2e.py b/sumpy/e2e.py
index 253f855928ee36b816f8e8a2b081d11c219db7a5..bcb02f126a0040329947104e9f9fe268a1efd38a 100644
--- a/sumpy/e2e.py
+++ b/sumpy/e2e.py
@@ -26,7 +26,7 @@ import sumpy.symbolic as sym
 import pymbolic
 
 from loopy.version import MOST_RECENT_LANGUAGE_VERSION
-from sumpy.tools import KernelCacheWrapper, to_complex_dtype
+from sumpy.tools import KernelCacheMixin, to_complex_dtype
 from pytools import memoize_method
 
 import logging
@@ -48,7 +48,9 @@ Expansion-to-expansion
 
 # {{{ translation base class
 
-class E2EBase(KernelCacheWrapper):
+class E2EBase(KernelCacheMixin):
+    default_name = "e2e"
+
     def __init__(self, ctx, src_expansion, tgt_expansion,
             name=None, device=None):
         """
@@ -130,6 +132,9 @@ class E2EBase(KernelCacheWrapper):
                 self.tgt_expansion,
         )
 
+    def get_kernel(self):
+        raise NotImplementedError
+
     def get_optimized_kernel(self):
         # FIXME
         knl = self.get_kernel()
diff --git a/sumpy/e2p.py b/sumpy/e2p.py
index 0f0e1d166a3f3bae3157cbdc170cfd1d49e9d0d5..34bdd5f520cf077301b8f0d487bac87f346f8737 100644
--- a/sumpy/e2p.py
+++ b/sumpy/e2p.py
@@ -24,7 +24,7 @@ import numpy as np
 import loopy as lp
 import sumpy.symbolic as sym
 
-from sumpy.tools import KernelCacheWrapper
+from sumpy.tools import KernelCacheMixin
 from loopy.version import MOST_RECENT_LANGUAGE_VERSION
 
 
@@ -42,7 +42,9 @@ Expansion-to-particle
 
 # {{{ E2P base class
 
-class E2PBase(KernelCacheWrapper):
+class E2PBase(KernelCacheMixin):
+    default_name = "e2p"
+
     def __init__(self, ctx, expansion, kernels,
             name=None, device=None):
         """
diff --git a/sumpy/expansion/level_to_order.py b/sumpy/expansion/level_to_order.py
index 5d00c5b01642a5c821ce0d0237a9a7d1313227aa..82db1aef2ed1f5253a8e39342ce6249fbb7f313e 100644
--- a/sumpy/expansion/level_to_order.py
+++ b/sumpy/expansion/level_to_order.py
@@ -48,38 +48,44 @@ class FMMLibExpansionOrderFinder:
         self.extra_order = extra_order
 
     def __call__(self, kernel, kernel_args, tree, level):
-        import pyfmmlib
-
+        from pyfmmlib import (          # pylint: disable=no-name-in-module
+            l2dterms, l3dterms, h2dterms, h3dterms)
         from sumpy.kernel import LaplaceKernel, HelmholtzKernel
 
-        assert isinstance(kernel, (LaplaceKernel, HelmholtzKernel))
-        assert tree.dimensions in (2, 3)
-
         if isinstance(kernel, LaplaceKernel):
             if tree.dimensions == 2:
-                nterms, ier = pyfmmlib.l2dterms(self.tol)
+                nterms, ier = l2dterms(self.tol)
                 if ier:
                     raise RuntimeError(f"l2dterms returned error code '{ier}'")
 
             elif tree.dimensions == 3:
-                nterms, ier = pyfmmlib.l3dterms(self.tol)
+                nterms, ier = l3dterms(self.tol)
                 if ier:
                     raise RuntimeError(f"l3dterms returned error code '{ier}'")
 
+            else:
+                raise ValueError(f"unsupported dimension: {tree.dimensions}")
+
         elif isinstance(kernel, HelmholtzKernel):
             helmholtz_k = dict(kernel_args)[kernel.helmholtz_k_name]
             size = tree.root_extent / 2 ** level
 
             if tree.dimensions == 2:
-                nterms, ier = pyfmmlib.h2dterms(size, helmholtz_k, self.tol)
+                nterms, ier = h2dterms(size, helmholtz_k, self.tol)
                 if ier:
                     raise RuntimeError(f"h2dterms returned error code '{ier}'")
 
             elif tree.dimensions == 3:
-                nterms, ier = pyfmmlib.h3dterms(size, helmholtz_k, self.tol)
+                nterms, ier = h3dterms(size, helmholtz_k, self.tol)
                 if ier:
                     raise RuntimeError(f"h3dterms returned error code '{ier}'")
 
+            else:
+                raise ValueError(f"unsupported dimension: {tree.dimensions}")
+
+        else:
+            raise TypeError(f"unsupported kernel: '{type(kernel).__name__}'")
+
         return nterms + self.extra_order
 
 
diff --git a/sumpy/kernel.py b/sumpy/kernel.py
index ba7282c03f08ddb5f322a9c880b1ac9caad4d351..70dd14e25abf3feda2f184237ead5f33f471cb33 100644
--- a/sumpy/kernel.py
+++ b/sumpy/kernel.py
@@ -20,6 +20,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 THE SOFTWARE.
 """
 
+from typing import ClassVar, Tuple
 
 import loopy as lp
 import numpy as np
@@ -111,7 +112,7 @@ class KernelArgument:
         # Needed for python2
         return not self == other
 
-    def __hash__(self):
+    def __hash__(self):                 # pylint: disable=invalid-hash-returned
         return (type(self), self.loopy_arg)
 
 
@@ -136,6 +137,8 @@ class Kernel:
     .. automethod:: get_source_args
     """
 
+    init_arg_names: ClassVar[Tuple[str, ...]]
+
     def __init__(self, dim):
         self.dim = dim
 
@@ -164,6 +167,9 @@ class Kernel:
         key_hash.update(type(self).__name__.encode("utf8"))
         key_builder.rec(key_hash, self.__getinitargs__())
 
+    def __getinitargs__(self):
+        return (self.dim,)
+
     def __getstate__(self):
         return self.__getinitargs__()
 
@@ -1083,6 +1089,7 @@ class _VectorIndexAdder(CSECachingMapperMixin, IdentityMapper):
 
 
 class DirectionalDerivative(DerivativeBase):
+    directional_kind: ClassVar[str]
     init_arg_names = ("inner_kernel", "dir_vec_name")
 
     def __init__(self, inner_kernel, dir_vec_name=None):
@@ -1307,6 +1314,9 @@ class KernelMapper:
 
 
 class KernelCombineMapper(KernelMapper):
+    def combine(self, values):
+        raise NotImplementedError
+
     def map_difference_kernel(self, kernel):
         return self.combine([
                 self.rec(kernel.kernel_plus),
@@ -1408,7 +1418,7 @@ def to_kernel_and_args(kernel_like):
 
     if not isinstance(kernel_like, Kernel):
         if kernel_like == 0:
-            return LaplaceKernel(), {}
+            return LaplaceKernel(None), {}
         elif isinstance(kernel_like, str):
             return HelmholtzKernel(None), {"k": var(kernel_like)}
         else:
diff --git a/sumpy/p2e.py b/sumpy/p2e.py
index 177d0b586ba8c04be2c1e01955a79d7b53f042f1..21551aefadcdcba33c11f07374ea954c4882a2c0 100644
--- a/sumpy/p2e.py
+++ b/sumpy/p2e.py
@@ -24,7 +24,7 @@ import numpy as np
 import loopy as lp
 from loopy.version import MOST_RECENT_LANGUAGE_VERSION
 
-from sumpy.tools import KernelCacheWrapper, KernelComputation
+from sumpy.tools import KernelCacheMixin, KernelComputation
 
 import logging
 logger = logging.getLogger(__name__)
@@ -43,12 +43,14 @@ Particle-to-Expansion
 
 # {{{ P2E base class
 
-class P2EBase(KernelComputation, KernelCacheWrapper):
+class P2EBase(KernelCacheMixin, KernelComputation):
     """Common input processing for kernel computations.
 
     .. automethod:: __init__
     """
 
+    default_name = "p2e"
+
     def __init__(self, ctx, expansion, kernels=None,
             name=None, device=None, strength_usage=None):
         """
@@ -122,6 +124,9 @@ class P2EBase(KernelComputation, KernelCacheWrapper):
         return (type(self).__name__, self.name, self.expansion,
                 tuple(self.source_kernels), tuple(self.strength_usage))
 
+    def get_kernel(self):
+        raise NotImplementedError
+
     def get_optimized_kernel(self, sources_is_obj_array, centers_is_obj_array):
         knl = self.get_kernel()
 
diff --git a/sumpy/p2p.py b/sumpy/p2p.py
index 83506a377dbd865bd1cf29b7de786aa8c0995264..abd5df0658a02a5da7be8762e4156c60419be19c 100644
--- a/sumpy/p2p.py
+++ b/sumpy/p2p.py
@@ -28,7 +28,7 @@ import loopy as lp
 from loopy.version import MOST_RECENT_LANGUAGE_VERSION
 
 from sumpy.tools import (
-        KernelComputation, KernelCacheWrapper, is_obj_array_like)
+        KernelComputation, KernelCacheMixin, is_obj_array_like)
 
 
 __doc__ = """
@@ -50,7 +50,9 @@ Particle-to-particle
 
 # {{{ p2p base class
 
-class P2PBase(KernelComputation, KernelCacheWrapper):
+class P2PBase(KernelCacheMixin, KernelComputation):
+    default_name = "p2p"
+
     def __init__(self, ctx, target_kernels, exclude_self, strength_usage=None,
             value_dtypes=None, name=None, device=None, source_kernels=None):
         """
diff --git a/sumpy/point_calculus.py b/sumpy/point_calculus.py
index b80ab51f06adda4c62fbfdd48b478794a45ed39e..2e17c41a1c11300e62a540a7306cd5371bece4e0 100644
--- a/sumpy/point_calculus.py
+++ b/sumpy/point_calculus.py
@@ -120,7 +120,7 @@ class CalculusPatch:
         """
 
         from pytools import indices_in_shape
-        from scipy.special import eval_chebyt
+        from scipy.special import eval_chebyt   # pylint: disable=no-name-in-module
 
         def eval_basis(ind, x):
             result = 1
@@ -240,8 +240,10 @@ class CalculusPatch:
         :returns: a scalar.
         """
         f_values = f_values.reshape(*self._pshape)
+        zero_eval_vec_1d = self._zero_eval_vec_1d
+
         for _ in range(self.dim):
-            f_values = self._zero_eval_vec_1d.dot(f_values)
+            f_values = zero_eval_vec_1d @ f_values
 
         return f_values
 
diff --git a/sumpy/qbx.py b/sumpy/qbx.py
index de2598881fc067dbe24e1b4e8f61b6510f2a3849..f5e505706ce2f80d375a030537a122eafb853aa5 100644
--- a/sumpy/qbx.py
+++ b/sumpy/qbx.py
@@ -32,7 +32,7 @@ from pytools import memoize_method
 from pymbolic import parse, var
 
 from sumpy.tools import (
-        KernelComputation, KernelCacheWrapper, is_obj_array_like)
+        KernelComputation, KernelCacheMixin, is_obj_array_like)
 
 import logging
 logger = logging.getLogger(__name__)
@@ -66,7 +66,9 @@ def stringify_expn_index(i):
 
 # {{{ base class
 
-class LayerPotentialBase(KernelComputation, KernelCacheWrapper):
+class LayerPotentialBase(KernelCacheMixin, KernelComputation):
+    default_name = "qbx"
+
     def __init__(self, ctx, expansion, strength_usage=None,
             value_dtypes=None, name=None, device=None,
             source_kernels=None, target_kernels=None):
diff --git a/sumpy/symbolic.py b/sumpy/symbolic.py
index ab4c25dcf9c64ccee990acc0c702cb4988dd0576..01adb6e834685a297b99ae1fa5677043c505ac0a 100644
--- a/sumpy/symbolic.py
+++ b/sumpy/symbolic.py
@@ -41,6 +41,8 @@ import pymbolic.primitives as prim
 import logging
 logger = logging.getLogger(__name__)
 
+USE_SYMENGINE = False
+
 
 # {{{ symbolic backend
 
@@ -48,23 +50,24 @@ def _find_symbolic_backend():
     global USE_SYMENGINE
 
     try:
-        import symengine  # noqa
+        import symengine  # noqa: F401
         symengine_found = True
+        symengine_error = None
     except ImportError as import_error:
         symengine_found = False
         symengine_error = import_error
 
-    ALLOWED_BACKENDS = ("sympy", "symengine")  # noqa
-    BACKEND_ENV_VAR = "SUMPY_FORCE_SYMBOLIC_BACKEND"  # noqa
+    allowed_backends = ("sympy", "symengine")
+    backend_env_var = "SUMPY_FORCE_SYMBOLIC_BACKEND"
 
     import os
-    backend = os.environ.get(BACKEND_ENV_VAR)
+    backend = os.environ.get(backend_env_var)
     if backend is not None:
-        if backend not in ALLOWED_BACKENDS:
+        if backend not in allowed_backends:
             raise RuntimeError(
-                f"{BACKEND_ENV_VAR} value is unrecognized: '{backend}' "
+                f"{backend_env_var} value is unrecognized: '{backend}' "
                 "(allowed values are {})".format(
-                    ", ".join(f"'{val}'" for val in ALLOWED_BACKENDS))
+                    ", ".join(f"'{val}'" for val in allowed_backends))
                 )
 
         if backend == "symengine" and not symengine_found:
@@ -123,17 +126,15 @@ def _coeff_isneg(a):
     return a.is_Number and a.is_negative
 
 
-have_unevaluated_expr = False
-if not USE_SYMENGINE:
+if USE_SYMENGINE:
+    def UnevaluatedExpr(x):  # noqa: F811, N802
+        return x
+else:
     try:
         from sympy import UnevaluatedExpr
-        have_unevaluated_expr = True
     except ImportError:
-        pass
-
-if not have_unevaluated_expr:
-    def UnevaluatedExpr(x):  # noqa
-        return x
+        def UnevaluatedExpr(x):  # noqa: F811, N802
+            return x
 
 
 if USE_SYMENGINE:
@@ -287,7 +288,7 @@ class PymbolicToSympyMapper(PymbolicToSympyMapperBase):
 
 
 class SympyToPymbolicMapper(SympyToPymbolicMapperBase):
-    def map_Symbol(self, expr):  # noqa
+    def map_Symbol(self, expr):  # noqa: N802
         if expr.name.startswith(SpatialConstant.prefix):
             return SpatialConstant.from_sympy(expr)
         return SympyToPymbolicMapperBase.map_Symbol(self, expr)
@@ -334,7 +335,8 @@ class _BesselOrHankel(sympy.Function):
     def fdiff(self, argindex=1):
         if argindex in (1, 3):
             # we are not differentiating w.r.t order or nderivs
-            raise ValueError()
+            raise ValueError(f"invalid argindex: {argindex}")
+
         order, z, nderivs = self.args
         return self.func(order, z, nderivs+1)
 
@@ -351,10 +353,10 @@ _SympyBesselJ = BesselJ
 _SympyHankel1 = Hankel1
 
 if USE_SYMENGINE:
-    def BesselJ(*args):   # noqa: N802
+    def BesselJ(*args):   # noqa: N802  # pylint: disable=function-redefined
         return sym.sympify(_SympyBesselJ(*args))
 
-    def Hankel1(*args):   # noqa: N802
+    def Hankel1(*args):   # noqa: N802  # pylint: disable=function-redefined
         return sym.sympify(_SympyHankel1(*args))
 
 # vim: fdm=marker
diff --git a/sumpy/tools.py b/sumpy/tools.py
index d3b61b8d1fa0702cdfa2861ff08b5610a0c9d96d..6505fa370e6b8d06565382ff365b7abc0f8aaace 100644
--- a/sumpy/tools.py
+++ b/sumpy/tools.py
@@ -54,7 +54,7 @@ import pyopencl as cl
 import pyopencl.array as cla
 
 import loopy as lp
-from typing import Dict, Tuple, Any
+from typing import Dict, Tuple, Any, ClassVar
 
 import logging
 logger = logging.getLogger(__name__)
@@ -579,6 +579,8 @@ class ScalingAssignmentTag(Tag):
 class KernelComputation:
     """Common input processing for kernel computations."""
 
+    default_name: ClassVar[str] = "unknown"
+
     def __init__(self, ctx, target_kernels, source_kernels, strength_usage,
             value_dtypes, name, device=None):
         """
@@ -722,7 +724,7 @@ class OrderedSet(MutableSet):
 # }}}
 
 
-class KernelCacheWrapper:
+class KernelCacheMixin:
     @memoize_method
     def get_cached_optimized_kernel(self, **kwargs):
         from sumpy import code_cache, CACHING_ENABLED, OPT_ENABLED
@@ -769,6 +771,9 @@ class KernelCacheWrapper:
                 knl, within=ObjTagged(ScalingAssignmentTag()))
 
 
+KernelCacheWrapper = KernelCacheMixin
+
+
 def is_obj_array_like(ary):
     return (
             isinstance(ary, (tuple, list))
@@ -1242,4 +1247,29 @@ def run_opencl_fft(fft_app, queue, input_vec, inverse=False, wait_for=None):
 
 # }}}
 
+
+# {{{ deprecations
+
+_depr_name_to_replacement_and_obj = {
+    "KernelCacheWrapper": ("KernelCacheMixin", 2023),
+    }
+
+if sys.version_info >= (3, 7):
+    def __getattr__(name):
+        replacement_and_obj = _depr_name_to_replacement_and_obj.get(name, None)
+        if replacement_and_obj is not None:
+            replacement, obj, year = replacement_and_obj
+            from warnings import warn
+            warn(f"'sumpy.tools.{name}' is deprecated. "
+                    f"Use '{replacement}' instead. "
+                    f"'sumpy.tools.{name}' will continue to work until {year}.",
+                    DeprecationWarning, stacklevel=2)
+            return obj
+        else:
+            raise AttributeError(name)
+else:
+    KernelCacheWrapper = KernelCacheMixin
+
+# }}}
+
 # vim: fdm=marker
diff --git a/sumpy/toys.py b/sumpy/toys.py
index ded4a54e2e071113ad87c865fb4b59e8f91af70a..be3bf843a4d754309b9a4051504e937674c6c4a6 100644
--- a/sumpy/toys.py
+++ b/sumpy/toys.py
@@ -697,7 +697,7 @@ class SchematicVisitor:
             elif expn_style == "circle":
                 draw_circle(psource.center, psource.radius, fill=None)
             else:
-                raise ValueError(f"unknown expn_style: {self.expn_style}")
+                raise ValueError(f"unknown expn_style: {expn_style}")
 
         if psource.derived_from is None:
             return
diff --git a/sumpy/visualization.py b/sumpy/visualization.py
index c5ce571095c9bfcd4e05e72ad127f02ecb424059..258745982d7011492de11b71d6643352beaf4639 100644
--- a/sumpy/visualization.py
+++ b/sumpy/visualization.py
@@ -141,7 +141,7 @@ class FieldPlotter:
 
         if max_val is not None:
             squeezed_fld[squeezed_fld > max_val] = max_val
-            squeezed_fld[squeezed_fld < -max_val] = -max_val
+            squeezed_fld[squeezed_fld < -max_val] = -max_val  # pylint: disable=E1130
 
         squeezed_fld = squeezed_fld[..., ::-1]
 
@@ -165,7 +165,8 @@ class FieldPlotter:
     def show_vector_in_mayavi(self, fld, do_show=True, **kwargs):
         c = self.points
 
-        from mayavi import mlab
+        from mayavi import mlab     # pylint: disable=import-error
+
         mlab.quiver3d(c[0], c[1], c[2], fld[0], fld[1], fld[2],
                 **kwargs)
 
@@ -181,7 +182,7 @@ class FieldPlotter:
     def show_scalar_in_mayavi(self, fld, max_val=None, **kwargs):
         if max_val is not None:
             fld[fld > max_val] = max_val
-            fld[fld < -max_val] = -max_val
+            fld[fld < -max_val] = -max_val  # pylint: disable=E1130
 
         if len(fld.shape) == 1:
             fld = fld.reshape(self.nd_points.shape[1:])
@@ -189,7 +190,7 @@ class FieldPlotter:
         nd_points = self.nd_points.squeeze()[self._get_nontrivial_dims()]
         squeezed_fld = fld.squeeze()
 
-        from mayavi import mlab
+        from mayavi import mlab     # pylint: disable=import-error
         mlab.surf(nd_points[0], nd_points[1], squeezed_fld, **kwargs)
 
 # vim: foldmethod=marker