1# coding: utf-8 2 3# Copyright (c) Jupyter Development Team. 4# Distributed under the terms of the Modified BSD License. 5 6import pytest 7import nbformat 8from nbformat.v4 import new_notebook, new_code_cell 9from collections import defaultdict 10 11from nbdime import merge_notebooks, diff 12from nbdime.diff_format import op_patch 13from nbdime.utils import Strategies 14from nbdime.merging.generic import decide_merge, decide_merge_with_diff 15from nbdime.merging.decisions import apply_decisions 16from nbdime.merging.strategies import _cell_marker_format 17 18from .utils import outputs_to_notebook, sources_to_notebook, strip_cell_ids, strip_cell_id 19 20 21def test_decide_merge_strategy_fail(reset_log): 22 """Check that "fail" strategy results in proper exception raised.""" 23 # One level dict 24 base = {"foo": 1} 25 local = {"foo": 2} 26 remote = {"foo": 3} 27 strategies = Strategies({"/foo": "fail"}) 28 with pytest.raises(RuntimeError): 29 # pylint: disable=unused-variable 30 conflicted_decisions = decide_merge(base, local, remote, strategies) 31 32 # Nested dicts 33 base = {"foo": {"bar": 1}} 34 local = {"foo": {"bar": 2}} 35 remote = {"foo": {"bar": 3}} 36 strategies = Strategies({"/foo/bar": "fail"}) 37 with pytest.raises(RuntimeError): 38 # pylint: disable=unused-variable 39 decisions = decide_merge(base, local, remote, strategies) 40 41 # We don't need this for non-leaf nodes and it's currently not implemented 42 # strategies = Strategies({"/foo": "fail"}) 43 # with pytest.raises(RuntimeError): 44 # decisions = decide_merge(base, local, remote, strategies) 45 46 47def test_decide_merge_strategy_clear1(): 48 """Check strategy "clear" in various cases.""" 49 # One level dict, clearing item value (think foo==execution_count) 50 base = {"foo": 1} 51 local = {"foo": 2} 52 remote = {"foo": 3} 53 strategies = Strategies({"/foo": "clear"}) 54 decisions = decide_merge(base, local, remote, strategies) 55 assert apply_decisions(base, decisions) == {"foo": None} 56 assert not any([d.conflict for d in decisions]) 57 58def test_decide_merge_strategy_clear2(): 59 base = {"foo": "1"} 60 local = {"foo": "2"} 61 remote = {"foo": "3"} 62 strategies = Strategies({"/foo": "clear"}) 63 decisions = decide_merge(base, local, remote, strategies) 64 #assert decisions == [] 65 assert apply_decisions(base, decisions) == {"foo": ""} 66 assert not any([d.conflict for d in decisions]) 67 68 # We don't need this for non-leaf nodes and it's currently not implemented 69 # base = {"foo": [1]} 70 # local = {"foo": [2]} 71 # remote = {"foo": [3]} 72 # strategies = Strategies({"/foo": "clear"}) 73 # decisions = decide_merge(base, local, remote, strategies) 74 # assert apply_decisions(base, decisions) == {"foo": []} 75 # assert not any([d.conflict for d in decisions]) 76 77 78def test_decide_merge_strategy_clear_all(): 79 base = {"foo": [1, 2]} 80 local = {"foo": [1, 4, 2]} 81 remote = {"foo": [1, 3, 2]} 82 83 strategies = Strategies({"/foo": "clear-all"}) 84 decisions = decide_merge(base, local, remote, strategies) 85 assert apply_decisions(base, decisions) == {"foo": []} 86 87 base = {"foo": [1, 2]} 88 local = {"foo": [1, 4, 2]} 89 remote = {"foo": [1, 2, 3]} 90 91 strategies = Strategies({"/foo": "clear-all"}) 92 decisions = decide_merge(base, local, remote, strategies) 93 assert apply_decisions(base, decisions) == {"foo": [1, 4, 2, 3]} 94 95 96def test_decide_merge_strategy_remove(): 97 base = {"foo": [1, 2]} 98 local = {"foo": [1, 4, 2]} 99 remote = {"foo": [1, 3, 2]} 100 101 strategies = Strategies({"/foo": "remove"}) 102 decisions = decide_merge(base, local, remote, strategies) 103 assert apply_decisions(base, decisions) == {"foo": [1, 2]} 104 assert decisions[0].local_diff != [] 105 assert decisions[0].remote_diff != [] 106 107 strategies = Strategies({}) 108 decisions = decide_merge(base, local, remote, strategies) 109 assert apply_decisions(base, decisions) == {"foo": [1, 2]} 110 assert decisions[0].local_diff != [] 111 assert decisions[0].remote_diff != [] 112 113 114def test_decide_merge_strategy_use_foo_on_dict_items(): 115 base = {"foo": 1} 116 local = {"foo": 2} 117 remote = {"foo": 3} 118 119 strategies = Strategies({"/foo": "use-base"}) 120 decisions = decide_merge(base, local, remote, strategies) 121 assert not any([d.conflict for d in decisions]) 122 assert apply_decisions(base, decisions) == {"foo": 1} 123 124 strategies = Strategies({"/foo": "use-local"}) 125 decisions = decide_merge(base, local, remote, strategies) 126 assert not any([d.conflict for d in decisions]) 127 assert apply_decisions(base, decisions) == {"foo": 2} 128 129 strategies = Strategies({"/foo": "use-remote"}) 130 decisions = decide_merge(base, local, remote, strategies) 131 assert not any([d.conflict for d in decisions]) 132 assert apply_decisions(base, decisions) == {"foo": 3} 133 134 base = {"foo": {"bar": 1}} 135 local = {"foo": {"bar": 2}} 136 remote = {"foo": {"bar": 3}} 137 138 strategies = Strategies({"/foo/bar": "use-base"}) 139 decisions = decide_merge(base, local, remote, strategies) 140 assert not any([d.conflict for d in decisions]) 141 assert apply_decisions(base, decisions) == {"foo": {"bar": 1}} 142 143 strategies = Strategies({"/foo/bar": "use-local"}) 144 decisions = decide_merge(base, local, remote, strategies) 145 assert not any([d.conflict for d in decisions]) 146 assert apply_decisions(base, decisions) == {"foo": {"bar": 2}} 147 148 strategies = Strategies({"/foo/bar": "use-remote"}) 149 decisions = decide_merge(base, local, remote, strategies) 150 assert not any([d.conflict for d in decisions]) 151 assert apply_decisions(base, decisions) == {"foo": {"bar": 3}} 152 153 154def test_decide_merge_simple_list_insert_conflict_resolution(): 155 # local and remote adds an entry each 156 b = [1] 157 l = [1, 2] 158 r = [1, 3] 159 160 strategies = Strategies({"/*": "use-local"}) 161 decisions = decide_merge(b, l, r, strategies) 162 assert apply_decisions(b, decisions) == l 163 assert not any(d.conflict for d in decisions) 164 165 strategies = Strategies({"/*": "use-remote"}) 166 decisions = decide_merge(b, l, r, strategies) 167 assert apply_decisions(b, decisions) == r 168 assert not any(d.conflict for d in decisions) 169 170 strategies = Strategies({"/*": "use-base"}) 171 decisions = decide_merge(b, l, r, strategies) 172 assert apply_decisions(b, decisions) == b 173 assert not any(d.conflict for d in decisions) 174 175 strategies = Strategies({"/": "clear-all"}) 176 decisions = decide_merge(b, l, r, strategies) 177 assert apply_decisions(b, decisions) == [] 178 assert not any(d.conflict for d in decisions) 179 180@pytest.mark.skip 181def test_decide_merge_simple_list_insert_conflict_resolution__union(): 182 # local and remote adds an entry each 183 b = [1] 184 l = [1, 2] 185 r = [1, 3] 186 187 strategies = Strategies({"/": "union"}) 188 decisions = decide_merge(b, l, r, strategies) 189 assert apply_decisions(b, decisions) == [1, 2, 3] 190 assert not any(d.conflict for d in decisions) 191 192 193def test_decide_merge_list_conflicting_insertions_separate_chunks_v1(): 194 # local and remote adds an equal entry plus a different entry each 195 # First, test when insertions DO NOT chunk together: 196 b = [1, 9] 197 l = [1, 2, 9, 11] 198 r = [1, 3, 9, 11] 199 200 # Check strategyless resolution 201 strategies = Strategies({}) 202 resolved = decide_merge(b, l, r, strategies) 203 expected_partial = [1, 9, 11] 204 assert apply_decisions(b, resolved) == expected_partial 205 assert len(resolved) == 2 206 assert resolved[0].conflict 207 assert not resolved[1].conflict 208 209 strategies = Strategies({"/*": "use-local"}) 210 resolved = decide_merge(b, l, r, strategies) 211 assert apply_decisions(b, resolved) == l 212 assert not any(d.conflict for d in resolved) 213 214 strategies = Strategies({"/*": "use-remote"}) 215 resolved = decide_merge(b, l, r, strategies) 216 assert apply_decisions(b, resolved) == r 217 assert not any(d.conflict for d in resolved) 218 219 strategies = Strategies({"/*": "use-base"}) 220 resolved = decide_merge(b, l, r, strategies) 221 # Strategy is only applied to conflicted decisions: 222 assert apply_decisions(b, resolved) == expected_partial 223 assert not any(d.conflict for d in resolved) 224 225 strategies = Strategies({"/": "clear-all"}) 226 resolved = decide_merge(b, l, r, strategies) 227 assert apply_decisions(b, resolved) == [] 228 assert not any(d.conflict for d in resolved) 229 230 # from _merge_concurrent_inserts: 231 # FIXME: This function doesn't work out so well with new conflict handling, 232 # when an insert (e.g. [2,7] vs [3,7]) gets split into agreement on [7] and 233 # conflict on [2] vs [3], the ordering gets lost. I think this was always 234 # slightly ambiguous in the decision format, as the new inserts will get 235 # the same key and decisions are supposed to be possible to reorder (sort) 236 # without considering original ordering of decisions. To preserve the 237 # ordering, perhaps we can add relative local/remote indices to the decisions? 238 # We had this, where ordering made it work out correctly: 239 # "conflicting insert [2] vs [3] at 1 (base index); 240 # insert [7] at 1 (base index)" 241 # instead we now have this which messes up the ordering: 242 # "insert [7] at 1 (base index); 243 # conflicting insert [2] vs [3] at 1 (base index)" 244 # perhaps change to this: 245 # "insert [7] at key=1 (base index) lkey=1 rkey=1; 246 # conflicting insert [2] vs [3] at key=1 (base index) lkey=0 rkey=0" 247 # then decisions can be sorted on (key,lkey) or (key,rkey) depending on chosen side. 248 # This test covers the behaviour: 249 # py.test -k test_shallow_merge_lists_insert_conflicted -s -vv 250 #DEBUGGING = 1 251 #if DEBUGGING: import ipdb; ipdb.set_trace() 252 253 # Example: 254 # b l r 255 # 1 a x 256 # 2 b y 257 # 3 c 3 258 # 4 4 4 259 # Diffs: 260 # b/l: insert a, b, c; remove 1-3 261 # b/r: insert x, y; remove 1-2 262 # The current chunking splits the removes here: 263 # [insert a, b, c; remove 1-2]; [remove 3] 264 # [insert x, y; remove 1-2] 265 # That results in remove 3 not being conflicted. 266 267def test_decide_merge_list_conflicting_insertions_separate_chunks_v2(): 268 # local and remote adds an equal entry plus a different entry each 269 # First, test when insertions DO NOT chunk together: 270 b = [1, 9] 271 l = [1, 2, 9, 11] 272 r = [1, 3, 9, 11] 273 274 # Check strategyless resolution 275 strategies = Strategies({}) 276 resolved = decide_merge(b, l, r, strategies) 277 expected_partial = [1, 9, 11] 278 assert apply_decisions(b, resolved) == expected_partial 279 assert len(resolved) == 2 280 assert resolved[0].conflict 281 assert not resolved[1].conflict 282 283 284@pytest.mark.skip 285def test_decide_merge_list_conflicting_insertions_separate_chunks__union(): 286 # local and remote adds an equal entry plus a different entry each 287 # First, test when insertions DO NOT chunk together: 288 b = [1, 9] 289 l = [1, 2, 9, 11] 290 r = [1, 3, 9, 11] 291 292 strategies = Strategies({"/": "union"}) 293 resolved = decide_merge(b, l, r, strategies) 294 assert apply_decisions(b, resolved) == [1, 2, 3, 9, 11] 295 assert not any(d.conflict for d in resolved) 296 297 298def test_decide_merge_list_conflicting_insertions_in_chunks(): 299 # Next, test when insertions DO chunk together: 300 b = [1, 9] 301 l = [1, 2, 7, 9] 302 r = [1, 3, 7, 9] 303 304 # Check strategyless resolution 305 strategies = Strategies({}) 306 resolved = decide_merge(b, l, r, strategies) 307 expected_partial = [1, 7, 9] 308 assert apply_decisions(b, resolved) == expected_partial 309 310 strategies = Strategies({"/*": "use-local"}) 311 resolved = decide_merge(b, l, r, strategies) 312 assert apply_decisions(b, resolved) == l 313 assert not any(d.conflict for d in resolved) 314 315 strategies = Strategies({"/*": "use-remote"}) 316 resolved = decide_merge(b, l, r, strategies) 317 assert apply_decisions(b, resolved) == r 318 assert not any(d.conflict for d in resolved) 319 320 strategies = Strategies({"/*": "use-base"}) 321 resolved = decide_merge(b, l, r, strategies) 322 assert apply_decisions(b, resolved) == expected_partial 323 assert not any(d.conflict for d in resolved) 324 325 strategies = Strategies({"/": "clear-all"}) 326 resolved = decide_merge(b, l, r, strategies) 327 assert apply_decisions(b, resolved) == [] 328 assert not any(d.conflict for d in resolved) 329 330 331@pytest.mark.skip 332def test_decide_merge_list_conflicting_insertions_in_chunks__union(): 333 # Next, test when insertions DO chunk together: 334 b = [1, 9] 335 l = [1, 2, 7, 9] 336 r = [1, 3, 7, 9] 337 338 strategies = Strategies({"/": "union"}) 339 resolved = decide_merge(b, l, r, strategies) 340 assert apply_decisions(b, resolved) == [1, 2, 3, 7, 9] 341 assert not any(d.conflict for d in resolved) 342 343 344def test_decide_merge_list_transients(): 345 # For this test, we need to use a custom predicate to ensure alignment 346 common = {'id': 'This ensures alignment'} 347 predicates = defaultdict(lambda: [operator.__eq__], { 348 '/': [lambda a, b: a['id'] == b['id']], 349 }) 350 351 # Setup transient difference in base and local, deletion in remote 352 b = [{'transient': 22}] 353 l = [{'transient': 242}] 354 b[0].update(common) 355 l[0].update(common) 356 r = [] 357 358 # Make decisions based on diffs with predicates 359 ld = diff(b, l, path="", predicates=predicates) 360 rd = diff(b, r, path="", predicates=predicates) 361 362 # Assert that generic merge without strategies gives conflict: 363 strategies = Strategies() 364 decisions = decide_merge_with_diff(b, l, r, ld, rd, strategies) 365 assert len(decisions) == 1 366 assert decisions[0].conflict 367 assert apply_decisions(b, decisions) == b 368 369 # Supply transient list to autoresolve, and check that transient is ignored 370 strategies = Strategies(transients=[ 371 '/*/transient' 372 ]) 373 decisions = decide_merge_with_diff(b, l, r, ld, rd, strategies) 374 assert apply_decisions(b, decisions) == r 375 assert not any(d.conflict for d in decisions) 376 377 378def test_decide_merge_dict_transients(): 379 # Setup transient difference in base and local, deletion in remote 380 b = {'a': {'transient': 22}} 381 l = {'a': {'transient': 242}} 382 r = {} 383 384 # Assert that generic merge gives conflict 385 strategies = Strategies() 386 decisions = decide_merge(b, l, r, strategies) 387 assert apply_decisions(b, decisions) == b 388 assert len(decisions) == 1 389 assert decisions[0].conflict 390 391 # Supply transient list to autoresolve, and check that transient is ignored 392 strategies = Strategies(transients=[ 393 '/a/transient' 394 ]) 395 decisions = decide_merge(b, l, r, strategies) 396 assert apply_decisions(b, decisions) == r 397 assert not any(d.conflict for d in decisions) 398 399 400def test_decide_merge_mixed_nested_transients(): 401 # For this test, we need to use a custom predicate to ensure alignment 402 common = {'id': 'This ensures alignment'} 403 predicates = defaultdict(lambda: [operator.__eq__], { 404 '/': [lambda a, b: a['id'] == b['id']], 405 }) 406 # Setup transient difference in base and local, deletion in remote 407 b = [{'a': {'transient': 22}}] 408 l = [{'a': {'transient': 242}}] 409 b[0].update(common) 410 l[0].update(common) 411 r = [] 412 413 # Make decisions based on diffs with predicates 414 ld = diff(b, l, path="", predicates=predicates) 415 rd = diff(b, r, path="", predicates=predicates) 416 417 # Assert that generic merge gives conflict 418 strategies = Strategies() 419 decisions = decide_merge_with_diff(b, l, r, ld, rd, strategies) 420 assert apply_decisions(b, decisions) == b 421 assert len(decisions) == 1 422 assert decisions[0].conflict 423 424 # Supply transient list to autoresolve, and check that transient is ignored 425 strategies = Strategies(transients=[ 426 '/*/a/transient' 427 ]) 428 decisions = decide_merge_with_diff(b, l, r, ld, rd, strategies) 429 assert apply_decisions(b, decisions) == r 430 assert not any(d.conflict for d in decisions) 431 432 433def test_inline_merge_empty_notebooks(): 434 "Missing fields all around passes through." 435 base = {} 436 local = {} 437 remote = {} 438 expected = {} 439 merged, decisions = merge_notebooks(base, local, remote) 440 assert expected == merged 441 442 443def test_inline_merge_dummy_notebooks(): 444 "Just the basic empty notebook passes through." 445 base = new_notebook() 446 local = new_notebook() 447 remote = new_notebook() 448 expected = new_notebook() 449 merged, decisions = merge_notebooks(base, local, remote) 450 assert expected == merged 451 452 453def test_inline_merge_notebook_version(): 454 "Minor version gets bumped to max." 455 base = new_notebook(nbformat=4, nbformat_minor=0) 456 local = new_notebook(nbformat=4, nbformat_minor=1) 457 remote = new_notebook(nbformat=4, nbformat_minor=2) 458 expected = new_notebook(nbformat=4, nbformat_minor=2) 459 merged, decisions = merge_notebooks(base, local, remote) 460 assert expected == merged 461 462 463def test_inline_merge_notebook_metadata(reset_log): 464 """Merging a wide range of different value types 465 and conflict types in the root /metadata dicts. 466 The goal is to exercise a decent part of the 467 generic diff and merge functionality. 468 """ 469 470 untouched = { 471 "string": "untouched string", 472 "integer": 123, 473 "float": 16.0, 474 "list": ["hello", "world"], 475 "dict": {"first": "Hello", "second": "World"}, 476 } 477 md_in = { 478 1: { 479 "untouched": untouched, 480 "unconflicted": { 481 "int_deleteme": 7, 482 "string_deleteme": "deleteme", 483 "list_deleteme": [7, "deleteme"], 484 "dict_deleteme": {"deleteme": "now", "removeme": True}, 485 "list_deleteitem": [7, "deleteme", 3, "notme", 5, "deletemetoo"], 486 487 "string": "string v1", 488 "integer": 456, 489 "float": 32.0, 490 "list": ["hello", "universe"], 491 "dict": {"first": "Hello", "second": "World", "third": "!"}, 492 }, 493 "conflicted": { 494 "int_delete_replace": 3, 495 "string_delete_replace": "string that will be deleted and modified", 496 "list_delete_replace": [1], 497 "dict_delete_replace": {"k":"v"}, 498 499 # "string": "string v1", 500 # "integer": 456, 501 # "float": 32.0, 502 # "list": ["hello", "universe"], 503 # "dict": {"first": "Hello", "second": "World"}, 504 } 505 }, 506 2: { 507 "untouched": untouched, 508 "unconflicted": { 509 "dict_deleteme": {"deleteme": "now", "removeme": True}, 510 "list_deleteitem": [7, 3, "notme", 5, "deletemetoo"], 511 512 "string": "string v1 equal addition", 513 "integer": 123, # equal change 514 "float": 16.0, # equal change 515 # Equal delete at beginning and insert of two values at end: 516 "list": ["universe", "new items", "same\non\nboth\nsides"], 517 # cases covered: twosided equal value change, onesided delete, onesided replace, onesided insert, twosided insert of same value 518 "dict": {"first": "changed", "second": "World", "third": "!", "newkey": "newvalue", "otherkey": "othervalue"}, 519 }, 520 "conflicted": { 521 "int_delete_replace": 5, 522 "list_delete_replace": [2], 523 524 # "string": "another text", 525 #"integer": 456, 526 # "float": 16.0, 527 # "list": ["hello", "world"], 528 # "dict": {"new": "value", "first": "Hello"}, #"second": "World"}, 529 530 # "added_string": "another text", 531 # "added_integer": 9, 532 # "added_float": 16.0, 533 # "added_list": ["another", "multiverse"], 534 # "added_dict": {"1st": "hey", "2nd": "there"}, 535 } 536 }, 537 3: { 538 "untouched": untouched, 539 "unconflicted": { 540 "list_deleteme": [7, "deleteme"], 541 "list_deleteitem": [7, "deleteme", 3, "notme", 5], 542 543 "string": "string v1 equal addition", 544 "integer": 123, # equal change 545 "float": 16.0, # equal change 546 # Equal delete at beginning and insert of two values at end: 547 "list": ["universe", "new items", "same\non\nboth\nsides"], 548 "dict": {"first": "changed", "third": ".", "newkey": "newvalue"}, 549 }, 550 "conflicted": { 551 "string_delete_replace": "string that is modified here and deleted in the other version", 552 "dict_delete_replace": {"k":"x","q":"r"}, 553 554 # "string": "different message", 555 # "integer": 456, 556 # #"float": 16.0, 557 # "list": ["hello", "again", "world"], 558 # "dict": {"new": "but different", "first": "Hello"}, #"second": "World"}, 559 560 # "added_string": "but not the same string", 561 # #"added_integer": 9, 562 # "added_float": 64.0, 563 # "added_list": ["initial", "values", "another", "multiverse", "trailing", "values"], 564 # "added_dict": {"3rt": "mergeme", "2nd": "conflict"}, 565 } 566 } 567 } 568 569 def join_dicts(dicta, dictb): 570 d = {} 571 d.update(dicta) 572 d.update(dictb) 573 return d 574 575 shared_unconflicted = { 576 "list_deleteitem": [7, 3, "notme", 5], 577 578 "string": "string v1 equal addition", 579 "integer": 123, 580 "float": 16.0, 581 "list": ["universe", "new items", "same\non\nboth\nsides"], 582 "dict": {"first": "changed", "third": ".", "newkey": "newvalue", "otherkey": "othervalue"}, 583 } 584 shared_conflicted = { 585 "int_delete_replace": 3, 586 "string_delete_replace": "string that will be deleted and modified", 587 "list_delete_replace": [1], 588 "dict_delete_replace": {"k":"v"}, 589 590 # #"string": "string v1", 591 # "string": "another textdifferent message", 592 593 # "float": 32.0, 594 # "list": ["hello", "universe"], 595 # "dict": {"first": "Hello", "second": "World"}, 596 # # FIXME 597 } 598 599 md_out = { 600 (1,2,3): { 601 "untouched": untouched, 602 "unconflicted": join_dicts(shared_unconflicted, { 603 # ... 604 }), 605 "conflicted": join_dicts(shared_conflicted, { 606 # ... 607 }), 608 }, 609 (1,3,2): { 610 "untouched": untouched, 611 "unconflicted": join_dicts(shared_unconflicted, { 612 # ... 613 }), 614 "conflicted": join_dicts(shared_conflicted, { 615 # ... 616 }), 617 }, 618 } 619 620 # Fill in expected conflict records 621 for triplet in sorted(md_out.keys()): 622 i, j, k = triplet 623 local_diff = diff(md_in[i]["conflicted"], md_in[j]["conflicted"]) 624 remote_diff = diff(md_in[i]["conflicted"], md_in[k]["conflicted"]) 625 626 # This may not be a necessary test, just checking my expectations 627 assert local_diff == sorted(local_diff, key=lambda x: x.key) 628 assert remote_diff == sorted(remote_diff, key=lambda x: x.key) 629 630 c = { 631 # These are patches on the /metadata dict 632 "local_diff": [op_patch("conflicted", local_diff)], 633 "remote_diff": [op_patch("conflicted", remote_diff)], 634 } 635 md_out[triplet]["nbdime-conflicts"] = c 636 637 # Fill in the trivial merge results 638 for i in (1, 2, 3): 639 for j in (1, 2, 3): 640 for k in (i, j): 641 # For any combination i,j,i or i,j,j the 642 # result should be j with no conflicts 643 md_out[(i,j,k)] = md_in[j] 644 645 tested = set() 646 # Check the trivial merge results 647 for i in (1, 2, 3): 648 for j in (1, 2, 3): 649 for k in (i, j): 650 triplet = (i, j, k) 651 tested.add(triplet) 652 base = new_notebook(metadata=md_in[i]) 653 local = new_notebook(metadata=md_in[j]) 654 remote = new_notebook(metadata=md_in[k]) 655 # For any combination i,j,i or i,j,j the result should be j 656 expected = new_notebook(metadata=md_in[j]) 657 merged, decisions = merge_notebooks(base, local, remote) 658 assert "nbdime-conflicts" not in merged["metadata"] 659 assert not any([d.conflict for d in decisions]) 660 assert expected == merged 661 662 # Check handcrafted merge results 663 for triplet in sorted(md_out.keys()): 664 i, j, k = triplet 665 tested.add(triplet) 666 base = new_notebook(metadata=md_in[i]) 667 local = new_notebook(metadata=md_in[j]) 668 remote = new_notebook(metadata=md_in[k]) 669 expected = new_notebook(metadata=md_out[triplet]) 670 merged, decisions = merge_notebooks(base, local, remote) 671 if "nbdime-conflicts" in merged["metadata"]: 672 assert any([d.conflict for d in decisions]) 673 else: 674 assert not any([d.conflict for d in decisions]) 675 assert expected == merged 676 677 # At least try to run merge without crashing for permutations 678 # of md_in that we haven't constructed expected results for 679 for i in (1, 2, 3): 680 for j in (1, 2, 3): 681 for k in (1, 2, 3): 682 triplet = (i, j, k) 683 if triplet not in tested: 684 base = new_notebook(metadata=md_in[i]) 685 local = new_notebook(metadata=md_in[j]) 686 remote = new_notebook(metadata=md_in[k]) 687 merged, decisions = merge_notebooks(base, local, remote) 688 689 690def test_inline_merge_notebook_metadata_reproduce_bug(reset_log): 691 md_in = { 692 1: { 693 "unconflicted": { 694 "list_deleteitem": [7, "deleteme", 3, "notme", 5, "deletemetoo"], 695 }, 696 "conflicted": { 697 "dict_delete_replace": {"k":"v"}, 698 } 699 }, 700 2: { 701 "unconflicted": { 702 "list_deleteitem": [7, 3, "notme", 5, "deletemetoo"], 703 }, 704 "conflicted": { 705 } 706 }, 707 3: { 708 "unconflicted": { 709 "list_deleteitem": [7, "deleteme", 3, "notme", 5], 710 }, 711 "conflicted": { 712 "dict_delete_replace": {"k":"x"}, 713 } 714 } 715 } 716 717 shared_unconflicted = { 718 "list_deleteitem": [7, 3, "notme", 5], 719 } 720 shared_conflicted = { 721 "dict_delete_replace": {"k":"v"}, 722 } 723 724 md_out = { 725 (1,2,3): { 726 "unconflicted": shared_unconflicted, 727 "conflicted": shared_conflicted 728 }, 729 } 730 731 # Fill in expected conflict records 732 for triplet in sorted(md_out.keys()): 733 i, j, k = triplet 734 local_diff = diff(md_in[i]["conflicted"], md_in[j]["conflicted"]) 735 remote_diff = diff(md_in[i]["conflicted"], md_in[k]["conflicted"]) 736 737 # This may not be a necessary test, just checking my expectations 738 assert local_diff == sorted(local_diff, key=lambda x: x.key) 739 assert remote_diff == sorted(remote_diff, key=lambda x: x.key) 740 741 c = { 742 # These are patches on the /metadata dict 743 "local_diff": [op_patch("conflicted", local_diff)], 744 "remote_diff": [op_patch("conflicted", remote_diff)], 745 } 746 md_out[triplet]["nbdime-conflicts"] = c 747 748 # Check handcrafted merge results 749 triplet = (1,2,3) 750 i, j, k = triplet 751 base = new_notebook(metadata=md_in[i]) 752 local = new_notebook(metadata=md_in[j]) 753 remote = new_notebook(metadata=md_in[k]) 754 expected = new_notebook(metadata=md_out[triplet]) 755 merged, decisions = merge_notebooks(base, local, remote) 756 if "nbdime-conflicts" in merged["metadata"]: 757 assert any([d.conflict for d in decisions]) 758 else: 759 assert not any([d.conflict for d in decisions]) 760 assert expected == merged 761 762 763def test_inline_merge_source_empty(): 764 base = new_notebook() 765 local = new_notebook() 766 remote = new_notebook() 767 expected = new_notebook() 768 merged, decisions = merge_notebooks(base, local, remote) 769 assert merged == expected 770 771 772def code_nb(sources, strip_ids=False): 773 nb = new_notebook(cells=[new_code_cell(s) for s in sources]) 774 strip_cell_ids(nb) 775 return nb 776 777 778def test_inline_merge_source_all_equal(): 779 base = code_nb([ 780 "first source", 781 "other text", 782 "yet more content", 783 ]) 784 local = base 785 remote = base 786 expected = base 787 merged, decisions = merge_notebooks(base, local, remote) 788 assert merged == expected 789 790 791def test_inline_merge_source_cell_deletions(): 792 "Cell deletions on both sides, onesided and agreed." 793 base = code_nb([ 794 "first source", 795 "other text", 796 "yet more content", 797 "and a final line", 798 ]) 799 local = code_nb([ 800 #"first source", 801 "other text", 802 #"yet more content", 803 #"and a final line", 804 ]) 805 remote = code_nb([ 806 "first source", 807 #"other text", 808 "yet more content", 809 #"and a final line", 810 ]) 811 empty = code_nb([]) 812 for a in [base, local, remote, empty]: 813 for b in [base, local, remote, empty]: 814 merged, decisions = merge_notebooks(base, a, b) 815 if a is b: 816 assert merged == a 817 elif a is base: 818 assert merged == b 819 elif b is base: 820 assert merged == a 821 else: 822 # All other combinations will delete all cells 823 assert merged == empty 824 825 826def test_inline_merge_source_onesided_only(): 827 "A mix of changes on one side (delete, patch, remove)." 828 base = code_nb([ 829 "first source", 830 "other text", 831 "yet more content", 832 ]) 833 changed = code_nb([ 834 #"first source", # deleted 835 "other text v2", 836 "a different cell inserted", 837 "yet more content", 838 ]) 839 merged, decisions = merge_notebooks(base, changed, base) 840 assert merged == changed 841 merged, decisions = merge_notebooks(base, base, changed) 842 assert merged == changed 843 844 845def test_inline_merge_source_replace_line(): 846 "More elaborate test of cell deletions on both sides, onesided and agreed." 847 # Note: Merge rendering of conflicted sources here will depend on git/diff/builtin params and availability 848 base = code_nb([ 849 "first source", 850 "other text", 851 "this cell will be deleted and patched", 852 "yet more content", 853 "and a final line", 854 ], strip_ids=True) 855 local = code_nb([ 856 "1st source", # onesided change 857 "other text", 858 #"this cell will be deleted and patched", 859 "some more content", # twosided equal change 860 "And a Final line", # twosided conflicted change 861 ], strip_ids=True) 862 remote = code_nb([ 863 "first source", 864 "other text?", # onesided change 865 "this cell will be deleted and modified", 866 "some more content", # equal 867 "and The final Line", # conflicted 868 ], strip_ids=True) 869 expected = code_nb([ 870 "1st source", 871 "other text?", 872 #'<<<<<<< local <CELL DELETED>\n\n=======\nthis cell will be deleted and modified\n>>>>>>> remote' 873 '<<<<<<< LOCAL CELL DELETED >>>>>>>\nthis cell will be deleted and modified', 874 "some more content", # equal 875 '<<<<<<< local\nAnd a Final line\n=======\nand The final Line\n>>>>>>> remote' 876 ], strip_ids=True) 877 merged, decisions = merge_notebooks(base, local, remote) 878 assert merged == expected 879 expected = code_nb([ 880 "1st source", 881 "other text?", 882 #'<<<<<<< local\nthis cell will be deleted and modified\n=======\n>>>>>>> remote <CELL DELETED>' 883 '<<<<<<< REMOTE CELL DELETED >>>>>>>\nthis cell will be deleted and modified', 884 "some more content", 885 '<<<<<<< local\nand The final Line\n=======\nAnd a Final line\n>>>>>>> remote' 886 ], strip_ids=True) 887 merged, decisions = merge_notebooks(base, remote, local) 888 assert merged == expected 889 890 891def test_inline_merge_source_add_to_line(): 892 "More elaborate test of cell deletions on both sides, onesided and agreed." 893 # Note: Merge rendering of conflicted sources here will depend on git/diff/builtin params and availability 894 base = code_nb([ 895 "first source", 896 "other text", 897 "this cell will be deleted and patched\nhere we add", 898 "yet more content", 899 "and a final line", 900 ], strip_ids=True) 901 local = code_nb([ 902 "1st source", # onesided change 903 "other text", 904 #"this cell will be deleted and patched", 905 "some more content", # twosided equal change 906 "And a Final line", # twosided conflicted change 907 ], strip_ids=True) 908 remote = code_nb([ 909 "first source", 910 "other text?", # onesided change 911 "this cell will be deleted and patched\nhere we add text to a line", 912 "some more content", # equal 913 "and The final Line", # conflicted 914 ], strip_ids=True) 915 expected = code_nb([ 916 "1st source", 917 "other text?", 918 #'<<<<<<< local <CELL DELETED>\n\n=======\nthis cell will be deleted and modified\n>>>>>>> remote' 919 '<<<<<<< LOCAL CELL DELETED >>>>>>>\nthis cell will be deleted and patched\nhere we add text to a line', 920 "some more content", # equal 921 '<<<<<<< local\nAnd a Final line\n=======\nand The final Line\n>>>>>>> remote' 922 ], strip_ids=True) 923 merged, decisions = merge_notebooks(base, local, remote) 924 assert merged == expected 925 expected = code_nb([ 926 "1st source", 927 "other text?", 928 #'<<<<<<< local\nthis cell will be deleted and modified\n=======\n>>>>>>> remote <CELL DELETED>' 929 '<<<<<<< REMOTE CELL DELETED >>>>>>>\nthis cell will be deleted and patched\nhere we add text to a line', 930 "some more content", 931 '<<<<<<< local\nand The final Line\n=======\nAnd a Final line\n>>>>>>> remote' 932 ], strip_ids=True) 933 merged, decisions = merge_notebooks(base, remote, local) 934 assert merged == expected 935 936 937def test_inline_merge_source_patches_both_ends(): 938 "More elaborate test of cell deletions on both sides, onesided and agreed." 939 # Note: Merge rendering of conflicted sources here will depend on git/diff/builtin params and availability 940 base = code_nb([ 941 "first source will be modified", 942 "other text", 943 "this cell will be untouched", 944 "yet more content", 945 "and final line will be changed", 946 ], strip_ids=True) 947 local = code_nb([ 948 "first source will be modified locally", 949 "other text", 950 "this cell will be untouched", 951 "yet more content", 952 "and final line will be changed locally", 953 ], strip_ids=True) 954 remote = code_nb([ 955 "first source will be modified remotely", 956 "other text", 957 "this cell will be untouched", 958 "yet more content", 959 "and final line will be changed remotely", 960 ], strip_ids=True) 961 expected = code_nb([ 962 '<<<<<<< local\nfirst source will be modified locally\n=======\nfirst source will be modified remotely\n>>>>>>> remote', 963 "other text", 964 "this cell will be untouched", 965 "yet more content", 966 '<<<<<<< local\nand final line will be changed locally\n=======\nand final line will be changed remotely\n>>>>>>> remote', 967 ], strip_ids=True) 968 merged, decisions = merge_notebooks(base, local, remote) 969 assert merged == expected 970 expected = code_nb([ 971 '<<<<<<< local\nfirst source will be modified remotely\n=======\nfirst source will be modified locally\n>>>>>>> remote', 972 "other text", 973 "this cell will be untouched", 974 "yet more content", 975 '<<<<<<< local\nand final line will be changed remotely\n=======\nand final line will be changed locally\n>>>>>>> remote', 976 ], strip_ids=True) 977 merged, decisions = merge_notebooks(base, remote, local) 978 assert merged == expected 979 980 981def test_inline_merge_source_patch_delete_conflicts_both_ends(): 982 "More elaborate test of cell deletions on both sides, onesided and agreed." 983 # Note: Merge rendering of conflicted sources here will depend on git/diff/builtin params and availability 984 base = code_nb([ 985 "first source will be modified", 986 "other text", 987 "this cell will be untouched", 988 "yet more content", 989 "and final line will be changed", 990 ]) 991 local = code_nb([ 992 "first source will be modified on one side", 993 "other text", 994 "this cell will be untouched", 995 "yet more content", 996 #"and final line will be deleted locally", 997 ]) 998 remote = code_nb([ 999 #"first source will be deleted remotely", 1000 "other text", 1001 "this cell will be untouched", 1002 "yet more content", 1003 "and final line will be changed on one side", 1004 ]) 1005 expected = code_nb([ 1006 '<<<<<<< REMOTE CELL DELETED >>>>>>>\nfirst source will be modified on one side', 1007 "other text", 1008 "this cell will be untouched", 1009 "yet more content", 1010 '<<<<<<< LOCAL CELL DELETED >>>>>>>\nand final line will be changed on one side', 1011 ]) 1012 merged, decisions = merge_notebooks(base, local, remote) 1013 assert merged == expected 1014 expected = code_nb([ 1015 '<<<<<<< LOCAL CELL DELETED >>>>>>>\nfirst source will be modified on one side', 1016 "other text", 1017 "this cell will be untouched", 1018 "yet more content", 1019 '<<<<<<< REMOTE CELL DELETED >>>>>>>\nand final line will be changed on one side', 1020 ]) 1021 merged, decisions = merge_notebooks(base, remote, local) 1022 assert merged == expected 1023 1024 1025def test_inline_merge_attachments(): 1026 # FIXME: Use output creation utils Vidar wrote in another test file 1027 base = new_notebook() 1028 local = new_notebook() 1029 remote = new_notebook() 1030 expected = new_notebook() 1031 merged, decisions = merge_notebooks(base, local, remote) 1032 assert merged == expected 1033 1034 1035def test_inline_merge_outputs(): 1036 # One cell with two outputs: 1037 base = outputs_to_notebook([['unmodified', 'base']], strip_ids=True) 1038 local = outputs_to_notebook([['unmodified', 'local']], strip_ids=True) 1039 remote = outputs_to_notebook([['unmodified', 'remote']], strip_ids=True) 1040 expected = outputs_to_notebook([[ 1041 'unmodified', 1042 nbformat.v4.new_output( 1043 output_type='stream', name='stderr', 1044 text='<<<<<<< local <modified: text/plain>\n'), 1045 'local', 1046 nbformat.v4.new_output( 1047 output_type='stream', name='stderr', 1048 text='=======\n'), 1049 'remote', 1050 nbformat.v4.new_output( 1051 output_type='stream', name='stderr', 1052 text='>>>>>>> remote <modified: text/plain>\n'), 1053 ]], strip_ids=True) 1054 merged, decisions = merge_notebooks(base, local, remote) 1055 assert merged == expected 1056 1057 1058def test_inline_merge_outputs_conflicting_insert_in_empty(): 1059 # One cell with two outputs: 1060 base = outputs_to_notebook([[]], strip_ids=True) 1061 local = outputs_to_notebook([['local']], strip_ids=True) 1062 remote = outputs_to_notebook([['remote']], strip_ids=True) 1063 expected = outputs_to_notebook([[ 1064 nbformat.v4.new_output( 1065 output_type='stream', name='stderr', 1066 text='<<<<<<< local\n'), 1067 'local', 1068 nbformat.v4.new_output( 1069 output_type='stream', name='stderr', 1070 text='=======\n'), 1071 'remote', 1072 nbformat.v4.new_output( 1073 output_type='stream', name='stderr', 1074 text='>>>>>>> remote\n'), 1075 ]], strip_ids=True) 1076 merged, decisions = merge_notebooks(base, local, remote) 1077 assert merged == expected 1078 1079 1080def test_inline_merge_cells_insertion_similar(): 1081 base = sources_to_notebook([['unmodified']], cell_type='markdown', strip_ids=True) 1082 local = sources_to_notebook([['unmodified'], ['local']], cell_type='markdown', strip_ids=True) 1083 remote = sources_to_notebook([['unmodified'], ['remote']], cell_type='markdown', strip_ids=True) 1084 expected = sources_to_notebook([ 1085 'unmodified', 1086 [ 1087 ("<"*7) + ' local\n', 1088 'local\n', 1089 ("="*7) + '\n', 1090 'remote\n', 1091 (">"*7) + ' remote' 1092 ] 1093 ], cell_type='markdown', strip_ids=True) 1094 merged, decisions = merge_notebooks(base, local, remote) 1095 assert merged == expected 1096 1097 1098def test_inline_merge_cells_insertion_unsimilar(): 1099 base = sources_to_notebook([['unmodified']], cell_type='markdown', strip_ids=True) 1100 local = sources_to_notebook([['unmodified'], ['local\n', 'friendly faces\n', '3.14']], cell_type='markdown', strip_ids=True) 1101 remote = sources_to_notebook([['unmodified'], ['remote\n', 'foo bar baz\n']], cell_type='markdown', strip_ids=True) 1102 expected = sources_to_notebook([ 1103 ['unmodified'], 1104 [_cell_marker_format(("<"*7) + ' local')], 1105 ['local\n', 'friendly faces\n', '3.14'], 1106 [_cell_marker_format("="*7)], 1107 ['remote\n', 'foo bar baz\n'], 1108 [_cell_marker_format((">"*7) + ' remote')], 1109 ], cell_type='markdown', strip_ids=True) 1110 merged, decisions = merge_notebooks(base, local, remote) 1111 assert strip_cell_ids(merged) == expected 1112 1113 1114def test_inline_merge_cells_replacement_similar(): 1115 base = sources_to_notebook([['unmodified'], ['base']], cell_type='markdown', strip_ids=False) 1116 local = sources_to_notebook([['unmodified'], ['local']], cell_type='markdown', strip_ids=True) 1117 remote = sources_to_notebook([['unmodified'], ['remote']], cell_type='markdown', strip_ids=True) 1118 expected = sources_to_notebook([ 1119 ['unmodified'], 1120 [ 1121 ("<"*7) + ' local\n', 1122 'local\n', 1123 ("="*7) + '\n', 1124 'remote\n', 1125 (">"*7) + ' remote' 1126 ] 1127 ], cell_type='markdown', strip_ids=True) 1128 merged, decisions = merge_notebooks(base, local, remote) 1129 assert merged == expected 1130 1131 1132def test_inline_merge_cells_replacement_unsimilar(): 1133 base = sources_to_notebook([['unmodified'], ['base']], cell_type='markdown', strip_ids=False) 1134 local = sources_to_notebook([['unmodified'], ['local\n', 'friendly faces\n', '3.14']], cell_type='markdown', strip_ids=True) 1135 remote = sources_to_notebook([['unmodified'], ['remote\n', 'foo bar baz\n']], cell_type='markdown', strip_ids=True) 1136 expected = sources_to_notebook([ 1137 ['unmodified'], 1138 [_cell_marker_format(("<"*7) + ' local')], 1139 ['local\n', 'friendly faces\n', '3.14'], 1140 [_cell_marker_format("="*7)], 1141 ['remote\n', 'foo bar baz\n'], 1142 [_cell_marker_format((">"*7) + ' remote')], 1143 ], cell_type='markdown', strip_ids=True) 1144 merged, decisions = merge_notebooks(base, local, remote) 1145 assert strip_cell_ids(merged) == expected 1146