1# -*- coding: utf-8 -*-
2
3# Copyright (c) Jupyter Development Team.
4# Distributed under the terms of the Modified BSD License.
5
6from collections import namedtuple
7import datetime
8from difflib import unified_diff
9import hashlib
10import io
11import os
12import pprint
13import re
14import shutil
15from subprocess import Popen, PIPE
16import sys
17import tempfile
18
19from shutil import which
20
21import colorama
22
23from .diff_format import NBDiffFormatError, DiffOp, op_patch
24from .ignorables import diff_ignorables
25from .patching import patch
26from .utils import star_path, split_path, join_path
27from .utils import as_text, as_text_lines
28from .log import warning
29
30
31# Indentation offset in pretty-print
32IND = "  "
33
34# Max line width used some placed in pretty-print
35# TODO: Use this for line wrapping some places?
36MAXWIDTH = 78
37
38git_diff_print_cmd = 'git diff --no-index --color-words before after'
39diff_print_cmd = 'diff before after'
40git_mergefile_print_cmd = 'git merge-file -p local base remote'
41diff3_print_cmd = 'diff3 -m local base remote'
42
43
44DIFF_ENTRY_END = '\n'
45
46ColoredConstants = namedtuple('ColoredConstants', (
47    'KEEP',
48    'REMOVE',
49    'ADD',
50    'INFO',
51    'RESET',
52))
53
54
55col_const = {
56    True: ColoredConstants(
57        KEEP   = '{color}   '.format(color=''),
58        REMOVE = '{color}-  '.format(color=colorama.Fore.RED),
59        ADD    = '{color}+  '.format(color=colorama.Fore.GREEN),
60        INFO   = '{color}## '.format(color=colorama.Fore.BLUE),
61        RESET  = colorama.Style.RESET_ALL,
62    ),
63
64    False: ColoredConstants(
65        KEEP   = '   ',
66        REMOVE = '-  ',
67        ADD    = '+  ',
68        INFO   = '## ',
69        RESET  = '',
70    )
71}
72
73
74class PrettyPrintConfig:
75    def __init__(
76            self,
77            out=sys.stdout,
78            include=None,
79            color_words=False,
80            use_git = True,
81            use_diff = True,
82            use_color = True,
83            language = None
84            ):
85        self.out = out
86        if include is None:
87            for key in diff_ignorables:
88                setattr(self, key, True)
89        else:
90            for key in diff_ignorables:
91                setattr(self, key, getattr(include, key, True))
92
93        self.color_words = color_words
94
95        self.use_git = use_git
96        self.use_diff = use_diff
97        self.use_color = use_color
98        self.language = language
99
100    def should_ignore_path(self, path):
101        starred = star_path(split_path(path))
102        if starred.startswith('/cells/*/source'):
103            return not self.sources
104        if starred.startswith('/cells/*/attachments'):
105            return not self.attachments
106        if starred.startswith('/cells/*/metadata') or starred.startswith('/metadata'):
107            return not self.metadata
108        if starred.startswith('/cells/*/id'):
109            return not self.id
110        if starred.startswith('/cells/*/outputs'):
111            return (
112                not self.outputs or
113                (starred == '/cells/*/outputs/*/execution_count' and
114                 not self.details))
115        # Can check against '/cells/*/' since we've processed all other
116        # sub-keys that we know about above.
117        if starred.startswith('/cells/*/'):
118            return not self.details
119        if starred.startswith('/nbformat'):
120            return not self.details
121        return False
122
123    @property
124    def KEEP(self):
125        return col_const[self.use_color].KEEP
126
127    @property
128    def REMOVE(self):
129        return col_const[self.use_color].REMOVE
130
131    @property
132    def ADD(self):
133        return col_const[self.use_color].ADD
134
135    @property
136    def INFO(self):
137        return col_const[self.use_color].INFO
138
139    @property
140    def RESET(self):
141        return col_const[self.use_color].RESET
142
143DefaultConfig = PrettyPrintConfig()
144
145
146def external_merge_render(cmd, b, l, r):
147    b = as_text(b)
148    l = as_text(l)
149    r = as_text(r)
150    td = tempfile.mkdtemp()
151    try:
152        with io.open(os.path.join(td, 'local'), 'w', encoding="utf8") as f:
153            f.write(l)
154        with io.open(os.path.join(td, 'base'), 'w', encoding="utf8") as f:
155            f.write(b)
156        with io.open(os.path.join(td, 'remote'), 'w', encoding="utf8") as f:
157            f.write(r)
158        assert all(fn in cmd for fn in ['local', 'base', 'remote']), (
159            'invalid cmd argument for external merge renderer')
160        p = Popen(cmd, cwd=td, stdout=PIPE)
161        output, errors = p.communicate()
162        status = p.returncode
163        output = output.decode('utf8')
164        # normalize newlines
165        output = output.replace('\r\n', '\n')
166    finally:
167        shutil.rmtree(td)
168    return output, status
169
170
171def external_diff_render(cmd, a, b):
172    a = as_text(a)
173    b = as_text(b)
174    td = tempfile.mkdtemp()
175    try:
176        # TODO: Pass in language information so that an appropriate file
177        # extension can be used. This should provide a hint to the differ.
178        with io.open(os.path.join(td, 'before'), 'w', encoding="utf8") as f:
179            f.write(a)
180        with io.open(os.path.join(td, 'after'), 'w', encoding="utf8") as f:
181            f.write(b)
182        assert all(fn in cmd for fn in ['before', 'after']), (
183            'invalid cmd argument for external diff renderer: %r' %
184            cmd)
185        p = Popen(cmd, cwd=td, stdout=PIPE)
186        output, errors = p.communicate()
187        status = p.returncode
188        output = output.decode('utf8')
189        r = re.compile(r"^\\ No newline at end of file\n?", flags=re.M)
190        output, n = r.subn("", output)
191        assert n <= 2, 'unexpected output from external diff renderer'
192    finally:
193        shutil.rmtree(td)
194    return output, status
195
196
197def format_merge_render_lines(
198        base, local, remote,
199        base_title, local_title, remote_title,
200        marker_size, include_base):
201    sep0 = "<"*marker_size
202    sep1 = "|"*marker_size
203    sep2 = "="*marker_size
204    sep3 = ">"*marker_size
205
206    if local and local[-1].endswith('\n'):
207        local[-1] = local[-1] + '\n'
208    if remote and remote[-1].endswith('\n'):
209        remote[-1] = remote[-1] + '\n'
210
211    # Extract equal lines at beginning
212    prelines = []
213    i = 0
214    n = min(len(local), len(remote))
215    while i < n and local[i] == remote[i]:
216        prelines.append(local[i])
217        i += 1
218    local = local[i:]
219    remote = remote[i:]
220
221    # Extract equal lines at end
222    postlines = []
223    i = len(local) - 1
224    j = len(remote) - 1
225    while (i >= 0 and i < len(local) and
226           j >= 0 and j < len(remote) and
227           local[i] == remote[j]):
228        postlines.append(local[i])
229        i += 1
230        j += 1
231    postlines = reversed(postlines)
232    local = local[:i+1]
233    remote = remote[:j+1]
234
235    lines = []
236    lines.extend(prelines)
237
238    sep0 = "%s %s\n" % (sep0, local_title)
239    lines.append(sep0)
240    lines.extend(local)
241
242    # This doesn't take prelines and postlines into account
243    # if include_base:
244    #     sep1 = "%s %s\n" % (sep1, base_title)
245    #     lines.append(sep1)
246    #     lines.extend(base)
247
248    sep2 = "%s\n" % (sep2,)
249    lines.append(sep2)
250    lines.extend(remote)
251
252    sep3 = "%s %s\n" % (sep3, remote_title)
253    lines.append(sep3)
254
255    lines.extend(postlines)
256
257    # Make sure all but the last line ends with newline
258    for i in range(len(lines)):
259        if not lines[i].endswith('\n'):
260            lines[i] = lines[i] + '\n'
261    if lines:
262        lines[-1] = lines[-1].rstrip("\r\n")
263
264    return lines
265
266
267def builtin_merge_render(base, local, remote, strategy=None):
268    if local == remote:
269        return local, 0
270
271    # In this extremely simplified merge rendering,
272    # we currently define conflict as local != remote
273
274    if strategy == "use-local":
275        return local, 0
276    elif strategy == "use-remote":
277        return remote, 0
278    elif strategy is not None:
279        warning("Using builtin merge render but ignoring strategy %s", strategy)
280
281    # Styling
282    local_title = "local"
283    base_title = "base"
284    remote_title = "remote"
285    marker_size = 7  # git uses 7 by default
286
287    include_base = False  # TODO: Make option
288
289    local = as_text_lines(local)
290    base = as_text_lines(base)
291    remote = as_text_lines(remote)
292
293    lines = format_merge_render_lines(
294        base, local, remote,
295        base_title, local_title, remote_title,
296        marker_size, include_base
297        )
298
299    merged = "".join(lines)
300    return merged, 1
301
302
303def builtin_diff_render(a, b, config):
304    gen = unified_diff(
305        a.splitlines(False),
306        b.splitlines(False),
307        lineterm='')
308    uni = []
309    for line in gen:
310        if line.startswith('+'):
311            uni.append("%s%s%s" % (config.ADD, line[1:], config.RESET))
312        elif line.startswith('-'):
313            uni.append("%s%s%s" % (config.REMOVE, line[1:], config.RESET))
314        elif line.startswith(' '):
315            uni.append("%s%s%s" % (config.KEEP, line[1:], config.RESET))
316        elif line.startswith('@'):
317            uni.append(line)
318        else:
319            # Don't think this will happen?
320            uni.append("%s%s%s" % (config.KEEP, line[1:], config.RESET))
321    return '\n'.join(uni)
322
323
324def diff_render_with_git(a, b, config):
325    cmd = git_diff_print_cmd
326    if not config.use_color:
327        cmd = cmd.replace(" --color-words", "")
328    elif not config.color_words:
329        # Will do nothing if use_color is not True:
330        cmd = cmd.replace("--color-words", "--color")
331    diff, status = external_diff_render(cmd.split(), a, b)
332    return "".join(diff.splitlines(True)[4:])
333
334
335def diff_render_with_diff(a, b):
336    cmd = diff_print_cmd
337    diff, status = external_diff_render(cmd.split(), a, b)
338    return diff
339
340
341def diff_render_with_difflib(a, b, config):
342    diff = builtin_diff_render(a, b, config)
343    return "".join(diff.splitlines(True)[2:])
344
345
346def diff_render(a, b, config=DefaultConfig):
347    if config.use_git and which('git'):
348        return diff_render_with_git(a, b, config)
349    elif config.use_diff and which('diff'):
350        return diff_render_with_diff(a, b)
351    else:
352        return diff_render_with_difflib(a, b, config)
353
354
355def merge_render_with_git(b, l, r, strategy=None):
356    # Note: git merge-file also takes argument -L to change label if needed
357    cmd = git_mergefile_print_cmd
358    if strategy == "use-local":
359        cmd += " --ours"
360    elif strategy == "use-remote":
361        cmd += " --theirs"
362    elif strategy == "union":
363        cmd += " --union"
364    elif strategy is not None:
365        warning("Using git merge-file but ignoring strategy %s", strategy)
366    merged, status = external_merge_render(cmd.split(), b, l, r)
367
368    # Remove trailing newline if ">>>>>>> remote" is the last line
369    lines = merged.splitlines(True)
370    if "\n" in lines[-1] and (">"*7) in lines[-1]:
371        merged = merged.rstrip()
372    return merged, status
373
374
375def merge_render_with_diff3(b, l, r, strategy=None):
376    # Note: diff3 also takes argument -L to change label if needed
377    cmd = diff3_print_cmd
378    if strategy == "use-local":
379        return l, 0
380    elif strategy == "use-remote":
381        return r, 0
382    elif strategy is not None:
383        warning("Using diff3 but ignoring strategy %s", strategy)
384    merged, status = external_merge_render(cmd.split(), b, l, r)
385    return merged, status
386
387
388def merge_render(b, l, r, strategy=None, config=DefaultConfig):
389    if strategy == "use-base":
390        return b, 0
391    if config.use_git and which('git'):
392        return merge_render_with_git(b, l, r, strategy)
393    elif config.use_diff and which('diff3'):
394        return merge_render_with_diff3(b, l, r, strategy)
395    else:
396        return builtin_merge_render(b, l, r, strategy)
397
398
399def file_timestamp(filename):
400    "Return modification time for filename as a string."
401    if os.path.exists(filename):
402        t = os.path.getmtime(filename)
403        dt = datetime.datetime.fromtimestamp(t)
404        return dt.isoformat(str(" "))
405    else:
406        return "(no timestamp)"
407
408
409def hash_string(s):
410    return hashlib.md5(s.encode("utf8")).hexdigest()
411
412_base64 = re.compile(
413    r'^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$',
414    re.MULTILINE | re.UNICODE)
415
416def _trim_base64(s):
417    """Trim and hash base64 strings"""
418    if len(s) > 64 and _base64.match(s.replace('\n', '')):
419        h = hash_string(s)
420        s = '%s...<snip base64, md5=%s...>' % (s[:8], h[:16])
421    return s
422
423
424def format_value(v):
425    "Format simple value for printing. Snips base64 strings and uses pprint for the rest."
426    if not isinstance(v, str):
427        # Not a string, defer to pprint
428        vstr = pprint.pformat(v)
429    else:
430        # Snip if base64 data
431        vstr = _trim_base64(v)
432    return vstr
433
434
435def pretty_print_value(value, prefix="", config=DefaultConfig):
436    """Print a possibly complex value with all lines prefixed.
437
438    Calls out to generic formatters based on value
439    type for dicts, lists, and multiline strings.
440    Uses format_value for simple values.
441    """
442    if isinstance(value, dict):
443        pretty_print_dict(value, (), prefix, config)
444    elif isinstance(value, list) and value:
445        pretty_print_list(value, prefix, config)
446    else:
447        pretty_print_multiline(format_value(value), prefix, config)
448
449
450def pretty_print_value_at(value, path, prefix="", config=DefaultConfig):
451    """Print a possibly complex value with all lines prefixed.
452
453    Calls out to other specialized formatters based on path
454    for cells, outputs, attachments, and more generic formatters
455    based on type for dicts, lists, and multiline strings.
456    Uses format_value for simple values.
457    """
458    # Format starred version of path
459    if path is None:
460        starred = None
461    else:
462        if path.startswith('/'):
463            path_prefix, path_trail = ('', path)
464        else:
465            path_prefix, path_trail = path.split('/', 1)
466        starred = star_path(split_path(path_trail))
467
468    # Check if we can handle path with specific formatter
469    if starred is not None:
470        if starred == "/cells/*":
471            pretty_print_cell(None, value, prefix, True, config)
472        elif starred == "/cells":
473            for cell in value:
474                pretty_print_cell(None, cell, prefix, True, config)
475        elif starred == "/cells/*/outputs/*":
476            pretty_print_output(None, value, prefix, config)
477        elif starred == "/cells/*/outputs":
478            for output in value:
479                pretty_print_output(None, output, prefix, config)
480        elif starred == "/cells/*/attachments":
481            pretty_print_attachments(value, prefix, config)
482        else:
483            starred = None
484
485    if starred is None:
486        pretty_print_value(value, prefix, config)
487
488
489def pretty_print_key(k, prefix, config):
490    config.out.write("%s%s:\n" % (prefix, k))
491
492
493def pretty_print_key_value(k, v, prefix, config):
494    config.out.write("%s%s: %s\n" % (prefix, k, v))
495
496
497def pretty_print_diff_action(msg, path, config):
498    config.out.write("%s%s %s:%s\n" % (config.INFO, msg, path, config.RESET))
499
500
501def pretty_print_item(k, v, prefix="", config=DefaultConfig):
502    if isinstance(v, dict):
503        pretty_print_key(k, prefix, config)
504        pretty_print_dict(v, (), prefix+IND, config)
505    elif isinstance(v, list):
506        pretty_print_key(k, prefix, config)
507        pretty_print_list(v, prefix+IND, config)
508    else:
509        vstr = format_value(v)
510        if "\n" in vstr:
511            # Multiline strings
512            pretty_print_key(k, prefix, config)
513            for line in vstr.splitlines(False):
514                config.out.write("%s%s\n" % (prefix+IND, line))
515        else:
516            # Singleline strings
517            pretty_print_key_value(k, vstr, prefix, config)
518
519
520def pretty_print_multiline(text, prefix="", config=DefaultConfig):
521    assert isinstance(text, str), 'expected string argument'
522
523    # Preprend prefix to lines, letting lines keep their own newlines
524    lines = text.splitlines(True)
525    for line in lines:
526        config.out.write(prefix + line)
527
528    # If the final line doesn't have a newline,
529    # make sure we still start a new line
530    if not text.endswith("\n"):
531        config.out.write("\n")
532
533
534def pretty_print_list(li, prefix="", config=DefaultConfig):
535    listr = pprint.pformat(li)
536    if len(listr) < MAXWIDTH - len(prefix) and "\\n" not in listr:
537        config.out.write("%s%s\n" % (prefix, listr))
538    else:
539        for k, v in enumerate(li):
540            pretty_print_item("item[%d]" % k, v, prefix, config)
541
542
543def pretty_print_dict(d, exclude_keys=(), prefix="", config=DefaultConfig):
544    """Pretty-print a dict without wrapper keys
545
546    Instead of {'key': 'value'}, do
547
548        key: value
549        key:
550          long
551          value
552
553    """
554    for k in sorted(set(d) - set(exclude_keys)):
555        v = d[k]
556        pretty_print_item(k, v, prefix, config)
557
558
559def pretty_print_metadata(md, known_keys, prefix="", config=DefaultConfig):
560    md1 = {}
561    md2 = {}
562    for k in md:
563        if k in known_keys:
564            md1[k] = md[k]
565        else:
566            md2[k] = md[k]
567    if md1:
568        pretty_print_key("metadata (known keys)", prefix, config)
569        pretty_print_dict(md1, (), prefix+IND, config)
570    if md2:
571        pretty_print_key("metadata (unknown keys)", prefix, config)
572        pretty_print_dict(md2, (), prefix+IND, config)
573
574
575def pretty_print_output(i, output, prefix="", config=DefaultConfig):
576    oprefix = prefix+IND
577    numstr = "" if i is None else " %d" % i
578    k = "output%s" % (numstr,)
579    pretty_print_key(k, prefix, config)
580
581    item_keys = ("output_type", "execution_count",
582                 "name", "text", "data",
583                 "ename", "evalue", "traceback")
584    for k in item_keys:
585        v = output.get(k)
586        if v:
587            pretty_print_item(k, v, oprefix, config)
588
589    exclude_keys = {"output_type", "metadata", "traceback"} | set(item_keys)
590
591    metadata = output.get("metadata")
592    if metadata:
593        known_output_metadata_keys = {"isolated"}
594        pretty_print_metadata(metadata, known_output_metadata_keys, oprefix, config)
595
596    pretty_print_dict(output, exclude_keys, oprefix, config)
597
598
599def pretty_print_outputs(outputs, prefix="", config=DefaultConfig):
600    pretty_print_key("outputs", prefix, config)
601    for i, output in enumerate(outputs):
602        pretty_print_output(i, output, prefix+IND, config)
603
604
605def pretty_print_attachments(attachments, prefix="", config=DefaultConfig):
606    pretty_print_key("attachments", prefix, config)
607    for name in sorted(attachments):
608        pretty_print_item(name, attachments[name], prefix+IND, config)
609
610try:
611    from pygments import highlight
612    from pygments.formatters import Terminal256Formatter
613    from pygments.lexers import find_lexer_class_by_name
614    from pygments.util import ClassNotFound
615
616    def colorize_source(source, lexer_name):
617        try:
618            lexer = find_lexer_class_by_name(lexer_name)()
619        except ClassNotFound:
620            return source
621        formatter = Terminal256Formatter()
622        return highlight(source, lexer, formatter)
623
624except ImportError as e:
625    def colorize_source(source, *args, **kwargs):
626        return source
627
628
629def pretty_print_source(source, prefix="", is_markdown=False, config=DefaultConfig):
630    pretty_print_key("source", prefix, config)
631    if not prefix.strip() and (is_markdown or config.language):
632        source_highlighted = colorize_source(
633            source,
634            'markdown' if is_markdown else config.language
635        )
636    else:
637        source_highlighted = source
638    pretty_print_multiline(source_highlighted, prefix+IND, config)
639
640
641def pretty_print_cell(i, cell, prefix="", force_header=False, config=DefaultConfig):
642    key_prefix = prefix+IND
643
644    def c():
645        "Write cell header first time this is called."
646        if not c.called:
647            # Write cell type and optionally number:
648            numstr = "" if i is None else " %d" % i
649            k = "%s cell%s" % (cell.get("cell_type"), numstr)
650            pretty_print_key(k, prefix, config)
651            c.called = True
652    c.called = False
653
654    if force_header:
655        c()
656
657    execution_count = cell.get("execution_count")
658    if execution_count and config.details:
659        # Write execution count if there (only source cells)
660        c()
661        pretty_print_item("execution_count", execution_count, key_prefix, config)
662
663    metadata = cell.get("metadata")
664    if metadata and config.metadata:
665        # Write cell metadata
666        c()
667        known_cell_metadata_keys = {
668            "collapsed", "autoscroll", "deletable", "format", "name", "tags",
669        }
670        pretty_print_metadata(
671            cell.metadata,
672            known_cell_metadata_keys,
673            key_prefix,
674            config)
675
676    source = cell.get("source")
677    if source and config.sources:
678        is_markdown = cell.get('cell_type', None) == 'markdown'
679        # Write source
680        c()
681        pretty_print_source(source, key_prefix, is_markdown=is_markdown, config=config)
682
683    attachments = cell.get("attachments")
684    if attachments and config.attachments:
685        # Write attachment if there (only markdown and raw cells)
686        c()
687        pretty_print_attachments(attachments, key_prefix, config)
688
689    outputs = cell.get("outputs")
690    if outputs and config.outputs:
691        # Write outputs if there (only source cells)
692        c()
693        pretty_print_outputs(outputs, key_prefix, config)
694
695    exclude_keys = {
696        'cell_type', 'source', 'execution_count', 'outputs', 'metadata',
697        'id', 'attachment',
698    }
699    if (set(cell) - exclude_keys) and config.details:
700        # present anything we haven't special-cased yet (future-proofing)
701        c()
702        pretty_print_dict(cell, exclude_keys, key_prefix, config)
703
704
705def pretty_print_notebook(nb, config=DefaultConfig):
706    """Pretty-print a notebook for debugging, skipping large details in metadata and output
707
708    Parameters
709    ----------
710
711    nb: dict
712        The notebook object
713    config: PrettyPrintConfig
714        A config object determining what is printed and where
715    """
716    prefix = ""
717
718    if config.language is None:
719        language_info = nb.metadata.get('language_info', {})
720        config.language = language_info.get(
721            'pygments_lexer',
722            language_info.get('name', None)
723        )
724
725    if config.details:
726        # Write notebook header
727        v = "%d.%d" % (nb.nbformat, nb.nbformat_minor)
728        pretty_print_key_value("notebook format", v, prefix, config)
729
730    # Report unknown keys
731    unknown_keys = set(nb.keys()) - {"nbformat", "nbformat_minor", "metadata", "cells"}
732    if unknown_keys:
733        pretty_print_key_value("unknown keys", repr(unknown_keys), prefix, config)
734
735    if config.metadata:
736        # Write notebook metadata
737        known_metadata_keys = {"kernelspec", "language_info"}
738        pretty_print_metadata(nb.metadata, known_metadata_keys, "", config)
739
740    # Write notebook cells
741    for i, cell in enumerate(nb.cells):
742        pretty_print_cell(i, cell, prefix="", config=config)
743
744
745def pretty_print_diff_entry(a, e, path, config=DefaultConfig):
746    if config.should_ignore_path(path):
747        return
748    key = e.key
749    nextpath = "/".join((path, str(key)))
750    op = e.op
751
752    # Recurse to handle patch ops
753    if op == DiffOp.PATCH:
754        # Useful for debugging:
755        #if not (len(e.diff) == 1 and e.diff[0].op == DiffOp.PATCH):
756        #    config.out.write("{}// patch -+{} //{}\n".format(INFO, nextpath, RESET))
757        #else:
758        #    config.out.write("{}// patch... -+{} //{}\n".format(INFO, nextpath, RESET))
759        pretty_print_diff(a[key], e.diff, nextpath, config)
760        return
761
762    if op == DiffOp.ADDRANGE:
763        pretty_print_diff_action("inserted before", nextpath, config)
764        pretty_print_value_at(e.valuelist, path, config.ADD, config)
765
766    elif op == DiffOp.REMOVERANGE:
767        if e.length > 1:
768            keyrange = "{}-{}".format(nextpath, key + e.length - 1)
769        else:
770            keyrange = nextpath
771        pretty_print_diff_action("deleted", keyrange, config)
772        pretty_print_value_at(a[key: key + e.length], path, config.REMOVE, config)
773
774    elif op == DiffOp.REMOVE:
775        if config.should_ignore_path(nextpath):
776            return
777        pretty_print_diff_action("deleted", nextpath, config)
778        pretty_print_value_at(a[key], nextpath, config.REMOVE, config)
779
780    elif op == DiffOp.ADD:
781        if config.should_ignore_path(nextpath):
782            return
783        pretty_print_diff_action("added", nextpath, config)
784        pretty_print_value_at(e.value, nextpath, config.ADD, config)
785
786    elif op == DiffOp.REPLACE:
787        if config.should_ignore_path(nextpath):
788            return
789        aval = a[key]
790        bval = e.value
791        if type(aval) is not type(bval):
792            typechange = " (type changed from %s to %s)" % (
793                aval.__class__.__name__, bval.__class__.__name__)
794        else:
795            typechange = ""
796        pretty_print_diff_action("replaced" + typechange, nextpath, config)
797        pretty_print_value_at(aval, nextpath, config.REMOVE, config)
798        pretty_print_value_at(bval, nextpath, config.ADD, config)
799
800    else:
801        raise NBDiffFormatError("Unknown list diff op {}".format(op))
802
803    config.out.write(DIFF_ENTRY_END + config.RESET)
804
805
806def pretty_print_dict_diff(a, di, path, config=DefaultConfig):
807    "Pretty-print a nbdime diff."
808    for key, e in sorted([(e.key, e) for e in di], key=lambda x: x[0]):
809        pretty_print_diff_entry(a, e, path, config)
810
811
812def pretty_print_list_diff(a, di, path, config=DefaultConfig):
813    "Pretty-print a nbdime diff."
814    for e in di:
815        pretty_print_diff_entry(a, e, path, config)
816
817
818def pretty_print_string_diff(a, di, path, config=DefaultConfig):
819    "Pretty-print a nbdime diff."
820    pretty_print_diff_action("modified", path, config)
821
822    b = patch(a, di)
823
824    ta = _trim_base64(a)
825    tb = _trim_base64(b)
826
827    if ta != a or tb != b:
828        if ta != a:
829            config.out.write('%s%s\n' % (config.REMOVE, ta))
830        else:
831            pretty_print_value_at(a, path, config.REMOVE, config)
832        if tb != b:
833            config.out.write('%s%s\n' % (config.ADD, tb))
834        else:
835            pretty_print_value_at(b, path, config.ADD, config)
836    elif "\n" in a or "\n" in b:
837        # Delegate multiline diff formatting
838        diff = diff_render(a, b, config)
839        config.out.write(diff)
840    else:
841        # Just show simple -+ single line (usually metadata values etc)
842        config.out.write("%s%s\n" % (config.REMOVE, a))
843        config.out.write("%s%s\n" % (config.ADD, b))
844
845    config.out.write(DIFF_ENTRY_END + config.RESET)
846
847
848def pretty_print_diff(a, di, path, config=DefaultConfig):
849    "Pretty-print a nbdime diff."
850    if isinstance(a, dict):
851        pretty_print_dict_diff(a, di, path, config)
852    elif isinstance(a, list):
853        pretty_print_list_diff(a, di, path, config)
854    elif isinstance(a, str):
855        pretty_print_string_diff(a, di, path, config)
856    else:
857        raise NBDiffFormatError(
858            "Invalid type {} for diff presentation.".format(type(a))
859        )
860
861
862notebook_diff_header = """\
863nbdiff {afn} {bfn}
864--- {afn}{atime}
865+++ {bfn}{btime}
866"""
867
868def pretty_print_notebook_diff(afn, bfn, a, di, config=DefaultConfig):
869    """Pretty-print a notebook diff
870
871    Parameters
872    ----------
873
874    afn: str
875        Filename of a, the base notebook
876    bfn: str
877        Filename of b, the updated notebook
878    a: dict
879        The base notebook object
880    di: diff
881        The diff object describing the transformation from a to b
882    config: PrettyPrintConfig
883        Config object determining what gets printed and where
884    """
885    if di:
886        path = ""
887        atime = "  " + file_timestamp(afn)
888        btime = "  " + file_timestamp(bfn)
889        config.out.write(notebook_diff_header.format(
890            afn=afn, bfn=bfn, atime=atime, btime=btime))
891        pretty_print_diff(a, di, path, config)
892
893
894def pretty_print_merge_decision(base, decision, config=DefaultConfig):
895    prefix = IND
896
897    path = join_path(decision.common_path)
898    confnote = "conflicted " if decision.conflict else ""
899    config.out.write("%s%sdecision at %s:%s\n" % (
900        config.INFO.replace("##", "===="), confnote, path, config.RESET))
901
902    diff_keys = ("diff", "local_diff", "remote_diff", "custom_diff", "similar_insert")
903    exclude_keys = set(diff_keys) | {"common_path", "action", "conflict"}
904    pretty_print_dict(decision, exclude_keys, prefix, config)
905
906    for dkey in diff_keys:
907        diff = decision.get(dkey)
908
909        if (dkey == "remote_diff" and decision.action == "either" and
910                diff == decision.get("local_diff")):
911            # Skip remote diff
912            continue
913        elif (dkey == "local_diff" and decision.action == "either" and
914                diff == decision.get("remote_diff")):
915            note = " (same as remote_diff)"
916        elif dkey.startswith(decision.action):
917            note = " (selected)"
918        else:
919            note = ""
920
921        if diff:
922            config.out.write("%s%s%s:%s\n" % (
923                config.INFO.replace("##", "---"), dkey, note, config.RESET))
924            value = base
925            for i, k in enumerate(decision.common_path):
926                if isinstance(value, str):
927                    # Example case:
928                    #   common_path = /cells/0/source/3
929                    #   value = nb.cells[0].source
930                    #   k = line number 3
931                    #   k is last item in common_path
932                    assert i == len(decision.common_path) - 1, (
933                        'invalid discision common path, tries to subindex string: %r' %
934                        decision.common_path)
935
936                    # Diffs on strings are usually line-based, _except_
937                    # when common_path points to a line within a string.
938                    # Wrap character based diff in a patch op with line
939                    # number to normalize.
940                    diff = [op_patch(k, diff)]
941                    break
942                else:
943                    # Either a list or dict, get subvalue
944                    value = value[k]
945            pretty_print_diff(value, diff, path, config)
946
947
948#def pretty_print_string_diff(string, lineno, diff, config):
949#    line = string.splitlines(True)[lineno]
950#    pretty_print_diff_entry(e)
951
952
953def pretty_print_merge_decisions(base, decisions, config=DefaultConfig):
954    """Pretty-print notebook merge decisions
955
956    Parameters
957    ----------
958
959    base: dict
960        The base notebook object
961    decisions: list
962        The list of merge decisions
963    """
964    conflicted = [d for d in decisions if d.conflict]
965    config.out.write("%d conflicted decisions of %d total:\n"
966              % (len(conflicted), len(decisions)))
967    for d in decisions:
968        pretty_print_merge_decision(base, d, config)
969
970
971def pretty_print_notebook_merge(bfn, lfn, rfn, bnb, lnb, rnb, mnb, decisions, config=DefaultConfig):
972    """Pretty-print a notebook merge
973
974    Parameters
975    ----------
976
977    bfn: str
978        Filename of the base notebook
979    lfn: str
980        Filename of the local notebook
981    rfn: str
982        Filename of the remote notebook
983    bnb: dict
984        The base notebook object
985    lnb: dict
986        The local notebook object
987    rnb: dict
988        The remote notebook object
989    mnb: dict
990        The partially merged notebook object
991    decisions: list
992        The list of merge decisions including conflicts
993    """
994    pretty_print_merge_decisions(bnb, decisions, config)
995