Skip to content

Commit

Permalink
Merge pull request #31 from minrk/terminal-diff
Browse files Browse the repository at this point in the history
work on pretty-printed diffs
  • Loading branch information
minrk committed Mar 22, 2016
2 parents cc37433 + 149ddce commit d15e6fd
Show file tree
Hide file tree
Showing 3 changed files with 366 additions and 7 deletions.
182 changes: 177 additions & 5 deletions nbdime/prettyprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,156 @@
from __future__ import unicode_literals
from __future__ import print_function

from six import string_types
from itertools import chain
import os
import pprint
import re
import shutil
from subprocess import Popen, PIPE
import tempfile
try:
from textwrap import indent
except ImportError:
def indent(text, prefix):
"""The relevant part of textwrap.indent for Python 2"""
return prefix + text.replace('\n', '\n' + prefix)

from six import string_types

from .diffing.notebooks import diff_notebooks
from .diff_format import NBDiffFormatError, Diff
from .patching import patch

try:
from shutil import which
except ImportError:
from backports.shutil_which import which

# Toggle indentation here
with_indent = True


def present_dict_no_markup(prefix, d, exclude_keys=None):
"""Pretty-print a dict without wrapper keys
Instead of {'key': 'value'}, do
key: value
key:
long
value
"""
pp = []
value_prefix = prefix + ' '
for key in sorted(d):
if exclude_keys and key in exclude_keys:
continue
value = d[key]
if isinstance(value, (dict, list)):
pp.append(prefix + key + ':')
pp.extend(present_value(value_prefix, value))
elif isinstance(value, string_types):
if '\n' in value:
pp.append(prefix + key + ':')
pp.extend(present_value(value_prefix, value))
else:
pp.append(prefix + '%s: %s' % (key, value))
else:
pp.append(prefix + '%s: %s' % (key, value))
return pp


# Disable indentation here
with_indent = False
_base64 = re.compile(r'^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$', re.MULTILINE|re.UNICODE)

def _trim_base64(s):
"""Trim base64 strings"""
if len(s) > 64 and _base64.match(s):
s = s[:16] + '...<snip base64>...' + s[-16:].strip()
return s

def present_multiline_string(prefix, s):
"""Present a multi-line string"""
s = _trim_base64(s)
return indent(s, prefix).splitlines()


def present_output(prefix, output):
"""Present an output (whole output add/delete)
Called by present_value
"""
pp = []
pp.append(prefix + 'output_type: %s' % output['output_type'])
value_prefix = prefix + ' '
if output.get('metadata'):
pp.append(prefix + 'metadata:')
pp.extend(present_value(value_prefix, output['metadata']))
if output['output_type'] in {'display_data', 'execute_result'} and 'data' in output:
pp.append(prefix + 'data:')
pp.extend(present_dict_no_markup(value_prefix, output['data']))

pp.extend(present_dict_no_markup(prefix, output,
exclude_keys={'output_type', 'metadata', 'data'},
))

return pp


def present_cell(prefix, cell):
"""Present a cell as a scalar (whole cell delete/add)
Called by present_value
"""
pp = []
pp.append('')
pp.append(prefix + "%s cell:" % cell['cell_type'])
key_prefix = prefix + ' '
value_prefix = prefix + ' '

if cell.get('execution_count') is not None:
pp.append(key_prefix + 'execution_count: %s' % cell['execution_count'])

if cell['metadata']:
pp.append(key_prefix + 'metadata:')
pp.extend(present_value(value_prefix, cell['metadata']))

pp.append(key_prefix + 'source:')
pp.extend(present_multiline_string(value_prefix, cell['source']))

if cell.get('outputs'):
pp.append(key_prefix + 'outputs:')
for output in cell['outputs']:
pp.extend(present_output(value_prefix, output))

# present_value on anything we haven't special-cased yet
pp.extend(present_dict_no_markup(key_prefix, cell,
exclude_keys={'cell_type', 'source', 'execution_count', 'outputs', 'metadata'},
))
return pp

def present_value(prefix, arg):
"""Present a whole value that is either added or deleted.
Calls out to other formatters for cells, outputs, and multiline strings.
Uses pprint.pformat, otherwise.
"""
# TODO: improve pretty-print of arbitrary values?
if isinstance(arg, dict):
if 'cell_type' in arg:
return present_cell(prefix, arg)
elif 'output_type' in arg:
return present_output(prefix, arg)
elif isinstance(arg, list) and arg:
first = arg[0]
if isinstance(first, dict):
if 'cell_type' in first:
return chain(*[ present_cell(prefix + ' ', cell) for cell in arg ])
elif 'output_type' in first:
return chain(*[ present_output(prefix + ' ', out) for out in arg ])
elif isinstance(arg, string_types):
return present_multiline_string(prefix, arg)

lines = pprint.pformat(arg).splitlines()
return [prefix + line for line in lines]

Expand Down Expand Up @@ -91,9 +228,30 @@ def present_list_diff(a, d, path):

return pp


def present_string_diff(a, di, path):
"Pretty-print a nbdime diff."
if _base64.match(a):
return ['<base64 data changed>']
b = patch(a, di)
td = tempfile.mkdtemp()
try:
with open(os.path.join(td, 'before'), 'w') as f:
f.write(a)
with open(os.path.join(td, 'after'), 'w') as f:
f.write(b)
print(which)
if which('git'):
cmd = 'git diff --no-index --color-words'.split()
heading_lines = 4
else:
cmd = ['diff']
heading_lines = 0
p = Popen(cmd + ['before', 'after'], cwd=td, stdout=PIPE)
out, _ = p.communicate()
dif = out.decode('utf8')
finally:
shutil.rmtree(td)
return dif.splitlines()[heading_lines:]

consumed = 0
lines = []
Expand Down Expand Up @@ -179,6 +337,20 @@ def present_diff(a, di, path, indent=True):


def pretty_print_notebook_diff(afn, bfn, a, di):
"""Pretty-print a notebook diff
Parameters
----------
afn: str
Filename of a, the base notebook
bfn: str
Filename of b, the updated notebook
a: dict
The base notebook object
di: diff
The diff object describing the transformation from a to b
"""
p = present_diff(a, di, path="a", indent=False)
if p:
p = [header.format(afn=afn, bfn=bfn)] + p
Expand Down
179 changes: 179 additions & 0 deletions nbdime/tests/test_prettyprint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
from __future__ import unicode_literals
from __future__ import print_function

try:
from base64 import encodebytes
except ImportError:
from base64 import encodestring as encodebytes
import os
from pprint import pformat
try:
from unittest import mock
except ImportError:
import mock

from nbformat import v4

from nbdime import prettyprint as pp
from nbdime.diffing import diff

def b64text(nbytes):
"""Return n bytes as base64-encoded text"""
return encodebytes(os.urandom(nbytes)).decode('ascii')

def test_present_dict_no_markup():
d = {
'a': 5,
'b': [1,2,3],
'c': {
'x': 'y',
},
'd': 10,
'short': 'text',
'long': 'long\ntext',
}
prefix = '- '
lines = pp.present_dict_no_markup(prefix, d, exclude_keys={'d',})
text = '\n'.join(lines)
print(text)
for key in d:
if key != 'd':
mark = '- %s:' % key
assert mark in text
assert "short: text" in text
assert 'long:\n' in text
assert 'd:' not in text

def test_present_multiline_string_b64():
ins = b64text(1024)
prefix = '+ '
lines = pp.present_multiline_string(prefix, ins)
assert len(lines) == 1
line = lines[0]
assert line.startswith(prefix)
assert len(line) < 100
assert 'snip base64' in line

def test_present_multiline_string_short():
ins = 'short string'
prefix = '+ '
lines = pp.present_multiline_string(prefix, ins)
assert lines == [prefix + ins]

def test_present_multiline_string_long():
ins = '\n'.join('line %i' % i for i in range(64))
prefix = '+ '
lines = pp.present_multiline_string(prefix, ins)
assert len(lines) == 64
assert (prefix + 'line 32') in lines

def test_present_value_int():
lines = pp.present_value('+', 5)
assert lines == ['+5']

def test_present_value_str():
lines = pp.present_value('+', 'x')
assert lines == ['+x']

def test_present_value_dict():
d = {'key': 5}
lines = pp.present_value('+ ', d)
assert '\n'.join(lines) == '+ ' + pformat(d)

def test_present_value_list():
lis = ['a', 'b']
lines = pp.present_value('+ ', lis)
assert '\n'.join(lines) == '+ ' + pformat(lis)

def test_present_stream_output():
output = v4.new_output('stream', name='stdout', text='some\ntext')
lines = pp.present_value('+ ', output)
assert lines == [
'+ output_type: stream',
"+ name: stdout",
"+ text:",
"+ some",
"+ text",
]

def test_present_display_data():
output = v4.new_output('display_data', {
'text/plain': 'text',
'image/png': b64text(1024),
})
lines = pp.present_value('+ ', output)
text = '\n'.join(lines)
assert 'output_type: display_data' in text
assert len(text) < 500
assert 'snip base64' in text
assert 'image/png' in text
assert "text/plain: text" in text
assert all(line.startswith('+ ') for line in lines if line)

def test_present_markdown_cell():
cell = v4.new_markdown_cell(source='# Heading\n\n*some markdown*')
lines = pp.present_value('+ ', cell)
text = '\n'.join(lines)
assert lines[0] == ''
assert lines[1] == '+ markdown cell:'
assert all(line.startswith('+ ') for line in lines if line)
assert 'source:' in text
assert '# Heading' in text
assert '' in lines
assert '*some markdown*' in text

def test_present_code_cell():
cell = v4.new_code_cell(source='def foo()',
outputs=[
v4.new_output('stream', name='stdout', text='some\ntext'),
v4.new_output('display_data', {'text/plain': 'hello display'}),
]
)
lines = pp.present_value('+ ', cell)
text = '\n'.join(lines)
assert lines[0] == ''
assert lines[1] == '+ code cell:'


def test_present_dict_diff():
a = {'a': 1}
b = {'a': 2}
di = diff(a, b, path='x/y')
lines = pp.present_diff(a, di, path='x/y')
assert lines == [
' replace at x/y/a:',
' - 1',
' +2',
]

def test_present_list_diff():
a = [1]
b = [2]
path = 'a/b'
di = diff(a, b, path=path)
lines = pp.present_diff(a, di, path=path)
assert lines == [
' delete a/b/0:',
' - [1]',
' insert before a/b/0:',
' + [2]',
]

def test_present_string_diff():
a = '\n'.join(['line 1', 'line 2', 'line 3', ''])
b = '\n'.join(['line 1', 'line 3', 'line 4', ''])
path = 'a/b'
di = diff(a, b, path=path)
with mock.patch('nbdime.prettyprint.which', lambda cmd: None):
lines = pp.present_diff(a, di, path=path)
text = '\n'.join(lines)
assert '< line 2' in text
assert '> line 4' in text

def test_present_string_diff_b64():
a = b64text(1024)
b = b64text(800)
path = 'a/b'
di = diff(a, b, path=path)
lines = pp.present_diff(a, di, path=path)
assert lines == [' <base64 data changed>']

0 comments on commit d15e6fd

Please sign in to comment.