Skip to content

Commit 38f1ed6

Browse files
committed
Remember the source of interactively defined functions
- Patches the linecache module to achieve this - inspect.getsource() works for functions defined on the console - tracebacks show code for frames inside interactively defined functions fixes bpython#229
1 parent 21b9a54 commit 38f1ed6

File tree

3 files changed

+97
-7
lines changed

3 files changed

+97
-7
lines changed

bpython/history.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
from __future__ import unicode_literals
2727
import io
28+
import linecache
2829
import os
2930
import stat
3031
from itertools import islice
@@ -232,3 +233,74 @@ def append_reload_and_write(self, s, filename, encoding):
232233
# Make sure that entries contains at least one element. If the
233234
# file and s are empty, this can occur.
234235
self.entries = ['']
236+
237+
class BPythonLinecache(dict):
238+
"""Replaces the cache dict in the standard-library linecache module,
239+
to also remember (in an unerasable way) bpython console input."""
240+
241+
def __init__(self, *args, **kwargs):
242+
dict.__init__(self, *args, **kwargs)
243+
self.bpython_history = []
244+
245+
def is_bpython_filename(self, fname):
246+
try:
247+
return fname.startswith('<bpython-input-')
248+
except AttributeError:
249+
# In case the key isn't a string
250+
return False
251+
252+
def get_bpython_history(self, key):
253+
"""Given a filename provided by remember_bpython_input,
254+
returns the associated source string."""
255+
try:
256+
idx = int(key.split('-')[2][:-1])
257+
return self.bpython_history[idx]
258+
except (IndexError, ValueError):
259+
raise KeyError
260+
261+
def remember_bpython_input(self, source):
262+
"""Remembers a string of source code, and returns
263+
a fake filename to use to retrieve it later."""
264+
filename = '<bpython-input-%s>' % len(self.bpython_history)
265+
self.bpython_history.append((len(source), None,
266+
source.splitlines(True), filename))
267+
return filename
268+
269+
def __getitem__(self, key):
270+
if self.is_bpython_filename(key):
271+
return self.get_bpython_history(key)
272+
return dict.__getitem__(self, key)
273+
274+
def __contains__(self, key):
275+
if self.is_bpython_filename(key):
276+
try:
277+
self.get_bpython_history(key)
278+
return True
279+
except KeyError:
280+
return False
281+
return dict.__contains__(self, key)
282+
283+
def __delitem__(self, key):
284+
if not self.is_bpython_filename(key):
285+
return dict.__delitem__(self, key)
286+
287+
def _bpython_clear_linecache():
288+
try:
289+
bpython_history = linecache.cache.bpython_history
290+
except AttributeError:
291+
bpython_history = []
292+
linecache.cache = BPythonLinecache()
293+
linecache.cache.bpython_history = bpython_history
294+
295+
# Monkey-patch the linecache module so that we're able
296+
# to hold our command history there and have it persist
297+
linecache.cache = BPythonLinecache(linecache.cache)
298+
linecache.clearcache = _bpython_clear_linecache
299+
300+
def filename_for_console_input(code_string):
301+
"""Remembers a string of source code, and returns
302+
a fake filename to use to retrieve it later."""
303+
try:
304+
return linecache.cache.remember_bpython_input(code_string)
305+
except AttributeError:
306+
return '<input>'

bpython/repl.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
from bpython.clipboard import get_clipboard, CopyFailed
4848
from bpython.config import getpreferredencoding
4949
from bpython.formatter import Parenthesis
50-
from bpython.history import History
50+
from bpython.history import History, filename_for_console_input
5151
from bpython.paste import PasteHelper, PastePinnwand, PasteFailed
5252
from bpython.translations import _, ngettext
5353

@@ -94,7 +94,7 @@ def __init__(self, locals=None, encoding=None):
9494
def reset_running_time(self):
9595
self.running_time = 0
9696

97-
def runsource(self, source, filename='<input>', symbol='single',
97+
def runsource(self, source, filename=None, symbol='single',
9898
encode=True):
9999
"""Execute Python code.
100100

@@ -104,6 +104,8 @@ def runsource(self, source, filename='<input>', symbol='single',
104104
if not py3 and encode:
105105
source = u'# coding: %s\n%s' % (self.encoding, source)
106106
source = source.encode(self.encoding)
107+
if filename is None:
108+
filename = filename_for_console_input(source)
107109
with self.timer:
108110
return code.InteractiveInterpreter.runsource(self, source,
109111
filename, symbol)

bpython/test/test_interpreter.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@
1717

1818
pypy = 'PyPy' in sys.version
1919

20+
def _last_console_filename():
21+
"""Returns the last 'filename' used for console input
22+
(as will be displayed in a traceback)."""
23+
import linecache
24+
try:
25+
return '<bpython-input-%s>' % (len(linecache.cache.bpython_history) - 1)
26+
except AttributeError:
27+
return '<input>'
2028

2129
class TestInterpreter(unittest.TestCase):
2230
def test_syntaxerror(self):
@@ -30,11 +38,11 @@ def append_to_a(message):
3038
i.runsource('1.1.1.1')
3139

3240
if pypy:
33-
expected = ' File ' + green('"<input>"') + ', line ' + \
41+
expected = ' File ' + green('"%s"' % _last_console_filename()) + ', line ' + \
3442
bold(magenta('1')) + '\n 1.1.1.1\n ^\n' + \
3543
bold(red('SyntaxError')) + ': ' + cyan('invalid syntax') + '\n'
3644
else:
37-
expected = ' File ' + green('"<input>"') + ', line ' + \
45+
expected = ' File ' + green('"%s"' % _last_console_filename()) + ', line ' + \
3846
bold(magenta('1')) + '\n 1.1.1.1\n ^\n' + \
3947
bold(red('SyntaxError')) + ': ' + cyan('invalid syntax') + '\n'
4048

@@ -56,16 +64,16 @@ def f():
5664
def g():
5765
return f()
5866

59-
i.runsource('g()')
67+
i.runsource('g()', encode=False)
6068

6169
if pypy:
6270
global_not_found = "global name 'g' is not defined"
6371
else:
6472
global_not_found = "name 'g' is not defined"
6573

6674
expected = 'Traceback (most recent call last):\n File ' + \
67-
green('"<input>"') + ', line ' + bold(magenta('1')) + ', in ' + \
68-
cyan('<module>') + '\n' + bold(red('NameError')) + ': ' + \
75+
green('"%s"' % _last_console_filename()) + ', line ' + bold(magenta('1')) + ', in ' + \
76+
cyan('<module>') + '\n g()\n' + bold(red('NameError')) + ': ' + \
6977
cyan(global_not_found) + '\n'
7078

7179
self.assertMultiLineEqual(str(plain('').join(a)), str(expected))
@@ -106,3 +114,11 @@ def test_runsource_unicode(self):
106114
i.runsource("a = u'\xfe'", encode=True)
107115
self.assertIsInstance(i.locals['a'], type(u''))
108116
self.assertEqual(i.locals['a'], u"\xfe")
117+
118+
def test_getsource_works_on_interactively_defined_functions(self):
119+
source = 'def foo(x):\n return x + 1\n'
120+
i = interpreter.Interp()
121+
i.runsource(source)
122+
import inspect
123+
inspected_source = inspect.getsource(i.locals['foo'])
124+
self.assertEquals(inspected_source, source)

0 commit comments

Comments
 (0)