Actually, you can solve this issue without using temporary files.
Apparently, this exact issue was encountered during the development of the builtin doctest module. This is even mentioned in the source code of inspect. It turns out that reading source files is done through the linecache.getlines function. What doctest does it that it swaps that function for its own wrapper. Based on this, we can create an analogous patch for exec:
def better_exec(code_, globals_=None, locals_=None, /, *, closure=None):
    import ast
    import linecache
    if not hasattr(better_exec, "saved_sources"):
        old_getlines = linecache.getlines
        better_exec.saved_sources = []
        def patched_getlines(filename, module_globals=None):
            if "<exec#" in filename:
                index = int(filename.split("#")[1].split(">")[0])
                return better_exec.saved_sources[index].splitlines(True)
            else:
                return old_getlines(filename, module_globals)
        linecache.getlines = patched_getlines
    better_exec.saved_sources.append(code_)
    exec(
        compile(
            ast.parse(code_),
            filename=f"<exec#{len(better_exec.saved_sources) - 1}>",
            mode="exec",
        ),
        globals_,
        locals_,
        closure=None,
    )
It works even if you try to get the source code from within the exec:
>>> namespace_ = {}
>>> better_exec("""\
... def foo():
...     print("foo")
... import inspect
... foo_source = inspect.getsource(foo)
... """, namespace_)
>>> print(namespace_["foo_source"])
def foo():
    print("foo")
>>> namespace_["foo"]()
foo
Each call to exec stores its input in the better_exec.saved_sources array. As such, if there are many calls to it, this could potentially cause high memory usage. This could be solved, for example, by swapping some of these sources to disk or simply deleting them.
If you are getting an OSError: source code not available from within inspect, make sure that your filename (<exec#...> here) begins with < and ends with >.
Note that while this successfully exposes the code to inspect, and pdb, it is, unfortunately, not visible in native exception stack traces:
>>> namespace_ = {}
>>> better_exec("""\
... def f():
...     raise RuntimeError()
... """, namespace_)
>>> import inspect
>>> print(inspect.getsource(namespace_["f"])) # Works!
def f():
    raise RuntimeError()
>>> namespace_["f"]()
Traceback (most recent call last):
  File "/app/output.s", line 37, in <module>
    namespace_["f"]() # No source :(
    ^^^^^^^^^^^^^^^^^
  File "<exec#0>", line 2, in f
RuntimeError
As as sidenote, if you are using VSCode, then by disabling Just My Code, you will even be able to step through the code given to exec:
