Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable Hy code in the debugger #1680

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 188 additions & 6 deletions hy/cmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,196 @@
import importlib
import py_compile
import runpy

import traceback
import astor.code_gen

import hy
from hy.lex import LexException, PrematureEndOfInput, mangle
from hy.compiler import HyTypeError, hy_compile
from hy.importer import hy_eval, hy_parse
from hy.importer import hy_eval, hy_parse, hy_ast_compile_flags
from hy.completer import completion, Completer
from hy.macros import macro, require
from hy.models import HyExpression, HyString, HySymbol
from hy._compat import builtins, PY3, FileNotFoundError
from hy._compat import builtins, PY3, FileNotFoundError, str_type


class HyPdb(object):
"""A contextmanager that patches `bdb` and `pdb` modules to make them parse
Hy.

In order to affect the interactive console environment when using `exec`,
we create custom versions of `[pb]db` methods that use specific namespace
dictionaries. Also, they force use of the closure's global and local
values to slightly reduce the number of functions that need patches.
"""

@staticmethod
def pdb_compile(src, filename='<stdin>',
mode='single', module_name='cmdline'):
hy_tree = hy_parse(src + '\n')
ast_root = ast.Interactive if mode == 'single' else ast.Module
hy_ast = hy_compile(hy_tree, module_name,
root=ast_root)
code = compile(hy_ast, filename, mode, hy_ast_compile_flags)
return code

def __init__(self, ctx_globals=None, ctx_locals=None):
self.ctx_globals = ctx_globals
self.ctx_locals = ctx_locals

def pdbpp_getval_or_undefined(self):
_pdb = self.pdb
def _pdbpp_getval_or_undefined(self, arg):
"""This is just for `pdb++`"""
try:
code = HyPdb.pdb_compile(arg)
return eval(code, self.curframe.f_globals,
self.curframe.f_locals)
except NameError:
return _pdb.undefined

def hy_pdb_default(self):
def _hy_pdb_default(self, line):
if line[:1] == '!': line = line[1:]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the condition here is equivalent to line.startswith('!'), which seems like a clearer way to write it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All these patched functions are direct copies from the [bp]db source (a couple have slight cross-version modifications, though), but, yeah, they could be cleaned up a bit.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case, you should attribute the source of the copied code in the commit message.

locals = self.curframe_locals
globals = self.curframe.f_globals
try:
code = HyPdb.pdb_compile(line + '\n', mode='single')
save_stdout = sys.stdout
save_stdin = sys.stdin
save_displayhook = sys.displayhook
try:
sys.stdin = self.stdin
sys.stdout = self.stdout
sys.displayhook = self.displayhook
exec(code, globals, locals)
finally:
sys.stdout = save_stdout
sys.stdin = save_stdin
sys.displayhook = save_displayhook
except:
exc_info = sys.exc_info()[:2]
msg = traceback.format_exception_only(*exc_info)[-1].strip()
print('***', msg, file=self.stdout)
return _hy_pdb_default

def hy_pdb_getval(self):
def _hy_pdb_getval(self, arg):
try:
code = HyPdb.pdb_compile(arg)
return eval(code, self.curframe.f_globals,
self.curframe_locals)
except:
t, v = sys.exc_info()[:2]
if isinstance(t, str):
exc_type_name = t
else:
exc_type_name = t.__name__

print('***', exc_type_name + ':', repr(v), file=self.stdout)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you could just write t if isinstance(t, str) else t.__name__ instead of defining exc_type_name.

raise
return _hy_pdb_getval

def hy_pdb_getval_except(self):
_pdb = self.pdb
def _hy_pdb_getval_except(self, arg, frame=None):
try:
code = HyPdb.pdb_compile(arg)
if frame is None:
return eval(code, self.curframe.f_globals, self.curframe_locals)
else:
return eval(code, frame.f_globals, frame.f_locals)
except:
exc_info = sys.exc_info()[:2]
err = traceback.format_exception_only(*exc_info)[-1].strip()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likewise, there's no need for a local exc_info.

return _pdb._rstr('** raised %s **' % err)
return _hy_pdb_getval_except

def hy_bdb_runeval(self):
ctx_globals = self.ctx_globals
ctx_locals = self.ctx_locals

def _hy_bdb_runeval(self, expr, globals=ctx_globals, locals=ctx_locals):
return self.run(expr, globals=globals, locals=locals, mode='eval')
return _hy_bdb_runeval

def hy_bdb_run(self):
ctx_globals = self.ctx_globals
ctx_locals = self.ctx_locals

_bdb = self.bdb
def _hy_bdb_run(self, cmd, globals=ctx_globals, locals=ctx_locals,
mode='exec'):
if globals is None:
if ctx_globals is None:
import __main__
globals = __main__.__dict__
else:
globals = ctx_globals
if locals is None:
locals = globals if ctx_locals is None else ctx_locals
self.reset()
if isinstance(cmd, str_type):
cmd = HyPdb.pdb_compile(cmd, filename='<string>', mode=mode)
sys.settrace(self.trace_dispatch)
try:
if mode == 'exec':
exec(cmd, globals, locals)
else:
return eval(cmd, globals, locals)
except _bdb.BdbQuit:
pass
finally:
self.quitting = 1
sys.settrace(None)
return _hy_bdb_run

def _swap_versions(self, restore=False):
# if hasattr(pdb, 'pdb'):
# pdb.pdb.Pdb.default = _pdb_default if restore else _hy_pdb_default
# pdb.pdb.Pdb._getval = _pdb_getval if restore else _hy_pdb_getval
# if hasattr(pdb.pdb.Pdb, '_getval_except'):
# pdb.pdb.Pdb._getval_except = _pdb_getval_except if restore else _hy_pdb_getval_except
# else:
# pdb.Pdb.default = _pdb_default if restore else _hy_pdb_default
# pdb.Pdb._getval = _pdb_getval if restore else _hy_pdb_getval
# if hasattr(pdb.Pdb, '_getval_except'):
# pdb.Pdb._getval_except = _pdb_getval_except if restore else _hy_pdb_getval_except
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You probably don't want to commit this big block of commented-out code.


self.bdb.Bdb.runeval = self._bdb_runeval if restore else self.hy_bdb_runeval()
self.bdb.Bdb.run = self._bdb_run if restore else self.hy_bdb_run()
self._old_pdb.Pdb.default = self._pdb_default if restore else self.hy_pdb_default()
self._old_pdb.Pdb._getval = self._pdb_getval if restore else self.hy_pdb_getval()
if hasattr(self._old_pdb.Pdb, '_getval_except'):
self._old_pdb.Pdb._getval_except = self._pdb_getval_except if restore else self.hy_pdb_getval_except()

def __enter__(self):
# Start with unpatched versions
if 'bdb' in sys.modules:
del sys.modules['bdb']
if 'pdb' in sys.modules:
del sys.modules['pdb']

self.bdb = importlib.import_module('bdb')
self.pdb = importlib.import_module('pdb')

# Keep track of the original methods
self._bdb_runeval = self.bdb.Bdb.runeval
self._bdb_run = self.bdb.Bdb.run
# This condition helps accounts for Pdb++
self._old_pdb = self.pdb if not hasattr(self.pdb, 'pdb') else self.pdb.pdb
self._pdb_getval = self._old_pdb.Pdb._getval
self._pdb_default = self._old_pdb.Pdb.default
# This method shows up in Python 3.x
if hasattr(self._old_pdb.Pdb, '_getval_except'):
self._pdb_getval_except = self._old_pdb.Pdb._getval_except

self._swap_versions(restore=False)

return self.pdb

def __exit__(self, exc_type, exc_value, traceback):
self._swap_versions(restore=True)


class HyQuitter(object):
Expand All @@ -47,7 +226,7 @@ def __call__(self, code=None):
builtins.exit = HyQuitter('exit')


class HyREPL(code.InteractiveConsole):
class HyREPL(code.InteractiveConsole, object):
def __init__(self, spy=False, output_fn=None, locals=None,
filename="<input>"):

Expand All @@ -65,8 +244,7 @@ def __init__(self, spy=False, output_fn=None, locals=None,
else:
self.output_fn = __builtins__[mangle(output_fn)]

code.InteractiveConsole.__init__(self, locals=locals,
filename=filename)
super(HyREPL, self).__init__(locals=locals, filename=filename)

# Pre-mangle symbols for repl recent results: *1, *2, *3
self._repl_results_symbols = [mangle("*{}".format(i + 1)) for i in range(3)]
Expand Down Expand Up @@ -129,6 +307,10 @@ def ast_callback(main_ast, expr_ast):
print(output)
return False

def interact(self, *args, **kwargs):
with HyPdb(ctx_locals=self.locals) as pdb:
self.locals['pdb'] = pdb
super(HyREPL, self).interact(*args, **kwargs)

@macro("koan")
def koan_macro(ETname):
Expand Down
6 changes: 6 additions & 0 deletions tests/resources/bin/pdb.hy
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
(defn func1 [x]
(print "func1")
(+ 1 x))
(defn func2 [x]
(print "func2")
(func1 x))
92 changes: 90 additions & 2 deletions tests/test_bin.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ def run_cmd(cmd, stdin_data=None, expect=0, dontwritebytecode=False):
else:
env.pop("PYTHONDONTWRITEBYTECODE", None)

cmd = shlex.split(cmd)
cmd[0] = os.path.join(hy_dir, cmd[0])
if not isinstance(cmd, list):
cmd = shlex.split(cmd)
cmd[0] = os.path.join(hy_dir, cmd[0])

p = subprocess.Popen(cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
Expand Down Expand Up @@ -382,3 +384,89 @@ def test_bin_hy_module_no_main():
def test_bin_hy_sys_executable():
output, _ = run_cmd("hy -c '(do (import sys) (print sys.executable))'")
assert output.strip().endswith('/hy')


def test_pdb_basics():
"""Tests for the Hy-compatibility `pdb` patches.

Basics that we need to confirm:
* `pdb.run` will evaluate in the interpreter environment
* the `pdb` shell will evaluate Hy
* Hy code will appear in the code listings

Also, these tests should probably be as debugger agnostic as possible.
"""
import textwrap

def clean_debug_output(output):
output = '\n'.join(output.split('\n')[1:]).strip()
# Remove debugger prompt
output = re.sub(r'\(i?[pP]db.*?\) ', '', output)
# Remove ANSI escape codes
output = re.sub(r'\x1B\[[0-?]*[ -/]*[@-~]', '', output)
return output

commands = textwrap.dedent("""
(pdb.run "(setv x 1)")
p (int (+ 1 1))
!(print "hi")
c
(assert (= x 1))
""").strip()

output, _ = run_cmd('hy', stdin_data=commands)
output = clean_debug_output(output)

expected_output = textwrap.dedent("""
2
None
hi
=> =>
""").strip()

assert output == expected_output

commands = textwrap.dedent("""
(import [tests.resources.bin.pdb [*]])
(pdb.run "(func2 0)")
b func1
c
l
""").strip()

output, _ = run_cmd('hy', stdin_data=commands)
output = clean_debug_output(output)

output_lines = output.split('\n')

assert output_lines[0].startswith('Breakpoint 1')
assert output_lines[0].endswith('pdb.hy:2')

break_pnt_linenum = next((i for i, l in enumerate(output_lines)
if l.strip().startswith('->')), None)

# Make sure we break in the right place
assert break_pnt_linenum == 3
assert output_lines[break_pnt_linenum].endswith('(print "func1")')

# Check the debugger src listing against the actual src file
import_src = open('tests/resources/bin/pdb.hy', 'r').readlines()
assert all(o.endswith(s.rstrip())
for s, o in zip(import_src, output_lines[break_pnt_linenum+1:]))

# Test the post-mortem debugger
commands = textwrap.dedent("""
(import [tests.resources.bin.pdb [*]])
(func2 'a)
(pdb.pm)
""").strip()

output, err_output = run_cmd('hy', stdin_data=commands)
output = clean_debug_output(output)
err_output = clean_debug_output(err_output)
output_lines = output.split('\n')
break_pnt_line = next((l for l in output_lines
if l.strip().startswith('->')), None)

assert 'TypeError' in err_output
assert break_pnt_line == '-> (+ 1 x))'