diff --git a/README.rst b/README.rst
index 61579558d75a9fb1c4ee7b3567555f58486b127c..49a675b57d135a1bca588b08084081cb264dac4b 100644
--- a/README.rst
+++ b/README.rst
@@ -88,7 +88,7 @@ Before you can use kgb, you need to install it. You can do this by typing::
 
     $ pip install kgb
 
-kgb supports Python 2.7 and 3.6 through 3.10, both CPython and PyPy.
+kgb supports Python 2.7 and 3.6 through 3.11, both CPython and PyPy.
 
 
 Spying for fun and profit
diff --git a/kgb/spies.py b/kgb/spies.py
index 014943e19edbcbc4d4035d3981d4343e3c2b6d08..f85834ec386341e45a014aeb5dbdb802eda4f294 100644
--- a/kgb/spies.py
+++ b/kgb/spies.py
@@ -812,20 +812,124 @@ class FunctionSpy(object):
         # dirty. Somehow, it all really fits in with the idea of spies,
         # though.
         sig = self._sig
-        exec_locals = {}
-        func_code_str = (
-            'def forwarding_call(%(params)s):\n'
-            '    from kgb.spies import FunctionSpy as _kgb_cls\n'
-            '    _kgb_l = locals()\n'
-            ''
-            '    return _kgb_cls._spy_map[%(spy_id)s](%(call_args)s)\n'
+        spy_id = id(self)
+        real_func = self._real_func
+
+        forwarding_call = self._compile_forwarding_call_func(
+            func=func,
+            sig=sig,
+            spy_id=spy_id)
+
+        old_code, new_code = self._build_spy_code(func, forwarding_call)
+        self._old_code = old_code
+        setattr(real_func, FunctionSig.FUNC_CODE_ATTR, new_code)
+
+        # Update our spy lookup map so the proxy function can easily find
+        # the spy instance.
+        FunctionSpy._spy_map[spy_id] = self
+
+        # Update the attributes on the function. we'll be placing all spy
+        # state and some proxy methods pointing to this spy, so that we can
+        # easily access them through the function.
+        real_func.spy = self
+        real_func.__dict__.update(copy.deepcopy(self._FUNC_ATTR_DEFAULTS))
+
+        for proxy_func_name in self._PROXY_METHODS:
+            assert not hasattr(real_func, proxy_func_name)
+            setattr(real_func, proxy_func_name, getattr(self, proxy_func_name))
+
+    def _compile_forwarding_call_func(self, func, sig, spy_id):
+        """Compile a forwarding call function for the spy.
+
+        This will build the Python code for a function that approximates the
+        function we're spying on, with the same function definition and
+        closure behavior.
+
+        Version Added:
+            7.1
+
+        Args:
+            func (callable):
+                The function being spied on.
+
+            sig (kgb.signature.BaseFunctionSig):
+                The function signature to use for this function.
+
+            spy_id (int):
+                The ID used for the spy registration.
+
+        Returns:
+            callable:
+            The resulting forwarding function.
+        """
+        closure_vars = func.__code__.co_freevars
+        use_closure = bool(closure_vars)
+
+        # If the function is in a closure, we'll need to mirror the closure
+        # state by using the referenced variables within _kgb_forwarding_call
+        # and by defining those variables within a closure.
+        #
+        # Start by setting up a string that will use each closure.
+        if use_closure:
+            # This is an efficient way of referencing each variable without
+            # side effects (at least in Python 2.7 through 3.11). Tuple
+            # operations are fast and compact, and don't risk any inadvertent
+            # invocation of the variables.
+            use_closure_vars_str = (
+                '        (%s)\n'
+                % ', '.join(func.__code__.co_freevars)
+            )
+        else:
+            # No closure, so nothing to set up.
+            use_closure_vars_str = ''
+
+        # Now define the forwarding call. This will always be nested within
+        # either a closure of an if statement, letting us build a single
+        # version at the right indentation level, keeping this as fast and
+        # portable as possible.
+        forwarding_call_str = (
+            '    def _kgb_forwarding_call(%(params)s):\n'
+            '        from kgb.spies import FunctionSpy as _kgb_cls\n'
+            '%(use_closure_vars)s'
+            '        _kgb_l = locals()\n'
+            '        return _kgb_cls._spy_map[%(spy_id)s](%(call_args)s)\n'
             % {
-                'params': sig.format_arg_spec(),
                 'call_args': sig.format_forward_call_args(),
-                'spy_id': id(self),
+                'params': sig.format_arg_spec(),
+                'spy_id': spy_id,
+                'use_closure_vars': use_closure_vars_str,
             }
         )
 
+        if use_closure:
+            # We now need to put _kgb_forwarding_call in a closure, to mirror
+            # the behavior of the spied function. The closure will provide
+            # the closure variables, and will return the function we can
+            # later use.
+            func_code_str = (
+                'def _kgb_forwarding_call_closure(%(params)s):\n'
+                '%(forwarding_call)s'
+                '    return _kgb_forwarding_call\n'
+                % {
+                    'forwarding_call': forwarding_call_str,
+                    'params': ', '.join(
+                        '%s=None' % _var
+                        for _var in closure_vars
+                    )
+                }
+            )
+        else:
+            # No closure, so just define the function as-is. We will need to
+            # wrap in an "if 1:" though, just to ensure indentation is fine.
+            func_code_str = (
+                'if 1:\n'
+                '%s'
+                % forwarding_call_str
+            )
+
+        # We can now build our function.
+        exec_locals = {}
+
         try:
             eval(compile(func_code_str, '<string>', 'exec'),
                  globals(), exec_locals)
@@ -840,60 +944,97 @@ class FunctionSpy(object):
                     'func': func,
                 })
 
-        forwarding_call = exec_locals['forwarding_call']
+        # Grab the resulting compiled function out of the locals.
+        if use_closure:
+            # It's in our closure, so call that and get the result.
+            forwarding_call = exec_locals['_kgb_forwarding_call_closure']()
+        else:
+            forwarding_call = exec_locals['_kgb_forwarding_call']
+
         assert forwarding_call is not None
 
-        old_code = getattr(func, FunctionSig.FUNC_CODE_ATTR)
-        temp_code = getattr(forwarding_call, FunctionSig.FUNC_CODE_ATTR)
+        return forwarding_call
 
-        self._old_code = old_code
+    def _build_spy_code(self, func, forwarding_call):
+        """Build a CodeType to inject into the spied function.
 
-        # Build the new CodeType. This will be a combination of our proxy
-        # function and the original function, creating something we can
-        # put back into the function we're spying on.
-        code_args = [temp_code.co_argcount]
+        This will create a function bytecode object that contains a mix of
+        attributes from the original function and the forwarding call. The
+        result can be injected directly into the spied function, containing
+        just the right data to impersonate the function and call our own
+        logic.
 
-        if pyver[0] >= 3:
-            if pyver[1] >= 8:
-                code_args.append(temp_code.co_posonlyargcount)
-
-            code_args.append(temp_code.co_kwonlyargcount)
-
-        code_args += [
-            temp_code.co_nlocals,
-            temp_code.co_stacksize,
-            temp_code.co_flags,
-            temp_code.co_code,
-            temp_code.co_consts,
-            temp_code.co_names,
-            temp_code.co_varnames,
-            temp_code.co_filename,
-            old_code.co_name,
-            temp_code.co_firstlineno,
-            temp_code.co_lnotab,
-            old_code.co_freevars,
-            old_code.co_cellvars,
-        ]
+        Version Added:
+            7.1
 
-        real_func = self._real_func
+        Args:
+            func (callable):
+                The function being spied on.
 
-        new_code = types.CodeType(*code_args)
-        setattr(real_func, FunctionSig.FUNC_CODE_ATTR, new_code)
-        assert old_code != new_code
+            forwarding_call (callable):
+                The spy forwarding call we built.
 
-        # Update our spy lookup map so the proxy function can easily find
-        # the spy instance.
-        FunctionSpy._spy_map[id(self)] = self
+        Returns:
+            tuple:
+            A 2-tuple containing:
 
-        # Update the attributes on the function. we'll be placing all spy
-        # state and some proxy methods pointing to this spy, so that we can
-        # easily access them through the function.
-        real_func.spy = self
-        real_func.__dict__.update(copy.deepcopy(self._FUNC_ATTR_DEFAULTS))
+            1. The spied function's code object (:py:class:`types.CodeType`).
+            1. The new spy code object (:py:class:`types.CodeType`).
+        """
+        old_code = getattr(func, FunctionSig.FUNC_CODE_ATTR)
+        temp_code = getattr(forwarding_call, FunctionSig.FUNC_CODE_ATTR)
 
-        for proxy_func_name in self._PROXY_METHODS:
-            assert not hasattr(real_func, proxy_func_name)
-            setattr(real_func, proxy_func_name, getattr(self, proxy_func_name))
+        assert old_code != temp_code
+
+        if hasattr(old_code, 'replace'):
+            # Python >= 3.8
+            #
+            # It's important we replace the code instead of building a new
+            # one when possible. On Python 3.11, this will ensure that
+            # state needed for exceptions (co_positions()) will be set
+            # correctly.
+            replace_kwargs = {
+                'co_name': old_code.co_name,
+                'co_freevars': old_code.co_freevars,
+                'co_cellvars': old_code.co_cellvars,
+            }
+
+            if pyver >= (3, 11):
+                replace_kwargs['co_qualname'] = old_code.co_qualname
+
+            new_code = temp_code.replace(**replace_kwargs)
+        else:
+            # Python <= 3.7
+            #
+            # We have to build this manually, using a combination of the
+            # two. We won't bother with anything newer than Python 3.7.
+            code_args = [temp_code.co_argcount]
+
+            if pyver >= (3, 0):
+                code_args.append(temp_code.co_kwonlyargcount)
+
+            code_args += [
+                temp_code.co_nlocals,
+                temp_code.co_stacksize,
+                temp_code.co_flags,
+                temp_code.co_code,
+                temp_code.co_consts,
+                temp_code.co_names,
+                temp_code.co_varnames,
+                temp_code.co_filename,
+                old_code.co_name,
+                temp_code.co_firstlineno,
+                temp_code.co_lnotab,
+                old_code.co_freevars,
+                old_code.co_cellvars,
+            ]
+
+            new_code = types.CodeType(*code_args)
+
+        assert new_code != old_code
+        assert new_code != temp_code
+
+        return old_code, new_code
 
     def _clone_function(self, func, code=None):
         """Clone a function, optionally providing new bytecode.
diff --git a/kgb/tests/test_function_spy.py b/kgb/tests/test_function_spy.py
index 49eee195081acdff6ba21d56fe92edd5809947d5..265d0f6f9e121e01660571af032d0068ee80390f 100644
--- a/kgb/tests/test_function_spy.py
+++ b/kgb/tests/test_function_spy.py
@@ -4,8 +4,10 @@ import functools
 import inspect
 import re
 import sys
+import traceback
 import types
 import unittest
+from contextlib import contextmanager
 from warnings import catch_warnings
 
 from kgb.errors import ExistingSpyError, IncompatibleFunctionError
@@ -72,6 +74,11 @@ def fake_something_awesome():
     return r'\o/'
 
 
+@contextmanager
+def do_context(a=1, b=2):
+    yield a + b
+
+
 class AdderObject(object):
     def func(self):
         assert isinstance(self, AdderObject)
@@ -650,6 +657,133 @@ class FunctionSpyTests(TestCase):
             a=2,
             b=5))
 
+    def test_call_with_fake_and_contextmanager(self):
+        """Testing FunctionSpy calls with call_fake and context manager"""
+        @contextmanager
+        def fake_do_context(a=1, b=2):
+            yield a * b
+
+        self.agency.spy_on(do_context,
+                           call_fake=fake_do_context)
+
+        with do_context(a=5) as ctx:
+            result = ctx
+
+        self.assertEqual(result, 10)
+        self.assertEqual(len(do_context.calls), 1)
+        self.assertEqual(do_context.calls[0].args, ())
+        self.assertEqual(do_context.calls[0].kwargs, {'a': 5})
+
+    def test_call_with_fake_and_contextmanager_func_raises_exception(self):
+        """Testing FunctionSpy calls with call_fake and context manager and
+        function raises exception
+        """
+        e = Exception('oh no')
+
+        @contextmanager
+        def fake_do_context(*args, **kwargs):
+            raise e
+
+        self.agency.spy_on(do_context,
+                           call_fake=fake_do_context)
+
+        with self.assertRaisesRegex(Exception, 'oh no'):
+            with do_context(a=5):
+                pass
+
+        self.assertEqual(len(do_context.calls), 1)
+        self.assertEqual(do_context.calls[0].args, ())
+        self.assertEqual(do_context.calls[0].kwargs, {'a': 5})
+        self.assertEqual(do_context.calls[0].exception, e)
+
+    def test_call_with_fake_and_contextmanager_body_raises_exception(self):
+        """Testing FunctionSpy calls with call_fake and context manager and
+        context body raises exception
+        """
+        e = Exception('oh no')
+
+        @contextmanager
+        def fake_do_context(a=1, b=2):
+            yield a * b
+
+        self.agency.spy_on(do_context,
+                           call_fake=fake_do_context)
+
+        with self.assertRaisesRegex(Exception, 'oh no'):
+            with do_context(a=5):
+                raise e
+
+        self.assertEqual(len(do_context.calls), 1)
+        self.assertEqual(do_context.calls[0].args, ())
+        self.assertEqual(do_context.calls[0].kwargs, {'a': 5})
+        self.assertIsNone(do_context.calls[0].exception)
+
+    def test_call_with_exception(self):
+        e = ValueError('oh no')
+
+        def orig_func(arg1=None, arg2=None):
+            # Create enough of a difference in code positions between this
+            # and the forwarding functions, to ensure the exception's
+            # position count is higher than that of the forwarding function.
+            #
+            # This is important for sanity checks on Python 3.11.
+            try:
+                if 1:
+                    if 2:
+                        try:
+                            a = 1
+                            b = a
+                            a = 2
+                        except Exception:
+                            raise
+                    else:
+                        c = [1, 2, 3, 4, 5]
+                        a = c
+            except Exception:
+                raise
+
+            for i in range(10):
+                try:
+                    d = [1, 2, 3, 4, 5]
+                    a = d
+                    b = a
+                    d = b
+                except Exception:
+                    raise
+
+            # We should be good. We'll verify counts later.
+            raise e
+
+        # Verify the above.
+        orig_func_code = orig_func.__code__
+        supports_co_positions = hasattr(orig_func_code, 'co_positions')
+
+        if supports_co_positions:
+            orig_positions_count = len(list(orig_func_code.co_positions()))
+        else:
+            orig_positions_count = None
+
+        # Now spy.
+        self.agency.spy_on(orig_func)
+
+        if supports_co_positions:
+            spy_positions_count = len(list(orig_func.__code__.co_positions()))
+
+            # Make sure we had enough padding up above.
+            self.assertGreater(orig_positions_count, spy_positions_count)
+
+        # Now test.
+        try:
+            orig_func()
+        except Exception as ex:
+            # This should fail if we've built the CodeType wrong and have a
+            # resulting offset issue. The act of pretty-printing the exception
+            # triggers the noticeable co_positions() issue.
+            traceback.print_exception(ex)
+
+        self.assertEqual(len(orig_func.calls), 1)
+        self.assertIs(orig_func.calls[0].exception, e)
+
     def test_call_with_fake_and_args(self):
         """Testing FunctionSpy calls with call_fake and arguments"""
         obj = MathClass()
@@ -686,7 +820,6 @@ class FunctionSpyTests(TestCase):
         self.agency.spy_on(obj.do_math, call_fake=fake_do_math)
         result = obj.do_math(a=10, b=20)
 
-        print(obj.do_math.calls)
         self.assertEqual(result, -10)
         self.assertEqual(len(obj.do_math.calls), 1)
         self.assertEqual(len(obj.do_math.calls[0].args), 0)
@@ -1020,6 +1153,21 @@ class FunctionSpyTests(TestCase):
         self.assertTrue(func.called)
         self.assertEqual(d, {'called': True})
 
+    def test_call_with_inline_function_using_closure_vars_and_args(self):
+        """Testing FunctionSpy calls for inline function using a closure's
+        variables and function with arguments
+        """
+        d = {}
+
+        def func(a, b, c):
+            d['call_result'] = a + b + c
+
+        self.agency.spy_on(func)
+
+        func(1, 2, c=3)
+        self.assertTrue(func.called)
+        self.assertEqual(d, {'call_result': 6})
+
     def test_call_with_function_providing_closure_vars(self):
         """Testing FunctionSpy calls for function providing variables for an
         inline function
diff --git a/setup.py b/setup.py
index b49e365d4225b2ef8d794ad68ddc65dc087b0f88..4f6e01cb53faef7df94cc228d5fd25ade4525cfc 100755
--- a/setup.py
+++ b/setup.py
@@ -74,6 +74,7 @@ setup(name=PACKAGE_NAME,
           'Programming Language :: Python :: 3.8',
           'Programming Language :: Python :: 3.9',
           'Programming Language :: Python :: 3.10',
+          'Programming Language :: Python :: 3.11',
           'Topic :: Software Development',
           'Topic :: Software Development :: Libraries :: Python Modules',
           'Topic :: Software Development :: Testing',
diff --git a/tox.ini b/tox.ini
index 12f23fce00268cedd71aca7a7574c2b5057689ee..42de234ef3f15c2f463ebdc960cde626cbb31bd7 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = py{27,36,37,38,39},pypy{37,38}
+envlist = py{27,36,37,38,39,310,311},pypy{37,38}
 skipsdist = True
 
 [testenv]
