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
:
