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