3

Given a function defined inline, how do I get getsource to provide the output? - This is for a test, here's the kind of thing I'm trying:

from importlib.util import module_from_spec, spec_from_loader

_locals = module_from_spec(
    spec_from_loader("helper", loader=None, origin="str")  # loader=MemoryInspectLoader
)
exec(
    'def f(): return "foo"',
    _locals.__dict__,
)
f = getattr(_locals, "f")
setattr(f, "__loader__", MemoryInspectLoader)

With my attempt, as it looks like a linecache issue:

from importlib.abc import Loader

class MemoryInspectLoader(Loader):
    def get_code(self): raise NotImplementedError()

But the error is never raised. From getsource(f), I just get:

In [2]: import inspect
   ...: inspect.getsource(f)
---------------------------------------------------------------------------
OSError                                   Traceback (most recent call last)
<ipython-input-3-1348c7a45f75> in <module>
----> 1 inspect.getsource(f)

/usr/lib/python3.8/inspect.py in getsource(object)
    983     or code object.  The source code is returned as a single string.  An
    984     OSError is raised if the source code cannot be retrieved."""
--> 985     lines, lnum = getsourcelines(object)
    986     return ''.join(lines)
    987 

/usr/lib/python3.8/inspect.py in getsourcelines(object)
    965     raised if the source code cannot be retrieved."""
    966     object = unwrap(object)
--> 967     lines, lnum = findsource(object)
    968 
    969     if istraceback(object):

/usr/lib/python3.8/inspect.py in findsource(object)
    796         lines = linecache.getlines(file)
    797     if not lines:
--> 798         raise OSError('could not get source code')
    799 
    800     if ismodule(object):

OSError: could not get source code

How do I make getsource work with an inline-defined function in Python 3.6+?

Samuel Marks
  • 1,611
  • 1
  • 20
  • 25
  • Some relevant comments here: https://bugs.python.org/issue12920. What kind of test? – Ry- Nov 20 '20 at 07:42
  • I'm testing the AST that is inside the function. So I need to get the AST. But I'm providing the source code in a string, for various reasons (including imports, having features not present in different Python versions, &etc.). – Samuel Marks Nov 21 '20 at 09:12
  • So you have the source as a string. Why can’t you hold onto that string? – Ry- Nov 21 '20 at 09:15
  • @Ry- - So I'd rather not have custom code to handle when I'm testing the function and when it's receiving real-world inputs. Can't I just make both work normally using `inspect.getsource`? - Maybe by constructing a custom `Loader` and throwing the code in `def get_source(self): return 'def f(): return "foo"'`? – Samuel Marks Nov 21 '20 at 09:28

3 Answers3

3

Here's my solution to this:

import os.path
import sys
import tempfile
from importlib.util import module_from_spec, spec_from_loader
from types import ModuleType
from typing import Any, Callable

class ShowSourceLoader:
    def __init__(self, modname: str, source: str) -> None:
        self.modname = modname
        self.source = source

    def get_source(self, modname: str) -> str:
        if modname != self.modname:
            raise ImportError(modname)
        return self.source


def make_function(s: str) -> Callable[..., Any]:
    filename = tempfile.mktemp(suffix='.py')
    modname = os.path.splitext(os.path.basename(filename))[0]
    assert modname not in sys.modules
    # our loader is a dummy one which just spits out our source
    loader = ShowSourceLoader(modname, s)
    spec = spec_from_loader(modname, loader, origin=filename)
    module = module_from_spec(spec)
    # the code must be compiled so the function's code object has a filename
    code = compile(s, mode='exec', filename=filename)
    exec(code, module.__dict__)
    # inspect.getmodule(...) requires it to be in sys.modules
    sys.modules[modname] = module
    return module.f


import inspect
func = make_function('def f(): print("hi")')
print(inspect.getsource(func))

output:

$ python3 t.py 
def f(): print("hi")

there's a few subtle, and unfortunate points to this:

  1. it requires something injected into sys.modules (inspect.getsource always looks there for inspect.getmodule)
  2. the __loader__ I've built is bogus, if you're doing anything else that requires a functioning __loader__ this will likely break for that
  3. other oddities are documented inline

an aside, you're probably better to keep the original source around in some other way, rather than boomeranging through several globals (sys.modules, linecache, __loader__, etc.)

anthony sottile
  • 61,815
  • 15
  • 148
  • 207
  • Thanks. So this cheats by creating a file? - Oh, also there's just a file leak going on, which I suppose you could fix by replacing that line with `filename = tempfile.NamedTemporaryFile(suffix=".py").name` – Samuel Marks Nov 23 '20 at 23:16
  • 1
    it doesn't actually make a file -- I used `mktemp` to get a name only – anthony sottile Nov 23 '20 at 23:52
  • Ah, then `mktemp` is nothing better than a `random` call with an `abspath` call + suffix here? - Since it doesn't reserve the filename, so there are no guarantees when it comes to `sys.modules` presence. - I remember seeing this when I was running through the hierarchy of modules, which is why I was going for a non file based approach messing with `__loader__`… EDIT: still though, +1 – Samuel Marks Nov 24 '20 at 02:26
  • correct, I just used it as a pseudo-random string -- you might do better with manipulating a uuid or base64 of urandom or something – anthony sottile Nov 24 '20 at 03:48
  • It just broke again. Fixed by adding this line above `sys.modules[modname] = module`: `setattr(module, "__file__", s)` – Samuel Marks Dec 02 '20 at 13:53
0

Not entirely sure if I got the question correctly.

But if you have the following code:

class MemoryInspectLoader(Loader):
    def get_code(self): raise NotImplementedError()

You can extract the functions body by using dill.

from dill.source import getsource

print(getsource(MemoryInspectLoader.get_code))

Which will output:

        def get_code(self): raise NotImplementedError()

Which is also demonstrated in this SO answer.

Thymen
  • 2,089
  • 1
  • 9
  • 13
  • dill is a cool project, and yes this would work. But my question is about the `inspect` module, so really I want a non-external-library solution. – Samuel Marks Nov 29 '20 at 11:53
  • Very well, I will leave my answer for people that might stumble upon this and would allow other libraries. – Thymen Nov 29 '20 at 18:17
0

Monkey patch linecache.getlines in order to make inspect.getsource() to work with code coming from exec(). When you look at the error stack, it stops at findsource() in inspect.py. When you look at the code of findsource(), you'll see a hint:

# Allow filenames in form of "<something>" to pass through.
# `doctest` monkeypatches `linecache` module to enable
# inspection, so let `linecache.getlines` to be called.

And then if you look at this test function you'll see what it means. You can temporarily change one of the core Python function to serve your purpose.

Anyway, here's the solution:

import linecache
import inspect

def exec_getsource(code):
    getlines = linecache.getlines
    def monkey_patch(filename, module_globals=None):
        if filename == '<string>':
            return code.splitlines(keepends=True)
        else:
            return getlines(filename, module_globals)
    linecache.getlines = monkey_patch
    
    try:
        exec(code)
        #you can now use inspect.getsource() on the result of exec() here
        
    finally:
        linecache.getlines = getlines
Robin Lobel
  • 630
  • 1
  • 5
  • 16