├── .coveragerc ├── .github └── workflows │ ├── pypi-release.yml │ └── tests.yml ├── .gitignore ├── .lgtm ├── AUTHORS ├── CHANGES ├── CONTRIBUTING.rst ├── LICENSE ├── MAINTAINERS ├── MANIFEST.in ├── README.rst ├── dictdiffer ├── __init__.py ├── conflict.py ├── merge.py ├── resolve.py ├── testing.py ├── unify.py ├── utils.py └── version.py ├── docs ├── Makefile ├── conf.py ├── index.rst └── requirements.txt ├── pytest.ini ├── run-tests.sh ├── setup.cfg ├── setup.py ├── tests ├── test_conflict.py ├── test_dictdiffer.py ├── test_merge.py ├── test_resolve.py ├── test_testing.py ├── test_unify.py └── test_utils.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | # This file is part of Dictdiffer. 2 | # 3 | # Copyright (C) 2014 CERN. 4 | # 5 | # Dictdiffer is free software; you can redistribute it and/or modify 6 | # it under the terms of the MIT License; see LICENSE file for more 7 | # details. 8 | 9 | [run] 10 | source = dictdiffer 11 | -------------------------------------------------------------------------------- /.github/workflows/pypi-release.yml: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Invenio. 4 | # Copyright (C) 2020 CERN. 5 | # 6 | # Invenio is free software; you can redistribute it and/or modify it 7 | # under the terms of the MIT License; see LICENSE file for more details 8 | 9 | name: Publish 10 | 11 | on: 12 | push: 13 | tags: 14 | - v* 15 | 16 | jobs: 17 | Publish: 18 | runs-on: ubuntu-20.04 19 | 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v2 23 | 24 | - name: Set up Python 25 | uses: actions/setup-python@v2 26 | with: 27 | python-version: 3.8 28 | 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install setuptools wheel 33 | 34 | - name: Build package 35 | run: python setup.py sdist bdist_wheel 36 | 37 | - name: Publish on PyPI 38 | uses: pypa/gh-action-pypi-publish@v1.3.1 39 | with: 40 | user: __token__ 41 | # The token is provided by the inveniosoftware organization 42 | password: ${{ secrets.pypi_token }} 43 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Invenio. 4 | # Copyright (C) 2020 CERN. 5 | # 6 | # Invenio is free software; you can redistribute it and/or modify it 7 | # under the terms of the MIT License; see LICENSE file for more details. 8 | 9 | name: CI 10 | 11 | on: 12 | push: 13 | branches: master 14 | pull_request: 15 | branches: master 16 | schedule: 17 | # * is a special character in YAML so you have to quote this string 18 | - cron: '0 3 * * 6' 19 | workflow_dispatch: 20 | inputs: 21 | reason: 22 | description: 'Reason' 23 | required: false 24 | default: 'Manual trigger' 25 | 26 | jobs: 27 | Tests: 28 | runs-on: ubuntu-20.04 29 | strategy: 30 | matrix: 31 | python-version: [3.5, 3.6, 3.7, 3.8, 3.9] 32 | requirements-level: [min, pypi] 33 | 34 | steps: 35 | - name: Checkout 36 | uses: actions/checkout@v2 37 | 38 | - name: Set up Python ${{ matrix.python-version }} 39 | uses: actions/setup-python@v2 40 | with: 41 | python-version: ${{ matrix.python-version }} 42 | 43 | - name: Generate dependencies 44 | run: | 45 | sudo apt-get install libxml2-dev libxslt1-dev 46 | python -m pip install --upgrade pip setuptools py wheel requirements-builder 47 | requirements-builder -e all --level=${{ matrix.requirements-level }} setup.py > .${{ matrix.requirements-level }}-${{ matrix.python-version }}-requirements.txt 48 | 49 | - name: Cache pip 50 | uses: actions/cache@v2 51 | with: 52 | path: ~/.cache/pip 53 | key: ${{ runner.os }}-pip-${{ hashFiles('.${{ matrix.requirements-level }}-${{ matrix.python-version }}-requirements.txt') }} 54 | 55 | - name: Install dependencies 56 | run: | 57 | pip install -r .${{ matrix.requirements-level }}-${{ matrix.python-version }}-requirements.txt 58 | pip install .[all] 59 | pip freeze 60 | 61 | - name: Run tests 62 | run: | 63 | ./run-tests.sh 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This file is part of Dictdiffer. 2 | # 3 | # Copyright (C) 2013 Fatih Erikli. 4 | # Copyright (C) 2014, 2016 CERN. 5 | # 6 | # Dictdiffer is free software; you can redistribute it and/or modify 7 | # it under the terms of the MIT License; see LICENSE file for more 8 | # details. 9 | 10 | .eggs/ 11 | *.egg 12 | *.egg-info 13 | *.pyc 14 | .DS_Store 15 | .cache 16 | .coverage 17 | .tox 18 | build/ 19 | dist/ 20 | docs/_build 21 | docs/db 22 | docs/static 23 | htmlcov 24 | -------------------------------------------------------------------------------- /.lgtm: -------------------------------------------------------------------------------- 1 | approvals = 1 2 | pattern = "(?i)LGTM" 3 | self_approval_off = true 4 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Authors 2 | ======= 3 | 4 | Dictdiffer was originally developed by Fatih Erikli. It is now being 5 | developed and maintained by the Invenio collaboration. You can 6 | contact us at `info@inveniosoftware.org `_. 7 | 8 | Contributors: 9 | 10 | * Fatih Erikli 11 | * Brian Rue 12 | * Lars Holm Nielsen 13 | * Tibor Simko 14 | * Jiri Kuncar 15 | * Jason Peddle 16 | * Martin Vesper 17 | * Gilles DAVID 18 | * Alexander Mohr 19 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | Changes 2 | ======= 3 | 4 | Version 0.9.0 (released 2021-07-22) 5 | 6 | - Adds absolute tolerance feature for floats (@adrien-berchet) (#152) 7 | - Drops support of Python<3.5 (@adrien-berchet) (#160) 8 | - Adds `assert_no_diff` helper to assist pytest users (@joesolly) (#153) 9 | - Migrates CI to gh-actions (@ParthS007 @diegodelemos) (#145) 10 | - Removes dependency on pkg_resources (@eldruin) 11 | 12 | Version 0.8.1 (released 2019-12-13) 13 | 14 | - Fix invalid diff output for sets. (@jirikuncar @danielduhh) (#133 #134) 15 | 16 | Version 0.8.0 (released 2019-03-17) 17 | 18 | - Respect `dot_notation` flag in ignore argument (@yoyonel) (#107) 19 | - Adds argument for toggling dot notation in diff. (@robinchew) 20 | 21 | Version 0.7.2 (released 2019-02-22) 22 | 23 | - Two NaN values are considered the same, hence they are not shown in `diff` 24 | output. (#114) (@t-b) 25 | - Refactors `diff` method to reduce recursive call stack size. (#112) 26 | (@yoyonel) 27 | - Python porting best practice use feature detection instead 28 | of version detection to save an import and pass both PyLint 29 | and Flake8 tests with neither 'pragma' nor 'noqa'. (@cclauss) 30 | 31 | Version 0.7.1 (released 2018-05-04) 32 | 33 | - Resolves issue with keys containing dots. (#101) 34 | 35 | Version 0.7.0 (released 2017-10-16) 36 | 37 | - Fixes problem with diff results that reference the original structure by 38 | introduction of `deepcopy` for all possibly unhashable items. Thus the diff 39 | does not change later when the diffed structures change. 40 | - Adds new option for patching and reverting patches in-place. 41 | - Adds Python 3.6 to test matrix. 42 | - Fixes the `ignore` argument when it contains a unicode value. 43 | 44 | Version 0.6.1 (released 2016-11-22) 45 | 46 | - Changes order of items for REMOVE section of generated patches when 47 | `swap` is called so the list items are removed from the end. (#85) 48 | - Improves API documentation for `ignore` argument in `diff` function. 49 | (#79) 50 | - Executes doctests during PyTest invocation. 51 | 52 | Version 0.6.0 (released 2016-06-22) 53 | 54 | - Adds support for comparing NumPy arrays. (#68) 55 | - Adds support for comparing mutable mappings, sequences and sets from 56 | `collections.abs` module. (#67) 57 | - Updates package structure, sorts imports and runs doctests. 58 | - Fixes order in which handled conflicts are unified so that the 59 | Merger's unified_patches can be always applied. 60 | 61 | Version 0.5.0 (released 2016-01-04) 62 | 63 | - Adds tolerance parameter used when user wants to treat closed values 64 | as equals 65 | - Adds support for comparing numerical values and NaN. (#54) (#55) 66 | 67 | Version 0.4.0 (released 2015-03-11) 68 | 69 | - Adds support for diffing and patching of sets. (#44) 70 | - New tests for diff on the same lists. (#48) 71 | - Fix for exception when dict has unicode keys and ignore parameter is 72 | provided. (#50) 73 | - PEP8 improvements. 74 | 75 | Version 0.3.0 (released 2014-11-05) 76 | 77 | - Adds ignore argument to `diff` function that allows skipping check 78 | on specified keys. (#34 #35) 79 | - Fix for diffing of dict or list subclasses. (#37) 80 | - Better instance checking of diffing objects. (#39) 81 | 82 | Version 0.2.0 (released 2014-09-29) 83 | 84 | - Fix for empty list instructions. (#30) 85 | - Regression test for empty list instructions. 86 | 87 | Version 0.1.0 (released 2014-09-01) 88 | 89 | - Fix for list removal issues during patching caused by wrong 90 | iteration. (#10) 91 | - Fix for issues with multiple value types for the same key. (#10) 92 | - Fix for issues with strings handled as iterables. (#6) 93 | - Fix for integer keys. (#12) 94 | - Regression test for complex dictionaries. (#4) 95 | - Better testing with Github actions, tox, pytest, code coverage. (#10) 96 | - Initial release of documentation on ReadTheDocs. (#21 #24) 97 | - Support for Python 3. (#15) 98 | 99 | Version 0.0.4 (released 2014-01-04) 100 | 101 | - List diff behavior treats lists as lists instead of sets. (#3) 102 | - Differed typed objects are flagged as `changed` now. 103 | - Swap function refactored. 104 | 105 | Version 0.0.3 (released 2013-05-26) 106 | 107 | - Initial public release on PyPI. 108 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Bug reports, feature requests, and other contributions are welcome. 5 | If you find a demonstrable problem that is caused by the code of this 6 | library, please: 7 | 8 | 1. Search for `already reported problems 9 | `_. 10 | 2. Check if the issue has been fixed or is still reproducible on the 11 | latest `master` branch. 12 | 3. Create an issue with **a test case**. 13 | 14 | If you create a feature branch, you can run the tests to ensure everything is 15 | operating correctly: 16 | 17 | .. code-block:: console 18 | 19 | $ ./run-tests.sh 20 | 21 | ... 22 | 23 | Name Stmts Miss Cover Missing 24 | --------------------------------------------------- 25 | dictdiffer/__init__ 88 0 100% 26 | dictdiffer/version 2 0 100% 27 | --------------------------------------------------- 28 | TOTAL 90 0 100% 29 | 30 | ... 31 | 32 | 52 passed, 2 skipped in 0.44 seconds 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Dictdiffer is free software; you can redistribute it and/or modify it 2 | under the terms of the MIT License quoted below. 3 | 4 | Copyright (C) 2013 Fatih Erikli. 5 | Copyright (C) 2013, 2014 CERN. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining 8 | a copy of this software and associated documentation files (the 9 | "Software"), to deal in the Software without restriction, including 10 | without limitation the rights to use, copy, modify, merge, publish, 11 | distribute, sublicense, and/or sell copies of the Software, and to 12 | permit persons to whom the Software is furnished to do so, subject to 13 | the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 20 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 22 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 24 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 25 | 26 | In applying this license, CERN does not waive the privileges and 27 | immunities granted to it by virtue of its status as an 28 | Intergovernmental Organization or submit itself to any jurisdiction. 29 | -------------------------------------------------------------------------------- /MAINTAINERS: -------------------------------------------------------------------------------- 1 | Jiri Kuncar (@jirikuncar) 2 | Tibor Simko (@tiborsimko) 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # This file is part of Dictdiffer. 2 | # 3 | # Copyright (C) 2014, 2016 CERN. 4 | # 5 | # Dictdiffer is free software; you can redistribute it and/or modify 6 | # it under the terms of the MIT License; see LICENSE file for more 7 | # details. 8 | 9 | include README.rst CHANGES CONTRIBUTING.rst AUTHORS LICENSE MANIFEST.in 10 | include RELEASE-NOTES.rst 11 | include .coveragerc pytest.ini 12 | include .lgtm MAINTAINERS 13 | include docs/*.rst docs/*.py docs/Makefile docs/requirements.txt 14 | include *.py *.sh 15 | include tox.ini tests/*.py 16 | recursive-include .github/workflows *.yml 17 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Dictdiffer 3 | ============ 4 | 5 | .. image:: https://github.com/inveniosoftware/dictdiffer/workflows/CI/badge.svg 6 | :target: https://github.com/inveniosoftware/dictdiffer/actions 7 | 8 | .. image:: https://img.shields.io/coveralls/inveniosoftware/dictdiffer.svg 9 | :target: https://coveralls.io/r/inveniosoftware/dictdiffer 10 | 11 | .. image:: https://img.shields.io/github/tag/inveniosoftware/dictdiffer.svg 12 | :target: https://github.com/inveniosoftware/dictdiffer/releases 13 | 14 | .. image:: https://img.shields.io/pypi/dm/dictdiffer.svg 15 | :target: https://pypi.python.org/pypi/dictdiffer 16 | 17 | .. image:: https://img.shields.io/github/license/inveniosoftware/dictdiffer.svg 18 | :target: https://github.com/inveniosoftware/dictdiffer/blob/master/LICENSE 19 | 20 | About 21 | ===== 22 | 23 | Dictdiffer is a helper module that helps you to diff and patch 24 | dictionaries. 25 | 26 | 27 | Installation 28 | ============ 29 | 30 | Dictdiffer is on PyPI so all you need is: :: 31 | 32 | pip install dictdiffer 33 | 34 | 35 | Documentation 36 | ============= 37 | 38 | Documentation is readable at https://dictdiffer.readthedocs.io or can be 39 | built using Sphinx: :: 40 | 41 | pip install dictdiffer[docs] 42 | python setup.py build_sphinx 43 | 44 | 45 | Testing 46 | ======= 47 | 48 | Running the test suite is as simple as: :: 49 | 50 | ./run-tests.sh 51 | -------------------------------------------------------------------------------- /dictdiffer/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of Dictdiffer. 2 | # 3 | # Copyright (C) 2013 Fatih Erikli. 4 | # Copyright (C) 2013, 2014, 2015, 2016 CERN. 5 | # Copyright (C) 2017-2019 ETH Zurich, Swiss Data Science Center, Jiri Kuncar. 6 | # 7 | # Dictdiffer is free software; you can redistribute it and/or modify 8 | # it under the terms of the MIT License; see LICENSE file for more 9 | # details. 10 | 11 | """Dictdiffer is a helper module to diff and patch dictionaries.""" 12 | 13 | from collections.abc import (Iterable, MutableMapping, MutableSequence, 14 | MutableSet) 15 | from copy import deepcopy 16 | 17 | from .utils import EPSILON, PathLimit, are_different, dot_lookup 18 | from .version import __version__ 19 | 20 | (ADD, REMOVE, CHANGE) = ( 21 | 'add', 'remove', 'change') 22 | 23 | __all__ = ('diff', 'patch', 'swap', 'revert', 'dot_lookup', '__version__') 24 | 25 | DICT_TYPES = (MutableMapping, ) 26 | LIST_TYPES = (MutableSequence, ) 27 | SET_TYPES = (MutableSet, ) 28 | 29 | try: 30 | import numpy 31 | HAS_NUMPY = True 32 | LIST_TYPES += (numpy.ndarray, ) 33 | except ImportError: # pragma: no cover 34 | HAS_NUMPY = False 35 | 36 | 37 | def diff(first, second, node=None, ignore=None, path_limit=None, expand=False, 38 | tolerance=EPSILON, absolute_tolerance=None, dot_notation=True): 39 | """Compare two dictionary/list/set objects, and returns a diff result. 40 | 41 | Return an iterator with differences between two objects. The diff items 42 | represent addition/deletion/change and the item value is a *deep copy* 43 | from the corresponding source or destination objects. 44 | 45 | >>> from dictdiffer import diff 46 | >>> result = diff({'a': 'b'}, {'a': 'c'}) 47 | >>> list(result) 48 | [('change', 'a', ('b', 'c'))] 49 | 50 | The keys can be skipped from difference calculation when they are included 51 | in ``ignore`` argument of type :class:`collections.Container`. 52 | 53 | >>> list(diff({'a': 1, 'b': 2}, {'a': 3, 'b': 4}, ignore=set(['a']))) 54 | [('change', 'b', (2, 4))] 55 | >>> class IgnoreCase(set): 56 | ... def __contains__(self, key): 57 | ... return set.__contains__(self, str(key).lower()) 58 | >>> list(diff({'a': 1, 'b': 2}, {'A': 3, 'b': 4}, ignore=IgnoreCase('a'))) 59 | [('change', 'b', (2, 4))] 60 | 61 | The difference calculation can be limitted to certain path: 62 | 63 | >>> list(diff({}, {'a': {'b': 'c'}})) 64 | [('add', '', [('a', {'b': 'c'})])] 65 | 66 | >>> from dictdiffer.utils import PathLimit 67 | >>> list(diff({}, {'a': {'b': 'c'}}, path_limit=PathLimit())) 68 | [('add', '', [('a', {})]), ('add', 'a', [('b', 'c')])] 69 | 70 | >>> from dictdiffer.utils import PathLimit 71 | >>> list(diff({}, {'a': {'b': 'c'}}, path_limit=PathLimit([('a',)]))) 72 | [('add', '', [('a', {'b': 'c'})])] 73 | 74 | >>> from dictdiffer.utils import PathLimit 75 | >>> list(diff({}, {'a': {'b': 'c'}}, 76 | ... path_limit=PathLimit([('a', 'b')]))) 77 | [('add', '', [('a', {})]), ('add', 'a', [('b', 'c')])] 78 | 79 | >>> from dictdiffer.utils import PathLimit 80 | >>> list(diff({'a': {'b': 'c'}}, {'a': {'b': 'c'}}, path_limit=PathLimit([('a',)]))) 81 | [] 82 | 83 | The patch can be expanded to small units e.g. when adding multiple values: 84 | 85 | >>> list(diff({'fruits': []}, {'fruits': ['apple', 'mango']})) 86 | [('add', 'fruits', [(0, 'apple'), (1, 'mango')])] 87 | 88 | >>> list(diff({'fruits': []}, {'fruits': ['apple', 'mango']}, expand=True)) 89 | [('add', 'fruits', [(0, 'apple')]), ('add', 'fruits', [(1, 'mango')])] 90 | 91 | >>> list(diff({'a': {'x': 1}}, {'a': {'x': 2}})) 92 | [('change', 'a.x', (1, 2))] 93 | 94 | >>> list(diff({'a': {'x': 1}}, {'a': {'x': 2}}, 95 | ... dot_notation=False)) 96 | [('change', ['a', 'x'], (1, 2))] 97 | 98 | :param first: The original dictionary, ``list`` or ``set``. 99 | :param second: New dictionary, ``list`` or ``set``. 100 | :param node: Key for comparison that can be used in :func:`dot_lookup`. 101 | :param ignore: Set of keys that should not be checked. 102 | :param path_limit: List of path limit tuples or dictdiffer.utils.Pathlimit 103 | object to limit the diff recursion depth. 104 | A diff is still performed beyond the path_limit, 105 | but individual differences will be aggregated up to the path_limit. 106 | :param expand: Expand the patches. 107 | :param tolerance: Threshold to consider when comparing two float numbers. 108 | :param absolute_tolerance: Absolute threshold to consider when comparing 109 | two float numbers. 110 | :param dot_notation: Boolean to toggle dot notation on and off. 111 | 112 | .. versionchanged:: 0.3 113 | Added *ignore* parameter. 114 | 115 | .. versionchanged:: 0.4 116 | Arguments ``first`` and ``second`` can now contain a ``set``. 117 | 118 | .. versionchanged:: 0.5 119 | Added *path_limit* parameter. 120 | Added *expand* paramter. 121 | Added *tolerance* parameter. 122 | 123 | .. versionchanged:: 0.7 124 | Diff items are deep copies from its corresponding objects. 125 | Argument *ignore* is always converted to a ``set``. 126 | 127 | .. versionchanged:: 0.8 128 | Added *dot_notation* parameter. 129 | """ 130 | if path_limit is not None and not isinstance(path_limit, PathLimit): 131 | path_limit = PathLimit(path_limit) 132 | 133 | if isinstance(ignore, Iterable): 134 | def _process_ignore_value(value): 135 | if isinstance(value, int): 136 | return value, 137 | elif isinstance(value, list): 138 | return tuple(value) 139 | elif not dot_notation and isinstance(value, str): 140 | return value, 141 | return value 142 | 143 | ignore = type(ignore)(_process_ignore_value(value) for value in ignore) 144 | 145 | def dotted(node, default_type=list): 146 | """Return dotted notation.""" 147 | if dot_notation and \ 148 | all(map(lambda x: isinstance(x, str) and '.' not in x, 149 | node)): 150 | return '.'.join(node) 151 | else: 152 | return default_type(node) 153 | 154 | def _diff_recursive(_first, _second, _node=None): 155 | _node = _node or [] 156 | 157 | dotted_node = dotted(_node) 158 | 159 | differ = False 160 | 161 | if isinstance(_first, DICT_TYPES) and isinstance(_second, DICT_TYPES): 162 | # dictionaries are not hashable, we can't use sets 163 | def check(key): 164 | """Test if key in current node should be ignored.""" 165 | return ignore is None or ( 166 | dotted(_node + [key], default_type=tuple) not in ignore and 167 | tuple(_node + [key]) not in ignore 168 | ) 169 | 170 | intersection = [k for k in _first if k in _second and check(k)] 171 | addition = [k for k in _second if k not in _first and check(k)] 172 | deletion = [k for k in _first if k not in _second and check(k)] 173 | 174 | differ = True 175 | 176 | elif isinstance(_first, LIST_TYPES) and isinstance(_second, 177 | LIST_TYPES): 178 | len_first = len(_first) 179 | len_second = len(_second) 180 | 181 | intersection = list(range(0, min(len_first, len_second))) 182 | addition = list(range(min(len_first, len_second), len_second)) 183 | deletion = list( 184 | reversed(range(min(len_first, len_second), len_first))) 185 | 186 | differ = True 187 | 188 | elif isinstance(_first, SET_TYPES) and isinstance(_second, SET_TYPES): 189 | # Deep copy is not necessary for hashable items. 190 | addition = _second - _first 191 | if len(addition): 192 | yield ADD, dotted_node, [(0, addition)] 193 | deletion = _first - _second 194 | if len(deletion): 195 | yield REMOVE, dotted_node, [(0, deletion)] 196 | 197 | return # stop here for sets 198 | 199 | if differ: 200 | # Compare if object is a dictionary or list. 201 | # 202 | # NOTE variables: intersection, addition, deletion contain only 203 | # hashable types, hence they do not need to be deepcopied. 204 | # 205 | # Call again the parent function as recursive if dictionary have 206 | # child objects. Yields `add` and `remove` flags. 207 | for key in intersection: 208 | # if type is not changed, 209 | # callees again diff function to compare. 210 | # otherwise, the change will be handled as `change` flag. 211 | if path_limit and path_limit.path_is_limit(_node + [key]): 212 | if _first[key] == _second[key]: 213 | return 214 | 215 | yield CHANGE, _node + [key], ( 216 | deepcopy(_first[key]), deepcopy(_second[key]) 217 | ) 218 | else: 219 | recurred = _diff_recursive( 220 | _first[key], _second[key], 221 | _node=_node + [key], 222 | ) 223 | 224 | for diffed in recurred: 225 | yield diffed 226 | 227 | if addition: 228 | if path_limit: 229 | collect = [] 230 | collect_recurred = [] 231 | for key in addition: 232 | if not isinstance(_second[key], 233 | SET_TYPES + LIST_TYPES + DICT_TYPES): 234 | collect.append((key, deepcopy(_second[key]))) 235 | elif path_limit.path_is_limit(_node + [key]): 236 | collect.append((key, deepcopy(_second[key]))) 237 | else: 238 | collect.append((key, _second[key].__class__())) 239 | recurred = _diff_recursive( 240 | _second[key].__class__(), 241 | _second[key], 242 | _node=_node + [key], 243 | ) 244 | 245 | collect_recurred.append(recurred) 246 | 247 | if expand: 248 | for key, val in collect: 249 | yield ADD, dotted_node, [(key, val)] 250 | else: 251 | yield ADD, dotted_node, collect 252 | 253 | for recurred in collect_recurred: 254 | for diffed in recurred: 255 | yield diffed 256 | else: 257 | if expand: 258 | for key in addition: 259 | yield ADD, dotted_node, [ 260 | (key, deepcopy(_second[key]))] 261 | else: 262 | yield ADD, dotted_node, [ 263 | # for additions, return a list that consist with 264 | # two-pair tuples. 265 | (key, deepcopy(_second[key])) for key in addition] 266 | 267 | if deletion: 268 | if expand: 269 | for key in deletion: 270 | yield REMOVE, dotted_node, [ 271 | (key, deepcopy(_first[key]))] 272 | else: 273 | yield REMOVE, dotted_node, [ 274 | # for deletions, return the list of removed keys 275 | # and values. 276 | (key, deepcopy(_first[key])) for key in deletion] 277 | 278 | else: 279 | # Compare string and numerical types and yield `change` flag. 280 | if are_different(_first, _second, tolerance, absolute_tolerance): 281 | yield CHANGE, dotted_node, (deepcopy(_first), 282 | deepcopy(_second)) 283 | 284 | return _diff_recursive(first, second, node) 285 | 286 | 287 | def patch(diff_result, destination, in_place=False): 288 | """Patch the diff result to the destination dictionary. 289 | 290 | :param diff_result: Changes returned by ``diff``. 291 | :param destination: Structure to apply the changes to. 292 | :param in_place: By default, destination dictionary is deep copied 293 | before applying the patch, and the copy is returned. 294 | Setting ``in_place=True`` means that patch will apply 295 | the changes directly to and return the destination 296 | structure. 297 | """ 298 | if not in_place: 299 | destination = deepcopy(destination) 300 | 301 | def add(node, changes): 302 | for key, value in changes: 303 | dest = dot_lookup(destination, node) 304 | if isinstance(dest, LIST_TYPES): 305 | dest.insert(key, value) 306 | elif isinstance(dest, SET_TYPES): 307 | dest |= value 308 | else: 309 | dest[key] = value 310 | 311 | def change(node, changes): 312 | dest = dot_lookup(destination, node, parent=True) 313 | if isinstance(node, str): 314 | last_node = node.split('.')[-1] 315 | else: 316 | last_node = node[-1] 317 | if isinstance(dest, LIST_TYPES): 318 | last_node = int(last_node) 319 | _, value = changes 320 | dest[last_node] = value 321 | 322 | def remove(node, changes): 323 | for key, value in changes: 324 | dest = dot_lookup(destination, node) 325 | if isinstance(dest, SET_TYPES): 326 | dest -= value 327 | else: 328 | del dest[key] 329 | 330 | patchers = { 331 | REMOVE: remove, 332 | ADD: add, 333 | CHANGE: change 334 | } 335 | 336 | for action, node, changes in diff_result: 337 | patchers[action](node, changes) 338 | 339 | return destination 340 | 341 | 342 | def swap(diff_result): 343 | """Swap the diff result. 344 | 345 | It uses following mapping: 346 | 347 | - remove -> add 348 | - add -> remove 349 | 350 | In addition, swap the changed values for `change` flag. 351 | 352 | >>> from dictdiffer import swap 353 | >>> swapped = swap([('add', 'a.b.c', [('a', 'b'), ('c', 'd')])]) 354 | >>> next(swapped) 355 | ('remove', 'a.b.c', [('c', 'd'), ('a', 'b')]) 356 | 357 | >>> swapped = swap([('change', 'a.b.c', ('a', 'b'))]) 358 | >>> next(swapped) 359 | ('change', 'a.b.c', ('b', 'a')) 360 | 361 | """ 362 | def add(node, changes): 363 | return REMOVE, node, list(reversed(changes)) 364 | 365 | def remove(node, changes): 366 | return ADD, node, changes 367 | 368 | def change(node, changes): 369 | first, second = changes 370 | return CHANGE, node, (second, first) 371 | 372 | swappers = { 373 | REMOVE: remove, 374 | ADD: add, 375 | CHANGE: change 376 | } 377 | 378 | for action, node, change in diff_result: 379 | yield swappers[action](node, change) 380 | 381 | 382 | def revert(diff_result, destination, in_place=False): 383 | """Call swap function to revert patched dictionary object. 384 | 385 | Usage example: 386 | 387 | >>> from dictdiffer import diff, revert 388 | >>> first = {'a': 'b'} 389 | >>> second = {'a': 'c'} 390 | >>> revert(diff(first, second), second) 391 | {'a': 'b'} 392 | 393 | :param diff_result: Changes returned by ``diff``. 394 | :param destination: Structure to apply the changes to. 395 | :param in_place: By default, destination dictionary is deep 396 | copied before being reverted, and the copy 397 | is returned. Setting ``in_place=True`` means 398 | that revert will apply the changes directly to 399 | and return the destination structure. 400 | """ 401 | return patch(swap(diff_result), destination, in_place) 402 | -------------------------------------------------------------------------------- /dictdiffer/conflict.py: -------------------------------------------------------------------------------- 1 | # This file is part of Dictdiffer. 2 | # 3 | # Copyright (C) 2015 CERN. 4 | # 5 | # Dictdiffer is free software; you can redistribute it and/or modify 6 | # it under the terms of the MIT License; see LICENSE file for more 7 | # details. 8 | 9 | """Sub module to recognize conflicts in dictdiffer patches.""" 10 | 11 | import itertools 12 | 13 | from .utils import get_path, is_super_path 14 | 15 | 16 | class Conflict(object): 17 | """Wrapper class to store and handle two conflicting patches.""" 18 | 19 | def __init__(self, patch1, patch2): 20 | """Initialize Conflict object. 21 | 22 | :param patch1: First patch tuple 23 | :param patch2: Second patch tuple 24 | """ 25 | self.first_patch = patch1 26 | self.second_patch = patch2 27 | self.take = None 28 | 29 | def take_patch(self): 30 | """Return the patch determined by the *take* attribute.""" 31 | if self.take: 32 | return self.first_patch if self.take == 'f' else self.second_patch 33 | raise Exception('Take attribute not set.') 34 | 35 | def __repr__(self): 36 | """Return string representation.""" 37 | return 'Conflict({0}, {1})'.format(self.first_patch, self.second_patch) 38 | 39 | 40 | class ConflictFinder(object): 41 | """Responsible for finding conflicting patches.""" 42 | 43 | def _is_conflict(self, patch1, patch2): 44 | """Decide on a conflict between two patches. 45 | 46 | The conditions are: 47 | 1. The paths are identical 48 | 2. On of the paths is the super path of the other 49 | 50 | :param patch1: First patch tuple 51 | :param patch2: First patch tuple 52 | """ 53 | path1 = get_path(patch1) 54 | path2 = get_path(patch2) 55 | 56 | if path1 == path2: 57 | return True 58 | elif is_super_path(path1, path2) and patch1[0] == 'remove': 59 | return True 60 | elif is_super_path(path2, path1) and patch2[0] == 'remove': 61 | return True 62 | 63 | return False 64 | 65 | def find_conflicts(self, first_patches, second_patches): 66 | """Find all conflicts between two lists of patches. 67 | 68 | Iterates over the lists of patches, comparing each patch from list 69 | one to each patch from list two. 70 | 71 | :param first_patches: List of patch tuples 72 | :param second_patches: List of patch tuples 73 | """ 74 | self.conflicts = [Conflict(patch1, patch2) for patch1, patch2 75 | in itertools.product(first_patches, 76 | second_patches) 77 | if self._is_conflict(patch1, patch2)] 78 | 79 | return self.conflicts 80 | -------------------------------------------------------------------------------- /dictdiffer/merge.py: -------------------------------------------------------------------------------- 1 | # This file is part of Dictdiffer. 2 | # 3 | # Copyright (C) 2015 CERN. 4 | # Copyright (C) 2017 ETH Zurich, Swiss Data Science Center, Jiri Kuncar. 5 | # 6 | # Dictdiffer is free software; you can redistribute it and/or modify 7 | # it under the terms of the MIT License; see LICENSE file for more 8 | # details. 9 | 10 | """Sub module to handle the merging of dictdiffer patches.""" 11 | 12 | from . import diff 13 | from .conflict import ConflictFinder 14 | from .resolve import Resolver, UnresolvedConflictsException 15 | from .unify import Unifier 16 | from .utils import PathLimit 17 | 18 | 19 | class Merger(object): 20 | """Class wrapping steps of the automated merging process. 21 | 22 | Usage: 23 | >>> lca = {} 24 | >>> first = {'foo': 'bar'} 25 | >>> second = {'bar': 'foo'} 26 | >>> path_limits = [] 27 | >>> actions = {} 28 | >>> additional_info = {} 29 | >>> m = Merger(lca, first, second, actions, 30 | ... path_limits, additional_info) 31 | >>> try: 32 | ... m.run() 33 | ... except UnresolvedConflictsException: 34 | ... # fix the conflicts 35 | ... m.continue_run() 36 | """ 37 | 38 | def __init__(self, 39 | lca, first, second, actions, 40 | path_limits=[], additional_info=None, ignore=None): 41 | """Initialize the Merger object. 42 | 43 | :param lca: latest common ancestor of the two diverging data structures 44 | :param first: first data structure 45 | :param second: second data structure 46 | :param path_limits: list of paths, utilized to instantiate a 47 | dictdiffer.utils.PathLimit object 48 | :param additional_info: Any object containing additional information 49 | used by the resolution functions 50 | :param ignore: Set of keys that should not be merged 51 | """ 52 | self.lca = lca 53 | self.first = first 54 | self.second = second 55 | self.path_limit = PathLimit(path_limits) 56 | self.ignore = ignore 57 | 58 | self.actions = actions 59 | self.additional_info = additional_info 60 | 61 | self.conflict_finder = ConflictFinder() 62 | 63 | self.resolver = Resolver(self.actions, 64 | self.additional_info) 65 | 66 | self.unifier = Unifier() 67 | 68 | self.conflicts = [] 69 | self.unresolved_conflicts = [] 70 | 71 | def run(self): 72 | """Run the automated merging process. 73 | 74 | Runs every step necessary for the automated merging process, raising 75 | an UnresolvedConflictsException in case that the provided resolution 76 | actions can not solve a given conflict. 77 | 78 | After every performed step, the results are stored inside attributes of 79 | the merger object. 80 | """ 81 | self.extract_patches() 82 | self.find_conflicts() 83 | self.resolve_conflicts() 84 | 85 | if self.unresolved_conflicts: 86 | raise UnresolvedConflictsException(self.unresolved_conflicts) 87 | 88 | self.unify_patches() 89 | 90 | def continue_run(self, picks): 91 | """Continue the merge after an UnresolvedConflictsException. 92 | 93 | :param picks: a list of 'f' or 's' strings, which utilize the Conflicts 94 | class *take* attribute 95 | """ 96 | self.resolver.manual_resolve_conflicts(picks) 97 | self.unresolved_conflicts = [] 98 | self.unify_patches() 99 | 100 | def extract_patches(self): 101 | """Extract the patches. 102 | 103 | Extracts the differences between the *lca* and the *first* and 104 | *second* data structure and stores them in the attributes 105 | *first_patches* and *second_patches*. 106 | """ 107 | self.first_patches = list(diff(self.lca, self.first, 108 | path_limit=self.path_limit, 109 | ignore=self.ignore, 110 | expand=True)) 111 | self.second_patches = list(diff(self.lca, self.second, 112 | path_limit=self.path_limit, 113 | ignore=self.ignore, 114 | expand=True)) 115 | 116 | def find_conflicts(self): 117 | """Find conflicts between the tow lists of patches. 118 | 119 | Finds the conflicts between the two difference lists and stores 120 | them in the *conflicts* attribute. 121 | """ 122 | self.conflicts = (self 123 | .conflict_finder 124 | .find_conflicts(self.first_patches, 125 | self.second_patches)) 126 | 127 | def resolve_conflicts(self): 128 | """Resolve the conflicts. 129 | 130 | Runs the automated conflict resolution process. 131 | Occurring unresolvable conflicts are stored in *unresolved_conflicts*. 132 | """ 133 | try: 134 | self.resolver.resolve_conflicts(self.first_patches, 135 | self.second_patches, 136 | self.conflicts) 137 | except UnresolvedConflictsException as e: 138 | self.unresolved_conflicts = e.content 139 | 140 | def unify_patches(self): 141 | """Unify the patches after the conflict resolution. 142 | 143 | Unifies the patches after a successful merge and stores them in 144 | *unified_patches*. 145 | """ 146 | self.unified_patches = self.unifier.unify(self.first_patches, 147 | self.second_patches, 148 | self.conflicts) 149 | -------------------------------------------------------------------------------- /dictdiffer/resolve.py: -------------------------------------------------------------------------------- 1 | # This file is part of Dictdiffer. 2 | # 3 | # Copyright (C) 2015 CERN. 4 | # Copyright (C) 2017 ETH Zurich, Swiss Data Science Center, Jiri Kuncar. 5 | # 6 | # Dictdiffer is free software; you can redistribute it and/or modify 7 | # it under the terms of the MIT License; see LICENSE file for more 8 | # details. 9 | 10 | """Sub module to handle the conflict resolution.""" 11 | 12 | from .utils import get_path 13 | 14 | 15 | class UnresolvedConflictsException(Exception): 16 | """Exception raised in case of an unresolveable conflict. 17 | 18 | Exception raised in case of conflicts, that can not be resolved using 19 | the provided actions in the automated merging process. 20 | """ 21 | 22 | def __init__(self, unresolved_conflicts): 23 | """Initialize the UnresolvedConflictsException. 24 | 25 | :param unresolved_conflicts: list of unresolved conflicts. 26 | dictdiffer.conflict.Conflict objects. 27 | """ 28 | self.message = ("The unresolved conflicts are stored in the *content* " 29 | "attribute of this exception or in the " 30 | "*unresolved_conflicts* attribute of the " 31 | "dictdiffer.merge.Merger object.") 32 | self.content = unresolved_conflicts 33 | 34 | def __repr__(self): 35 | """Return the object representation.""" 36 | return self.message 37 | 38 | def __str__(self): 39 | """Return the string representation.""" 40 | return self.message 41 | 42 | 43 | class NoFurtherResolutionException(Exception): 44 | """Exception raised to stop the automated resolution process. 45 | 46 | Raised in case that the automatic conflict resolution process should stop 47 | trying more general keys. 48 | """ 49 | 50 | pass 51 | 52 | 53 | class Resolver(object): 54 | """Class handling the conflict resolution process. 55 | 56 | Presents the given conflicts to actions designed to solve them. 57 | """ 58 | 59 | def __init__(self, actions, additional_info=None): 60 | """Initialize the Resolver. 61 | 62 | :param action: dict object containing the necessary resolution 63 | functions 64 | :param additional_info: any additional information required by the 65 | actions 66 | """ 67 | self.actions = actions 68 | self.additional_info = additional_info 69 | 70 | self.unresolved_conflicts = [] 71 | 72 | def _auto_resolve(self, conflict): 73 | """Try to auto resolve conflicts. 74 | 75 | Method trying to auto resolve conflicts in case that the perform the 76 | same amendment. 77 | """ 78 | if conflict.first_patch == conflict.second_patch: 79 | conflict.take = 'f' 80 | return True 81 | return False 82 | 83 | def _find_conflicting_path(self, conflict): 84 | """Return the shortest path commown to two patches.""" 85 | p1p = get_path(conflict.first_patch) 86 | p2p = get_path(conflict.second_patch) 87 | 88 | # This returns the shortest path 89 | return p1p if len(p1p) <= len(p2p) else p2p 90 | 91 | def _consecutive_slices(self, iterable): 92 | """Build a list of consecutive slices of a given path. 93 | 94 | >>> r = Resolver(None, None) 95 | >>> list(r._consecutive_slices([1, 2, 3])) 96 | [[1, 2, 3], [1, 2], [1]] 97 | """ 98 | return (iterable[:i] for i in reversed(range(1, len(iterable)+1))) 99 | 100 | def resolve_conflicts(self, first_patches, second_patches, conflicts): 101 | """Convert the given conflicts to the actions. 102 | 103 | The method, will map the conflicts to an actions based on the path of 104 | the conflict. In case that the resolution attempt is not successful, it 105 | will strip the last element of the path and try again, until the 106 | resolution is just not possible. 107 | 108 | :param first_patches: list of dictdiffer.diff patches 109 | :param second_patches: list of dictdiffer.diff patches 110 | :param conflicts: list of Conflict objects 111 | """ 112 | for conflict in conflicts: 113 | conflict_path = self._find_conflicting_path(conflict) 114 | 115 | if self._auto_resolve(conflict): 116 | continue 117 | # Let's do some cascading here 118 | for sub_path in self._consecutive_slices(conflict_path): 119 | try: 120 | if self.actions[sub_path](conflict, 121 | first_patches, 122 | second_patches, 123 | self.additional_info): 124 | break 125 | except NoFurtherResolutionException: 126 | self.unresolved_conflicts.append(conflict) 127 | break 128 | except KeyError: 129 | pass 130 | else: 131 | # The conflict could not be resolved 132 | self.unresolved_conflicts.append(conflict) 133 | 134 | if self.unresolved_conflicts: 135 | raise UnresolvedConflictsException(self.unresolved_conflicts) 136 | 137 | def manual_resolve_conflicts(self, picks): 138 | """Resolve manually the conflicts. 139 | 140 | This method resolves conflicts that could not be resolved in an 141 | automatic way. The picks parameter utilized the *take* attribute of the 142 | Conflict objects. 143 | 144 | :param picks: list of 'f' or 's' strings, utilizing the *take* 145 | parameter of each Conflict object 146 | """ 147 | if len(picks) != len(self.unresolved_conflicts): 148 | raise UnresolvedConflictsException(self.unresolved_conflicts) 149 | for pick, conflict in zip(picks, self.unresolved_conflicts): 150 | conflict.take = pick 151 | 152 | self.unresolved_conflicts = [] 153 | -------------------------------------------------------------------------------- /dictdiffer/testing.py: -------------------------------------------------------------------------------- 1 | # This file is part of Dictdiffer. 2 | # 3 | # Copyright (C) 2021 CERN. 4 | # 5 | # Dictdiffer is free software; you can redistribute it and/or modify 6 | # it under the terms of the MIT License; see LICENSE file for more 7 | # details. 8 | """Define helper functions for testing.""" 9 | 10 | from pprint import pformat 11 | 12 | from . import diff 13 | 14 | 15 | def assert_no_diff(*args, **kwargs): 16 | """Compare two dictionary/list/set objects and raise error on difference. 17 | 18 | When there is a difference, this will print a formatted diff. 19 | This is especially useful for pytest. 20 | 21 | Usage example: 22 | 23 | >>> from dictdiffer.testing import assert_no_diff 24 | >>> result = {'a': 'b'} 25 | >>> expected = {'a': 'c'} 26 | >>> assert_no_diff(result, expected) 27 | Traceback (most recent call last): 28 | File "", line 1, in 29 | File "", line 14, in assert_no_diff 30 | AssertionError: [('change', 'a', ('b', 'c'))] 31 | 32 | :param args: Positional arguments to the ``diff`` function. 33 | :param second: Named arguments to the ``diff`` function. 34 | """ 35 | d = [d for d in diff(*args, **kwargs)] 36 | assert not d, pformat(d) 37 | -------------------------------------------------------------------------------- /dictdiffer/unify.py: -------------------------------------------------------------------------------- 1 | # This file is part of Dictdiffer. 2 | # 3 | # Copyright (C) 2015 CERN. 4 | # 5 | # Dictdiffer is free software; you can redistribute it and/or modify 6 | # it under the terms of the MIT License; see LICENSE file for more 7 | # details. 8 | 9 | """Sub module to handle the unification of patches after the merge.""" 10 | 11 | from .utils import get_path, nested_hash 12 | 13 | 14 | class Unifier(object): 15 | """Class handling the unification process after the merge.""" 16 | 17 | def unify(self, first_patches, second_patches, conflicts): 18 | """Unify two lists of patches into one. 19 | 20 | Takes into account their appearance in the given list of conflicts. 21 | 22 | :param first_patches: list of dictdiffer.diff patches 23 | :param second_patches: list of dictdiffer.diff patches 24 | :param conflicts: list of Conflict objects 25 | """ 26 | self.unified_patches = [] 27 | self._build_index(conflicts) 28 | 29 | sorted_patches = sorted(first_patches + second_patches, key=get_path) 30 | 31 | for patch in sorted_patches: 32 | conflict = self._index.get(nested_hash(patch)) 33 | 34 | # Apply only the patches that were taken as part of conflict 35 | # resolution. 36 | if conflict: 37 | if conflict.take_patch() != patch: 38 | continue 39 | 40 | self.unified_patches.append(patch) 41 | 42 | return self.unified_patches 43 | 44 | def _build_index(self, conflicts): 45 | """Create a dictionary attribute mapping patches to conflicts. 46 | 47 | Creates a dictionary attribute mapping the tuplefied version of each 48 | patch to it's containing Conflict object. 49 | """ 50 | self._index = {} 51 | for conflict in conflicts: 52 | self._index[nested_hash(conflict.first_patch)] = conflict 53 | self._index[nested_hash(conflict.second_patch)] = conflict 54 | -------------------------------------------------------------------------------- /dictdiffer/utils.py: -------------------------------------------------------------------------------- 1 | # This file is part of Dictdiffer. 2 | # 3 | # Copyright (C) 2015 CERN. 4 | # Copyright (C) 2017, 2019 ETH Zurich, Swiss Data Science Center, Jiri Kuncar. 5 | # 6 | # Dictdiffer is free software; you can redistribute it and/or modify 7 | # it under the terms of the MIT License; see LICENSE file for more 8 | # details. 9 | 10 | """Utils gathers helper functions, classes for the dictdiffer module.""" 11 | 12 | import math 13 | import sys 14 | from itertools import zip_longest 15 | 16 | num_types = int, float 17 | EPSILON = sys.float_info.epsilon 18 | 19 | 20 | class WildcardDict(dict): 21 | """Provide possibility to use special wildcard keys to access values. 22 | 23 | Those wildcards are: 24 | *: wildcard for everything that follows 25 | +: wildcard for anything on the same path level 26 | The intended use case of this are dictionaries, that utilize tuples as 27 | keys. 28 | 29 | >>> from dictdiffer.utils import WildcardDict 30 | >>> w = WildcardDict({('foo', '*'): '* card', 31 | ... ('banana', '+'): '+ card'}) 32 | >>> w[ ('foo', 'bar', 'baz') ] 33 | '* card' 34 | >>> w[ ('banana', 'apple') ] 35 | '+ card' 36 | """ 37 | 38 | def __init__(self, values=None): 39 | """Set lookup key indices. 40 | 41 | :param values: a dictionary 42 | """ 43 | super(WildcardDict, self).__init__() 44 | self.star_keys = set() 45 | self.plus_keys = set() 46 | 47 | if values is not None: 48 | for key, value in values.items(): 49 | self.__setitem__(key, value) 50 | 51 | def __getitem__(self, key): 52 | """Return the value corresponding to the key, regarding wildcards. 53 | 54 | If the key doesn't exit it tries the '+' wildcard and then the 55 | '*' wildcard. 56 | 57 | >>> w = WildcardDict({('foo', '*'): '* card', 58 | ... ('banana', '+'): '+ card'}) 59 | >>> w[ ('foo', 'bar') ] 60 | '* card' 61 | >>> w[ ('foo', 'bar', 'baz') ] 62 | '* card' 63 | >>> w[ ('banana', 'apple') ] 64 | '+ card' 65 | >>> w[ ('banana', 'apple', 'mango') ] 66 | Traceback (most recent call last): 67 | ... 68 | KeyError 69 | """ 70 | try: 71 | return super(WildcardDict, self).__getitem__(key) 72 | except KeyError: 73 | if key[:-1] in self.plus_keys: 74 | return super(WildcardDict, self).__getitem__(key[:-1]+('+',)) 75 | for _key in [key[:-i] for i in range(1, len(key)+1)]: 76 | if _key in self.star_keys: 77 | return super(WildcardDict, self).__getitem__(_key+('*',)) 78 | raise KeyError 79 | 80 | def __setitem__(self, key, value): 81 | """Set the item for a given key (path).""" 82 | super(WildcardDict, self).__setitem__(key, value) 83 | 84 | if key[-1] == '+': 85 | self.plus_keys.add(key[:-1]) 86 | if key[-1] == '*': 87 | self.star_keys.add(key[:-1]) 88 | 89 | def query_path(self, key): 90 | """Return the key (path) that matches the queried key. 91 | 92 | >>> w = WildcardDict({('foo', '*'): 'banana'}) 93 | >>> w.query_path(('foo', 'bar', 'baz')) 94 | ('foo', '*') 95 | """ 96 | if key in self: 97 | return key 98 | if key[:-1] in self.plus_keys: 99 | return key[:-1]+('+',) 100 | for _key in [key[:-i] for i in range(1, len(key)+1)]: 101 | if _key in self.star_keys: 102 | return _key+('*',) 103 | 104 | raise KeyError 105 | 106 | 107 | class PathLimit(object): 108 | """Class to limit recursion depth during the dictdiffer.diff execution.""" 109 | 110 | def __init__(self, path_limits=[], final_key=None): 111 | """Initialize a dictionary structure to determine a path limit. 112 | 113 | :param path_limits: list of keys (tuples) determining the path limits 114 | :param final_key: the key used in the dictionary to determin if the 115 | path is final 116 | 117 | >>> pl = PathLimit( [('foo', 'bar')] , final_key='!@#$%FINAL') 118 | >>> pl.dict 119 | {'foo': {'bar': {'!@#$%FINAL': True}}} 120 | """ 121 | self.final_key = final_key if final_key else '!@#$FINAL' 122 | self.dict = {} 123 | for key_path in path_limits: 124 | containing = self.dict 125 | for key in key_path: 126 | try: 127 | containing = containing[key] 128 | except KeyError: 129 | containing[key] = {} 130 | containing = containing[key] 131 | 132 | containing[self.final_key] = True 133 | 134 | def path_is_limit(self, key_path): 135 | """Query the PathLimit object if the given key_path is a limit. 136 | 137 | >>> pl = PathLimit( [('foo', 'bar')] , final_key='!@#$%FINAL') 138 | >>> pl.path_is_limit( ('foo', 'bar') ) 139 | True 140 | """ 141 | containing = self.dict 142 | for key in key_path: 143 | try: 144 | containing = containing[key] 145 | except KeyError: 146 | try: 147 | containing = containing['*'] 148 | except KeyError: 149 | return False 150 | 151 | return containing.get(self.final_key, False) 152 | 153 | 154 | def create_dotted_node(node): 155 | """Create the *dotted node* notation for the dictdiffer.diff patches. 156 | 157 | >>> create_dotted_node( ['foo', 'bar', 'baz'] ) 158 | 'foo.bar.baz' 159 | """ 160 | if all(map(lambda x: isinstance(x, str), node)): 161 | return '.'.join(node) 162 | else: 163 | return list(node) 164 | 165 | 166 | def get_path(patch): 167 | """Return the path for a given dictdiffer.diff patch.""" 168 | if patch[1] != '': 169 | keys = (patch[1].split('.') if isinstance(patch[1], str) 170 | else patch[1]) 171 | else: 172 | keys = [] 173 | keys = keys + [patch[2][0][0]] if patch[0] != 'change' else keys 174 | return tuple(keys) 175 | 176 | 177 | def is_super_path(path1, path2): 178 | """Check if one path is the super path of the other. 179 | 180 | Super path means, that the n values in tuple are equal to the first n of m 181 | vales in tuple b. 182 | 183 | >>> is_super_path( ('foo', 'bar'), ('foo', 'bar') ) 184 | True 185 | 186 | >>> is_super_path( ('foo', 'bar'), ('foo', 'bar', 'baz') ) 187 | True 188 | 189 | >>> is_super_path( ('foo', 'bar'), ('foo', 'apple', 'banana') ) 190 | False 191 | """ 192 | return all(map(lambda x: x[0] == x[1] or x[0] is None, 193 | zip_longest(path1, path2))) 194 | 195 | 196 | def nested_hash(obj): 197 | """Create a hash of nested, mutable data structures. 198 | 199 | It shall be noted, that the uniqeness of those hashes in general cases is 200 | not assured but it should be enough for the cases occurring during the 201 | merging process. 202 | """ 203 | try: 204 | return hash(obj) 205 | except TypeError: 206 | if isinstance(obj, (list, tuple)): 207 | return hash(tuple(map(nested_hash, obj))) 208 | elif isinstance(obj, set): 209 | return hash(tuple(map(nested_hash, sorted(obj)))) 210 | elif isinstance(obj, dict): 211 | return hash(tuple(map(nested_hash, sorted(obj.items())))) 212 | 213 | 214 | def dot_lookup(source, lookup, parent=False): 215 | """Allow you to reach dictionary items with string or list lookup. 216 | 217 | Recursively find value by lookup key split by '.'. 218 | 219 | >>> from dictdiffer.utils import dot_lookup 220 | >>> dot_lookup({'a': {'b': 'hello'}}, 'a.b') 221 | 'hello' 222 | 223 | If parent argument is True, returns the parent node of matched 224 | object. 225 | 226 | >>> dot_lookup({'a': {'b': 'hello'}}, 'a.b', parent=True) 227 | {'b': 'hello'} 228 | 229 | If node is empty value, returns the whole dictionary object. 230 | 231 | >>> dot_lookup({'a': {'b': 'hello'}}, '') 232 | {'a': {'b': 'hello'}} 233 | 234 | """ 235 | if lookup is None or lookup == '' or lookup == []: 236 | return source 237 | 238 | value = source 239 | if isinstance(lookup, str): 240 | keys = lookup.split('.') 241 | elif isinstance(lookup, list): 242 | keys = lookup 243 | else: 244 | raise TypeError('lookup must be string or list') 245 | 246 | if parent: 247 | keys = keys[:-1] 248 | 249 | for key in keys: 250 | if isinstance(value, list): 251 | key = int(key) 252 | value = value[key] 253 | return value 254 | 255 | 256 | def are_different(first, second, tolerance, absolute_tolerance=None): 257 | """Check if 2 values are different. 258 | 259 | In case of numerical values, the tolerance is used to check if the values 260 | are different. 261 | In all other cases, the difference is straight forward. 262 | """ 263 | if first == second: 264 | # values are same - simple case 265 | return False 266 | 267 | first_is_nan, second_is_nan = bool(first != first), bool(second != second) 268 | 269 | if first_is_nan or second_is_nan: 270 | # two 'NaN' values are not different (see issue #114) 271 | return not (first_is_nan and second_is_nan) 272 | elif isinstance(first, num_types) and isinstance(second, num_types): 273 | # two numerical values are compared with tolerance 274 | return not math.isclose( 275 | first, 276 | second, 277 | rel_tol=tolerance or 0, 278 | abs_tol=absolute_tolerance or 0, 279 | ) 280 | # we got different values 281 | return True 282 | -------------------------------------------------------------------------------- /dictdiffer/version.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Do not change the format of this next line. Doing so risks breaking 3 | # setup.py and docs/conf.py 4 | """Version information for dictdiffer package.""" 5 | 6 | __version__ = '0.9.0' 7 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # This file is part of Dictdiffer. 2 | # 3 | # Copyright (C) 2014 CERN. 4 | # 5 | # Dictdiffer is free software; you can redistribute it and/or modify 6 | # it under the terms of the MIT License; see LICENSE file for more 7 | # details. 8 | 9 | # Makefile for Sphinx documentation 10 | # 11 | 12 | # You can set these variables from the command line. 13 | SPHINXOPTS = 14 | SPHINXBUILD = sphinx-build 15 | PAPER = 16 | BUILDDIR = _build 17 | 18 | # User-friendly check for sphinx-build 19 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 20 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 21 | endif 22 | 23 | # Internal variables. 24 | PAPEROPT_a4 = -D latex_paper_size=a4 25 | PAPEROPT_letter = -D latex_paper_size=letter 26 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 27 | # the i18n builder cannot share the environment and doctrees with the others 28 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 29 | 30 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 31 | 32 | help: 33 | @echo "Please use \`make ' where is one of" 34 | @echo " html to make standalone HTML files" 35 | @echo " dirhtml to make HTML files named index.html in directories" 36 | @echo " singlehtml to make a single large HTML file" 37 | @echo " pickle to make pickle files" 38 | @echo " json to make JSON files" 39 | @echo " htmlhelp to make HTML files and a HTML help project" 40 | @echo " qthelp to make HTML files and a qthelp project" 41 | @echo " devhelp to make HTML files and a Devhelp project" 42 | @echo " epub to make an epub" 43 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 44 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 45 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 46 | @echo " text to make text files" 47 | @echo " man to make manual pages" 48 | @echo " texinfo to make Texinfo files" 49 | @echo " info to make Texinfo files and run them through makeinfo" 50 | @echo " gettext to make PO message catalogs" 51 | @echo " changes to make an overview of all changed/added/deprecated items" 52 | @echo " xml to make Docutils-native XML files" 53 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 54 | @echo " linkcheck to check all external links for integrity" 55 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 56 | 57 | clean: 58 | rm -rf $(BUILDDIR)/* 59 | 60 | html: 61 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 62 | @echo 63 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 64 | 65 | dirhtml: 66 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 67 | @echo 68 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 69 | 70 | singlehtml: 71 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 72 | @echo 73 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 74 | 75 | pickle: 76 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 77 | @echo 78 | @echo "Build finished; now you can process the pickle files." 79 | 80 | json: 81 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 82 | @echo 83 | @echo "Build finished; now you can process the JSON files." 84 | 85 | htmlhelp: 86 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 87 | @echo 88 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 89 | ".hhp project file in $(BUILDDIR)/htmlhelp." 90 | 91 | qthelp: 92 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 93 | @echo 94 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 95 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 96 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Dictdiffer.qhcp" 97 | @echo "To view the help file:" 98 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Dictdiffer.qhc" 99 | 100 | devhelp: 101 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 102 | @echo 103 | @echo "Build finished." 104 | @echo "To view the help file:" 105 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Dictdiffer" 106 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Dictdiffer" 107 | @echo "# devhelp" 108 | 109 | epub: 110 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 111 | @echo 112 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 113 | 114 | latex: 115 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 116 | @echo 117 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 118 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 119 | "(use \`make latexpdf' here to do that automatically)." 120 | 121 | latexpdf: 122 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 123 | @echo "Running LaTeX files through pdflatex..." 124 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 125 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 126 | 127 | latexpdfja: 128 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 129 | @echo "Running LaTeX files through platex and dvipdfmx..." 130 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 131 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 132 | 133 | text: 134 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 135 | @echo 136 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 137 | 138 | man: 139 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 140 | @echo 141 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 142 | 143 | texinfo: 144 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 145 | @echo 146 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 147 | @echo "Run \`make' in that directory to run these through makeinfo" \ 148 | "(use \`make info' here to do that automatically)." 149 | 150 | info: 151 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 152 | @echo "Running Texinfo files through makeinfo..." 153 | make -C $(BUILDDIR)/texinfo info 154 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 155 | 156 | gettext: 157 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 158 | @echo 159 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 160 | 161 | changes: 162 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 163 | @echo 164 | @echo "The overview file is in $(BUILDDIR)/changes." 165 | 166 | linkcheck: 167 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 168 | @echo 169 | @echo "Link check complete; look for any errors in the above output " \ 170 | "or in $(BUILDDIR)/linkcheck/output.txt." 171 | 172 | doctest: 173 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 174 | @echo "Testing of doctests in the sources finished, look at the " \ 175 | "results in $(BUILDDIR)/doctest/output.txt." 176 | 177 | xml: 178 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 179 | @echo 180 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 181 | 182 | pseudoxml: 183 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 184 | @echo 185 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 186 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Dictdiffer. 4 | # 5 | # Copyright (C) 2014, 2016 CERN. 6 | # 7 | # Dictdiffer is free software; you can redistribute it and/or modify 8 | # it under the terms of the MIT License; see LICENSE file for more 9 | # details. 10 | # 11 | # Dictdiffer documentation build configuration file, created by 12 | # sphinx-quickstart on Tue Aug 26 12:50:15 2014. 13 | # 14 | # This file is execfile()d with the current directory set to its 15 | # containing dir. 16 | # 17 | # Note that not all possible configuration values are present in this 18 | # autogenerated file. 19 | # 20 | # All configuration values have a default; values that are commented out 21 | # serve to show the default. 22 | 23 | """Sphinx configuration.""" 24 | 25 | from __future__ import print_function 26 | 27 | import sys 28 | 29 | from pkg_resources import get_distribution 30 | 31 | _html_theme = "sphinx_rtd_theme" 32 | _html_theme_path = [] 33 | try: 34 | import sphinx_rtd_theme 35 | _html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 36 | except ImportError: 37 | print("Template {0} not found, pip install it", file=sys.stderr) 38 | _html_theme = "default" 39 | 40 | # If extensions (or modules to document with autodoc) are in another directory, 41 | # add these directories to sys.path here. If the directory is relative to the 42 | # documentation root, use os.path.abspath to make it absolute, like shown here. 43 | #sys.path.insert(0, os.path.abspath('.')) 44 | 45 | # -- General configuration ------------------------------------------------ 46 | 47 | # If your documentation needs a minimal Sphinx version, state it here. 48 | #needs_sphinx = '1.0' 49 | 50 | # Do not warn on external images. 51 | suppress_warnings = ['image.nonlocal_uri'] 52 | 53 | # Add any Sphinx extension module names here, as strings. They can be 54 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 55 | # ones. 56 | extensions = [ 57 | 'sphinx.ext.autodoc', 58 | 'sphinx.ext.coverage', 59 | 'sphinx.ext.doctest', 60 | 'sphinx.ext.intersphinx', 61 | ] 62 | 63 | # Add any paths that contain templates here, relative to this directory. 64 | templates_path = ['_templates'] 65 | 66 | # The suffix of source filenames. 67 | source_suffix = '.rst' 68 | 69 | # The encoding of source files. 70 | #source_encoding = 'utf-8-sig' 71 | 72 | # The master toctree document. 73 | master_doc = 'index' 74 | 75 | # General information about the project. 76 | project = u'Dictdiffer' 77 | copyright = u'2014, Fatih Erikli' 78 | 79 | # The version info for the project you're documenting, acts as replacement for 80 | # |version| and |release|, also used in various other places throughout the 81 | # built documents. 82 | # 83 | # The short X.Y version. 84 | version = get_distribution('dictdiffer').version 85 | 86 | # The full version, including alpha/beta/rc tags. 87 | release = version 88 | 89 | # The language for content autogenerated by Sphinx. Refer to documentation 90 | # for a list of supported languages. 91 | #language = None 92 | 93 | # There are two options for replacing |today|: either, you set today to some 94 | # non-false value, then it is used: 95 | #today = '' 96 | # Else, today_fmt is used as the format for a strftime call. 97 | #today_fmt = '%B %d, %Y' 98 | 99 | # List of patterns, relative to source directory, that match files and 100 | # directories to ignore when looking for source files. 101 | exclude_patterns = ['_build'] 102 | 103 | # The reST default role (used for this markup: `text`) to use for all 104 | # documents. 105 | #default_role = None 106 | 107 | # If true, '()' will be appended to :func: etc. cross-reference text. 108 | #add_function_parentheses = True 109 | 110 | # If true, the current module name will be prepended to all description 111 | # unit titles (such as .. function::). 112 | #add_module_names = True 113 | 114 | # If true, sectionauthor and moduleauthor directives will be shown in the 115 | # output. They are ignored by default. 116 | #show_authors = False 117 | 118 | # The name of the Pygments (syntax highlighting) style to use. 119 | pygments_style = 'sphinx' 120 | 121 | # A list of ignored prefixes for module index sorting. 122 | #modindex_common_prefix = [] 123 | 124 | # If true, keep warnings as "system message" paragraphs in the built documents. 125 | #keep_warnings = False 126 | 127 | 128 | # -- Options for HTML output ---------------------------------------------- 129 | 130 | # The theme to use for HTML and HTML Help pages. See the documentation for 131 | # a list of builtin themes. 132 | #html_theme = 'default' 133 | html_theme = _html_theme 134 | 135 | # Theme options are theme-specific and customize the look and feel of a theme 136 | # further. For a list of options available for each theme, see the 137 | # documentation. 138 | #html_theme_options = {} 139 | 140 | # Add any paths that contain custom themes here, relative to this directory. 141 | #html_theme_path = [] 142 | html_theme_path = _html_theme_path 143 | 144 | # The name for this set of Sphinx documents. If None, it defaults to 145 | # " v documentation". 146 | #html_title = None 147 | 148 | # A shorter title for the navigation bar. Default is the same as html_title. 149 | #html_short_title = None 150 | 151 | # The name of an image file (relative to this directory) to place at the top 152 | # of the sidebar. 153 | #html_logo = None 154 | 155 | # The name of an image file (within the static path) to use as favicon of the 156 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 157 | # pixels large. 158 | #html_favicon = None 159 | 160 | # Add any paths that contain custom static files (such as style sheets) here, 161 | # relative to this directory. They are copied after the builtin static files, 162 | # so a file named "default.css" will overwrite the builtin "default.css". 163 | #html_static_path = ['_static'] 164 | 165 | # Add any extra paths that contain custom files (such as robots.txt or 166 | # .htaccess) here, relative to this directory. These files are copied 167 | # directly to the root of the documentation. 168 | #html_extra_path = [] 169 | 170 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 171 | # using the given strftime format. 172 | #html_last_updated_fmt = '%b %d, %Y' 173 | 174 | # If true, SmartyPants will be used to convert quotes and dashes to 175 | # typographically correct entities. 176 | #html_use_smartypants = True 177 | 178 | # Custom sidebar templates, maps document names to template names. 179 | #html_sidebars = {} 180 | 181 | # Additional templates that should be rendered to pages, maps page names to 182 | # template names. 183 | #html_additional_pages = {} 184 | 185 | # If false, no module index is generated. 186 | #html_domain_indices = True 187 | 188 | # If false, no index is generated. 189 | #html_use_index = True 190 | 191 | # If true, the index is split into individual pages for each letter. 192 | #html_split_index = False 193 | 194 | # If true, links to the reST sources are added to the pages. 195 | #html_show_sourcelink = True 196 | 197 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 198 | #html_show_sphinx = True 199 | 200 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 201 | #html_show_copyright = True 202 | 203 | # If true, an OpenSearch description file will be output, and all pages will 204 | # contain a tag referring to it. The value of this option must be the 205 | # base URL from which the finished HTML is served. 206 | #html_use_opensearch = '' 207 | 208 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 209 | #html_file_suffix = None 210 | 211 | # Output file base name for HTML help builder. 212 | htmlhelp_basename = 'Dictdifferdoc' 213 | 214 | 215 | # -- Options for LaTeX output --------------------------------------------- 216 | 217 | latex_elements = { 218 | # The paper size ('letterpaper' or 'a4paper'). 219 | #'papersize': 'letterpaper', 220 | 221 | # The font size ('10pt', '11pt' or '12pt'). 222 | #'pointsize': '10pt', 223 | 224 | # Additional stuff for the LaTeX preamble. 225 | #'preamble': '', 226 | } 227 | 228 | # Grouping the document tree into LaTeX files. List of tuples 229 | # (source start file, target name, title, 230 | # author, documentclass [howto, manual, or own class]). 231 | latex_documents = [ 232 | ('index', 'Dictdiffer.tex', u'Dictdiffer Documentation', 233 | u'Fatih Erikli', 'manual'), 234 | ] 235 | 236 | # The name of an image file (relative to this directory) to place at the top of 237 | # the title page. 238 | #latex_logo = None 239 | 240 | # For "manual" documents, if this is true, then toplevel headings are parts, 241 | # not chapters. 242 | #latex_use_parts = False 243 | 244 | # If true, show page references after internal links. 245 | #latex_show_pagerefs = False 246 | 247 | # If true, show URL addresses after external links. 248 | #latex_show_urls = False 249 | 250 | # Documents to append as an appendix to all manuals. 251 | #latex_appendices = [] 252 | 253 | # If false, no module index is generated. 254 | #latex_domain_indices = True 255 | 256 | 257 | # -- Options for manual page output --------------------------------------- 258 | 259 | # One entry per manual page. List of tuples 260 | # (source start file, name, description, authors, manual section). 261 | man_pages = [ 262 | ('index', 'dictdiffer', u'Dictdiffer Documentation', 263 | [u'Fatih Erikli'], 1) 264 | ] 265 | 266 | # If true, show URL addresses after external links. 267 | #man_show_urls = False 268 | 269 | 270 | # -- Options for Texinfo output ------------------------------------------- 271 | 272 | # Grouping the document tree into Texinfo files. List of tuples 273 | # (source start file, target name, title, author, 274 | # dir menu entry, description, category) 275 | texinfo_documents = [ 276 | ('index', 'Dictdiffer', u'Dictdiffer Documentation', 277 | u'Fatih Erikli', 'Dictdiffer', 'One line description of project.', 278 | 'Miscellaneous'), 279 | ] 280 | 281 | # Documents to append as an appendix to all manuals. 282 | #texinfo_appendices = [] 283 | 284 | # If false, no module index is generated. 285 | #texinfo_domain_indices = True 286 | 287 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 288 | #texinfo_show_urls = 'footnote' 289 | 290 | # If true, do not generate a @detailmenu in the "Top" node's menu. 291 | #texinfo_no_detailmenu = False 292 | 293 | # Example configuration for intersphinx: refer to the Python standard library. 294 | 295 | intersphinx_mapping = { 296 | 'python': ('https://docs.python.org/2.7', None), 297 | } 298 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Dictdiffer 3 | ========== 4 | .. currentmodule:: dictdiffer 5 | 6 | .. image:: https://github.com/inveniosoftware/dictdiffer/workflows/CI/badge.svg 7 | :target: https://github.com/inveniosoftware/dictdiffer/actions 8 | 9 | .. image:: https://img.shields.io/coveralls/inveniosoftware/dictdiffer.svg 10 | :target: https://coveralls.io/r/inveniosoftware/dictdiffer 11 | 12 | .. image:: https://img.shields.io/github/tag/inveniosoftware/dictdiffer.svg 13 | :target: https://github.com/inveniosoftware/dictdiffer/releases 14 | 15 | .. image:: https://img.shields.io/pypi/dm/dictdiffer.svg 16 | :target: https://pypi.python.org/pypi/dictdiffer 17 | 18 | .. image:: https://img.shields.io/github/license/inveniosoftware/dictdiffer.svg 19 | :target: https://github.com/inveniosoftware/dictdiffer/blob/master/LICENSE 20 | 21 | Dictdiffer is a helper module that helps you to diff and patch 22 | dictionaries. 23 | 24 | Installation 25 | ============ 26 | 27 | Dictdiffer is on PyPI so all you need is: 28 | 29 | .. code-block:: console 30 | 31 | $ pip install dictdiffer 32 | 33 | 34 | Usage 35 | ===== 36 | 37 | Let's start with an example on how to find the diff between two 38 | dictionaries using :func:`.diff` method: 39 | 40 | .. code-block:: python 41 | 42 | from dictdiffer import diff, patch, swap, revert 43 | 44 | first = { 45 | "title": "hello", 46 | "fork_count": 20, 47 | "stargazers": ["/users/20", "/users/30"], 48 | "settings": { 49 | "assignees": [100, 101, 201], 50 | } 51 | } 52 | 53 | second = { 54 | "title": "hellooo", 55 | "fork_count": 20, 56 | "stargazers": ["/users/20", "/users/30", "/users/40"], 57 | "settings": { 58 | "assignees": [100, 101, 202], 59 | } 60 | } 61 | 62 | result = diff(first, second) 63 | 64 | assert list(result) == [ 65 | ('change', ['settings', 'assignees', 2], (201, 202)), 66 | ('add', 'stargazers', [(2, '/users/40')]), 67 | ('change', 'title', ('hello', 'hellooo'))] 68 | 69 | 70 | Now we can apply the diff result with :func:`.patch` method: 71 | 72 | .. code-block:: python 73 | 74 | result = diff(first, second) 75 | patched = patch(result, first) 76 | 77 | assert patched == second 78 | 79 | 80 | Also we can swap the diff result with :func:`.swap` method: 81 | 82 | .. code-block:: python 83 | 84 | result = diff(first, second) 85 | swapped = swap(result) 86 | 87 | assert list(swapped) == [ 88 | ('change', ['settings', 'assignees', 2], (202, 201)), 89 | ('remove', 'stargazers', [(2, '/users/40')]), 90 | ('change', 'title', ('hellooo', 'hello'))] 91 | 92 | 93 | Let's revert the last changes: 94 | 95 | .. code-block:: python 96 | 97 | result = diff(first, second) 98 | reverted = revert(result, patched) 99 | assert reverted == first 100 | 101 | A tolerance can be used to consider closed values as equal. 102 | The tolerance parameter only applies for int and float. 103 | 104 | Let's try with a tolerance of 10% with the values 10 and 10.5: 105 | 106 | .. code-block:: python 107 | 108 | first = {'a': 10.0} 109 | second = {'a': 10.5} 110 | 111 | result = diff(first, second, tolerance=0.1) 112 | 113 | assert list(result) == [] 114 | 115 | Now with a tolerance of 1%: 116 | 117 | .. code-block:: python 118 | 119 | result = diff(first, second, tolerance=0.01) 120 | 121 | assert list(result) == ('change', 'a', (10.0, 10.5)) 122 | 123 | API 124 | === 125 | 126 | .. automodule:: dictdiffer 127 | :members: 128 | 129 | .. include:: ../CHANGES 130 | 131 | .. include:: ../CONTRIBUTING.rst 132 | 133 | License 134 | ======= 135 | 136 | .. include:: ../LICENSE 137 | 138 | .. include:: ../AUTHORS 139 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | -e .[docs,tests] 2 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | # This file is part of Dictdiffer. 2 | # 3 | # Copyright (C) 2014, 2016 CERN. 4 | # 5 | # Dictdiffer is free software; you can redistribute it and/or modify 6 | # it under the terms of the MIT License; see LICENSE file for more 7 | # details. 8 | 9 | [pytest] 10 | addopts = --isort --pydocstyle --pycodestyle --doctest-glob="*.rst" --doctest-modules --cov=dictdiffer --cov-report=term-missing 11 | testpaths = tests dictdiffer 12 | filterwarnings = ignore::pytest.PytestDeprecationWarning 13 | -------------------------------------------------------------------------------- /run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # -*- coding: utf-8 -*- 3 | # 4 | # This file is part of Dictdiffer. 5 | # 6 | # Copyright (C) 2013 Fatih Erikli. 7 | # Copyright (C) 2014, 2016 CERN. 8 | # Copyright (C) 2019 ETH Zurich, Swiss Data Science Center, Jiri Kuncar. 9 | # 10 | # Dictdiffer is free software; you can redistribute it and/or modify 11 | # it under the terms of the MIT License; see LICENSE file for more 12 | # details. 13 | 14 | # Quit on errors 15 | set -o errexit 16 | 17 | # Quit on unbound symbols 18 | set -o nounset 19 | 20 | python -m check_manifest --ignore ".*-requirements.txt" 21 | python -m sphinx.cmd.build -qnNW docs docs/_build/html 22 | python -m pytest 23 | python -m sphinx.cmd.build -qnNW -b doctest docs docs/_build/doctest 24 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # This file is part of Dictdiffer. 2 | # 3 | # Copyright (C) 2016 CERN. 4 | # 5 | # Dictdiffer is free software; you can redistribute it and/or modify 6 | # it under the terms of the MIT License; see LICENSE file for more 7 | # details. 8 | 9 | [aliases] 10 | test = pytest 11 | 12 | [build_sphinx] 13 | source-dir = docs/ 14 | build-dir = docs/_build 15 | all_files = 1 16 | 17 | [bdist_wheel] 18 | universal = 1 19 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # This file is part of Dictdiffer. 2 | # 3 | # Copyright (C) 2013 Fatih Erikli. 4 | # Copyright (C) 2014, 2015, 2016 CERN. 5 | # Copyright (C) 2017, 2019 ETH Zurich, Swiss Data Science Center, Jiri Kuncar. 6 | # 7 | # Dictdiffer is free software; you can redistribute it and/or modify 8 | # it under the terms of the MIT License; see LICENSE file for more 9 | # details. 10 | 11 | """Dictdiffer is a library that helps you to diff and patch dictionaries.""" 12 | 13 | from __future__ import absolute_import, print_function 14 | 15 | import os 16 | 17 | from setuptools import find_packages, setup 18 | 19 | readme = open('README.rst').read() 20 | 21 | tests_require = [ 22 | 'check-manifest>=0.42', 23 | 'mock>=1.3.0', 24 | 'pytest==5.4.3;python_version<="3.5"', 25 | 'pytest>=6;python_version>"3.5"', 26 | 'pytest-cov>=2.10.1', 27 | 'pytest-isort>=1.2.0', 28 | 'pytest-pycodestyle>=2;python_version<="3.5"', 29 | 'pytest-pycodestyle>=2.2.0;python_version>"3.5"', 30 | 'pytest-pydocstyle>=2;python_version<="3.5"', 31 | 'pytest-pydocstyle>=2.2.0;python_version>"3.5"', 32 | 'sphinx>=3', 33 | 'tox>=3.7.0', 34 | ] 35 | 36 | extras_require = { 37 | 'docs': [ 38 | 'Sphinx>=3', 39 | 'sphinx-rtd-theme>=0.2', 40 | ], 41 | 'numpy': [ 42 | 'numpy>=1.13.0;python_version<"3.7"', 43 | 'numpy>=1.15.0;python_version<"3.8"', 44 | 'numpy>=1.18.0;python_version<"3.9"', 45 | 'numpy>=1.20.0;python_version>="3.9"', 46 | ], 47 | 'tests': tests_require, 48 | } 49 | 50 | extras_require['all'] = [] 51 | for key, reqs in extras_require.items(): 52 | if ':' == key[0]: 53 | continue 54 | extras_require['all'].extend(reqs) 55 | 56 | setup_requires = [ 57 | 'pytest-runner>=2.7', 58 | 'setuptools_scm>=3.1.0', 59 | ] 60 | 61 | packages = find_packages() 62 | 63 | version_template = """\ 64 | # -*- coding: utf-8 -*- 65 | # Do not change the format of this next line. Doing so risks breaking 66 | # setup.py and docs/conf.py 67 | \"\"\"Version information for dictdiffer package.\"\"\" 68 | 69 | __version__ = {version!r} 70 | """ 71 | 72 | setup( 73 | name='dictdiffer', 74 | use_scm_version={ 75 | 'local_scheme': 'dirty-tag', 76 | 'write_to': os.path.join('dictdiffer', 'version.py'), 77 | 'write_to_template': version_template, 78 | }, 79 | description=__doc__, 80 | long_description=readme, 81 | author='Invenio Collaboration', 82 | author_email='info@inveniosoftware.org', 83 | url='https://github.com/inveniosoftware/dictdiffer', 84 | project_urls={ 85 | 'Changelog': ( 86 | 'https://github.com/inveniosoftware/dictdiffer' 87 | '/blob/master/CHANGES' 88 | ), 89 | 'Docs': 'https://dictdiffer.rtfd.io/', 90 | }, 91 | packages=['dictdiffer'], 92 | zip_safe=False, 93 | python_requires='>=3.5', 94 | extras_require=extras_require, 95 | setup_requires=setup_requires, 96 | tests_require=tests_require, 97 | classifiers=[ 98 | 'Programming Language :: Python :: 3', 99 | 'Programming Language :: Python :: 3.5', 100 | 'Programming Language :: Python :: 3.6', 101 | 'Programming Language :: Python :: 3.7', 102 | 'Programming Language :: Python :: 3.8', 103 | 'Programming Language :: Python :: 3.9', 104 | 'Development Status :: 5 - Production/Stable', 105 | 'Intended Audience :: Developers', 106 | 'License :: OSI Approved :: MIT License', 107 | 'Operating System :: OS Independent', 108 | 'Topic :: Utilities', 109 | ], 110 | ) 111 | -------------------------------------------------------------------------------- /tests/test_conflict.py: -------------------------------------------------------------------------------- 1 | # This file is part of Dictdiffer. 2 | # 3 | # Copyright (C) 2015 CERN. 4 | # 5 | # Dictdiffer is free software; you can redistribute it and/or modify 6 | # it under the terms of the MIT License; see LICENSE file for more 7 | # details. 8 | 9 | import unittest 10 | 11 | from dictdiffer.conflict import Conflict, ConflictFinder 12 | 13 | 14 | class ConflictTest(unittest.TestCase): 15 | def test_init(self): 16 | p1 = ('add', '', [(0, 0)]) 17 | p2 = ('add', '', [(1, 2)]) 18 | 19 | c = Conflict(p1, p2) 20 | 21 | self.assertEqual(c.first_patch, p1) 22 | self.assertEqual(c.second_patch, p2) 23 | self.assertEqual(c.take, None) 24 | 25 | def test_take_patch(self): 26 | p1 = ('add', '', [(1, 1)]) 27 | p2 = ('add', '', [(1, -1)]) 28 | 29 | c = Conflict(p1, p2) 30 | 31 | self.assertRaises(Exception, c.take_patch) 32 | 33 | c.take = 'f' 34 | self.assertEqual(c.take_patch(), p1) 35 | 36 | c.take = 's' 37 | self.assertEqual(c.take_patch(), p2) 38 | 39 | 40 | class ConflictFinderTest(unittest.TestCase): 41 | def test_is_conflict(self): 42 | # SAME LENGTH NO CONFLICT 43 | p1 = ('add', 'foo', [(0, 0)]) 44 | p2 = ('add', 'foo', [(2, 0)]) 45 | 46 | c = ConflictFinder() 47 | self.assertFalse(c._is_conflict(p1, p2)) 48 | 49 | p1 = ('add', 'foo.bar', [(0, 0)]) 50 | p2 = ('add', 'foo.bar', [(2, 0)]) 51 | 52 | c = ConflictFinder() 53 | self.assertFalse(c._is_conflict(p1, p2)) 54 | 55 | # SAME LENGTH CONFLICT 56 | p1 = ('add', 'foo', [(0, 0)]) 57 | p2 = ('add', 'foo', [(0, 0)]) 58 | 59 | c = ConflictFinder() 60 | self.assertTrue(c._is_conflict(p1, p2)) 61 | 62 | p1 = ('add', 'foo.bar', [(0, 0)]) 63 | p2 = ('add', 'foo.bar', [(0, 0)]) 64 | 65 | c = ConflictFinder() 66 | self.assertTrue(c._is_conflict(p1, p2)) 67 | 68 | # SUPER PATH 69 | p1 = ('remove', '', [('foo', [])]) 70 | p2 = ('add', 'foo.bar', [(0, 0)]) 71 | 72 | c = ConflictFinder() 73 | self.assertTrue(c._is_conflict(p1, p2)) 74 | 75 | p1 = ('add', 'foo.bar', [(0, 0)]) 76 | p2 = ('remove', '', [('foo', [])]) 77 | 78 | c = ConflictFinder() 79 | self.assertTrue(c._is_conflict(p1, p2)) 80 | 81 | def test_find_conflicts(self): 82 | p11 = ('add', 'foo.bar', [(0, 0)]) 83 | p12 = ('add', 'foo', [(0, 0)]) 84 | 85 | p21 = ('add', 'foo.bar', [(0, 0)]) 86 | p22 = ('add', 'foo', [(1, 0)]) 87 | 88 | conflicts = [Conflict(p11, p21)] 89 | 90 | c = ConflictFinder() 91 | self.assertEqual(repr(c.find_conflicts([p11, p12], [p21, p22])), 92 | repr(conflicts)) 93 | -------------------------------------------------------------------------------- /tests/test_dictdiffer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Dictdiffer. 4 | # 5 | # Copyright (C) 2013 Fatih Erikli. 6 | # Copyright (C) 2013, 2014, 2015, 2016 CERN. 7 | # Copyright (C) 2017-2019 ETH Zurich, Swiss Data Science Center, Jiri Kuncar. 8 | # 9 | # Dictdiffer is free software; you can redistribute it and/or modify 10 | # it under the terms of the MIT License; see LICENSE file for more 11 | # details. 12 | 13 | import unittest 14 | from collections import OrderedDict 15 | from collections.abc import MutableMapping, MutableSequence 16 | 17 | import pytest 18 | 19 | from dictdiffer import HAS_NUMPY, diff, dot_lookup, patch, revert, swap 20 | from dictdiffer.utils import PathLimit 21 | 22 | 23 | class DictDifferTests(unittest.TestCase): 24 | def test_without_dot_notation(self): 25 | (change1,) = diff({'a': {'x': 1}}, 26 | {'a': {'x': 2}}, 27 | dot_notation=False) 28 | 29 | assert change1 == ('change', ['a', 'x'], (1, 2)) 30 | 31 | def test_with_dot_notation(self): 32 | (change1,) = diff({'a': {'x': 1}}, 33 | {'a': {'x': 2}}) 34 | 35 | assert change1 == ('change', 'a.x', (1, 2)) 36 | 37 | def test_addition(self): 38 | first = {} 39 | second = {'a': 'b'} 40 | diffed = next(diff(first, second)) 41 | assert ('add', '', [('a', 'b')]) == diffed 42 | 43 | def test_deletion(self): 44 | first = {'a': 'b'} 45 | second = {} 46 | diffed = next(diff(first, second)) 47 | assert ('remove', '', [('a', 'b')]) == diffed 48 | 49 | def test_change(self): 50 | first = {'a': 'b'} 51 | second = {'a': 'c'} 52 | diffed = next(diff(first, second)) 53 | assert ('change', 'a', ('b', 'c')) == diffed 54 | 55 | first = {'a': None} 56 | second = {'a': 'c'} 57 | diffed = next(diff(first, second)) 58 | assert ('change', 'a', (None, 'c')) == diffed 59 | 60 | first = {'a': 'c'} 61 | second = {'a': None} 62 | diffed = next(diff(first, second)) 63 | assert ('change', 'a', ('c', None)) == diffed 64 | 65 | first = {'a': 'c'} 66 | second = {'a': u'c'} 67 | diffed = list(diff(first, second)) 68 | assert [] == diffed 69 | 70 | first = {'a': 'b'} 71 | second = {'a': None} 72 | diffed = next(diff(first, second)) 73 | assert ('change', 'a', ('b', None)) == diffed 74 | 75 | first = {'a': 10.0} 76 | second = {'a': 10.5} 77 | diffed = next(diff(first, second)) 78 | assert ('change', 'a', (10.0, 10.5)) == diffed 79 | 80 | def test_immutable_diffs(self): 81 | first = {'a': 'a'} 82 | second = {'a': {'b': 'b'}} 83 | result = list(diff(first, second)) 84 | assert result[0][2][1]['b'] == 'b' 85 | second['a']['b'] = 'c' # result MUST stay unchanged 86 | assert result[0][2][1]['b'] == 'b' 87 | 88 | def test_tolerance(self): 89 | first = {'a': 'b'} 90 | second = {'a': 'c'} 91 | diffed = next(diff(first, second, tolerance=0.1)) 92 | assert ('change', 'a', ('b', 'c')) == diffed 93 | 94 | first = {'a': None} 95 | second = {'a': 'c'} 96 | diffed = next(diff(first, second, tolerance=0.1)) 97 | assert ('change', 'a', (None, 'c')) == diffed 98 | 99 | first = {'a': 10.0} 100 | second = {'a': 10.5} 101 | diffed = list(diff(first, second, tolerance=0.1)) 102 | assert [] == diffed 103 | 104 | diffed = next(diff(first, second, tolerance=0.01)) 105 | assert ('change', 'a', (10.0, 10.5)) == diffed 106 | 107 | first = {'a': 10.0, 'b': 1.0e-15} 108 | second = {'a': 10.5, 'b': 2.5e-15} 109 | diffed = sorted(diff( 110 | first, second, tolerance=0.01 111 | )) 112 | assert [ 113 | ('change', 'a', (10.0, 10.5)), 114 | ('change', 'b', (1.0e-15, 2.5e-15)), 115 | ] == diffed 116 | 117 | diffed = sorted(diff( 118 | first, second, tolerance=0.01, absolute_tolerance=1e-12 119 | )) 120 | assert [('change', 'a', (10.0, 10.5))] == diffed 121 | 122 | diffed = sorted(diff( 123 | first, second, tolerance=0.01, absolute_tolerance=1e-18 124 | )) 125 | assert [ 126 | ('change', 'a', (10.0, 10.5)), 127 | ('change', 'b', (1.0e-15, 2.5e-15)), 128 | ] == diffed 129 | 130 | diffed = sorted(diff( 131 | first, second, tolerance=0.1, absolute_tolerance=1e-18 132 | )) 133 | assert [('change', 'b', (1.0e-15, 2.5e-15))] == diffed 134 | 135 | diffed = sorted(diff( 136 | first, second, tolerance=0.1, absolute_tolerance=1e-12 137 | )) 138 | assert [] == diffed 139 | 140 | diffed = sorted(diff( 141 | first, second, tolerance=None, absolute_tolerance=None 142 | )) 143 | assert [ 144 | ('change', 'a', (10.0, 10.5)), 145 | ('change', 'b', (1.0e-15, 2.5e-15)), 146 | ] == diffed 147 | 148 | first = {'a': 10.0, 'b': 1.0e-15} 149 | second = {'a': 10.0, 'b': 1.0e-15} 150 | diffed = sorted(diff( 151 | first, second, tolerance=None, absolute_tolerance=None 152 | )) 153 | assert [] == diffed 154 | 155 | def test_path_limit_as_list(self): 156 | first = {} 157 | second = {'author': {'last_name': 'Doe', 'first_name': 'John'}} 158 | diffed = list(diff(first, second, path_limit=[('author',)])) 159 | 160 | res = [('add', '', [('author', 161 | {'first_name': 'John', 'last_name': 'Doe'})])] 162 | 163 | assert res == diffed 164 | 165 | def test_path_limit_addition(self): 166 | first = {} 167 | second = {'author': {'last_name': 'Doe', 'first_name': 'John'}} 168 | p = PathLimit([('author',)]) 169 | diffed = list(diff(first, second, path_limit=p)) 170 | 171 | res = [('add', '', [('author', 172 | {'first_name': 'John', 'last_name': 'Doe'})])] 173 | 174 | assert res == diffed 175 | 176 | first = {} 177 | second = {'author': {'last_name': 'Doe', 'first_name': 'John'}} 178 | p = PathLimit([('author',)]) 179 | diffed = list(diff(first, second, path_limit=p, expand=True)) 180 | 181 | res = [('add', '', [('author', 182 | {'first_name': 'John', 'last_name': 'Doe'})])] 183 | 184 | assert res == diffed 185 | 186 | first = {} 187 | second = {'author': {'last_name': 'Doe', 'first_name': 'John'}} 188 | p = PathLimit() 189 | diffed = list(diff(first, second, path_limit=p, expand=True)) 190 | res = [('add', '', [('author', {})]), 191 | ('add', 'author', [('first_name', 'John')]), 192 | ('add', 'author', [('last_name', 'Doe')])] 193 | 194 | assert len(diffed) == 3 195 | for patch in res: 196 | assert patch in diffed 197 | 198 | def test_path_limit_deletion(self): 199 | first = {'author': {'last_name': 'Doe', 'first_name': 'John'}} 200 | second = {} 201 | p = PathLimit([('author',)]) 202 | diffed = list(diff(first, second, path_limit=p, expand=True)) 203 | 204 | res = [('remove', '', [('author', 205 | {'first_name': 'John', 'last_name': 'Doe'})])] 206 | 207 | assert res == diffed 208 | 209 | def test_path_limit_change(self): 210 | first = {'author': {'last_name': 'Do', 'first_name': 'John'}} 211 | second = {'author': {'last_name': 'Do', 'first_name': 'John'}} 212 | p = PathLimit([('author',)]) 213 | diffed = list(diff(first, second, path_limit=p, expand=True)) 214 | assert [] == diffed 215 | 216 | first = {'author': {'last_name': 'Do', 'first_name': 'John'}} 217 | second = {'author': {'last_name': 'Doe', 'first_name': 'John'}} 218 | p = PathLimit([('author',)]) 219 | diffed = list(diff(first, second, path_limit=p, expand=True)) 220 | 221 | res = [('change', 222 | ['author'], 223 | ({'first_name': 'John', 'last_name': 'Do'}, 224 | {'first_name': 'John', 'last_name': 'Doe'}))] 225 | 226 | assert res == diffed 227 | 228 | first = {'author': {'last_name': 'Do', 'first_name': 'John'}} 229 | second = {'author': {'last_name': 'Doe', 'first_name': 'John'}} 230 | p = PathLimit() 231 | diffed = list(diff(first, second, path_limit=p, expand=True)) 232 | 233 | res = [('change', 'author.last_name', ('Do', 'Doe'))] 234 | 235 | assert res == diffed 236 | 237 | def test_expand_addition(self): 238 | first = {} 239 | second = {'foo': 'bar', 'apple': 'banana'} 240 | diffed = list(diff(first, second, expand=True)) 241 | res = [('add', '', [('foo', 'bar')]), 242 | ('add', '', [('apple', 'banana')])] 243 | 244 | assert len(diffed) == 2 245 | for patch in res: 246 | assert patch in diffed 247 | 248 | def test_expand_deletion(self): 249 | first = {'foo': 'bar', 'apple': 'banana'} 250 | second = {} 251 | diffed = list(diff(first, second, expand=True)) 252 | res = [('remove', '', [('foo', 'bar')]), 253 | ('remove', '', [('apple', 'banana')])] 254 | 255 | assert len(diffed) == 2 256 | for patch in res: 257 | assert patch in diffed 258 | 259 | def test_nodes(self): 260 | first = {'a': {'b': {'c': 'd'}}} 261 | second = {'a': {'b': {'c': 'd', 'e': 'f'}}} 262 | diffed = next(diff(first, second)) 263 | assert ('add', 'a.b', [('e', 'f')]) == diffed 264 | 265 | def test_add_list(self): 266 | first = {'a': []} 267 | second = {'a': ['b']} 268 | diffed = next(diff(first, second)) 269 | assert ('add', 'a', [(0, 'b')]) == diffed 270 | 271 | def test_remove_list(self): 272 | first = {'a': ['b', 'c']} 273 | second = {'a': []} 274 | diffed = next(diff(first, second)) 275 | assert ('remove', 'a', [(1, 'c'), (0, 'b'), ]) == diffed 276 | 277 | def test_add_set(self): 278 | first = {'a': {1, 2, 3}} 279 | second = {'a': {0, 1, 2, 3}} 280 | diffed = next(diff(first, second)) 281 | assert ('add', 'a', [(0, set([0]))]) == diffed 282 | 283 | def test_remove_set(self): 284 | first = {'a': set([0, 1, 2, 3])} 285 | second = {'a': set([1, 2, 3])} 286 | diffed = next(diff(first, second)) 287 | assert ('remove', 'a', [(0, set([0]))]) == diffed 288 | 289 | def test_change_set(self): 290 | first = {'a': set([0, 1, 2, 3])} 291 | second = {'a': set([1, 2, 3, 4])} 292 | diffed = list(diff(first, second)) 293 | assert ('add', 'a', [(0, set([4]))]) in diffed 294 | assert ('remove', 'a', [(0, set([0]))]) in diffed 295 | 296 | def test_add_set_shift_order(self): 297 | first = set(["changeA", "changeB"]) 298 | second = set(["changeA", "changeC", "changeB"]) 299 | diffed = list(diff(first, second)) 300 | # There should only be 1 change reported 301 | assert len(diffed) == 1 302 | assert ('add', '', [(0, {'changeC'})]) in diffed 303 | 304 | def test_change_set_order(self): 305 | first = set(["changeA", "changeC", "changeB"]) 306 | second = set(["changeB", "changeC", "changeA"]) 307 | diffed = list(diff(first, second)) 308 | # There should be zero reported diffs 309 | assert len(diffed) == 0 310 | 311 | def test_types(self): 312 | first = {'a': ['a']} 313 | second = {'a': 'a'} 314 | diffed = next(diff(first, second)) 315 | assert ('change', 'a', (['a'], 'a')) == diffed 316 | 317 | def test_nan(self): 318 | value = float('nan') 319 | diffed = list(diff([value], [value])) 320 | assert [] == diffed 321 | 322 | diffed = list(diff([value], [3.5])) 323 | assert [('change', [0], (value, 3.5))] == diffed 324 | 325 | @unittest.skipIf(not HAS_NUMPY, 'NumPy is not installed') 326 | def test_numpy_nan(self): 327 | """Compare NumPy NaNs (#114).""" 328 | import numpy as np 329 | first = {'a': np.float32('nan')} 330 | second = {'a': float('nan')} 331 | result = list(diff(first, second)) 332 | assert result == [] 333 | 334 | def test_unicode_keys(self): 335 | first = {u'привет': 1} 336 | second = {'hello': 1} 337 | diffed = list(diff(first, second)) 338 | assert ('add', '', [('hello', 1)]) in diffed 339 | assert ('remove', '', [(u'привет', 1)]) in diffed 340 | 341 | diffed = list(diff(first, second, ignore=['hello'])) 342 | assert ('remove', '', [(u'привет', 1)]) == diffed[0] 343 | 344 | diffed = list(diff(first, second, ignore=[u'привет'])) 345 | assert ('add', '', [('hello', 1)]) == diffed[0] 346 | 347 | def test_dotted_key(self): 348 | first = {'a.b': {'c.d': 1}} 349 | second = {'a.b': {'c.d': 2}} 350 | diffed = list(diff(first, second)) 351 | assert [('change', ['a.b', 'c.d'], (1, 2))] == diffed 352 | 353 | def test_ignore_key(self): 354 | first = {'a': 'a', 'b': 'b', 'c': 'c'} 355 | second = {'a': 'a', 'b': 2, 'c': 3} 356 | diffed = next(diff(first, second, ignore=['b'])) 357 | assert ('change', 'c', ('c', 3)) == diffed 358 | 359 | def test_ignore_dotted_key(self): 360 | first = {'a': {'aa': 'A', 'ab': 'B', 'ac': 'C'}} 361 | second = {'a': {'aa': 1, 'ab': 'B', 'ac': 3}} 362 | diffed = next(diff(first, second, ignore=['a.aa'])) 363 | assert ('change', 'a.ac', ('C', 3)) == diffed 364 | 365 | def test_ignore_with_unicode_sub_keys(self): 366 | first = {u'a': {u'aא': {u'aa': 'A'}}} 367 | second = {u'a': {u'aא': {u'aa': 'B'}}} 368 | 369 | assert len(list(diff(first, second))) == 1 370 | assert len(list(diff(first, second, ignore=[u'a.aא.aa']))) == 0 371 | assert len( 372 | list(diff(first, second, ignore=[[u'a', u'aא', u'aa'] 373 | ]))) == 0 374 | 375 | def test_ignore_complex_key(self): 376 | first = {'a': {1: {'a': 'a', 'b': 'b'}}} 377 | second = {'a': {1: {'a': 1, 'b': 2}}} 378 | diffed = next(diff(first, second, ignore=[['a', 1, 'a']])) 379 | assert ('change', ['a', 1, 'b'], ('b', 2)) == diffed 380 | 381 | def test_ignore_missing_keys(self): 382 | first = {'a': 'a'} 383 | second = {'a': 'a', 'b': 'b'} 384 | assert len(list(diff(first, second, ignore=['b']))) == 0 385 | assert len(list(diff(second, first, ignore=['b']))) == 0 386 | 387 | def test_ignore_missing_complex_keys(self): 388 | first = {'a': {1: {'a': 'a', 'b': 'b'}}} 389 | second = {'a': {1: {'a': 1}}} 390 | diffed = next(diff(first, second, ignore=[['a', 1, 'b']])) 391 | assert ('change', ['a', 1, 'a'], ('a', 1)) == diffed 392 | diffed = next(diff(second, first, ignore=[['a', 1, 'b']])) 393 | assert ('change', ['a', 1, 'a'], (1, 'a')) == diffed 394 | 395 | def test_ignore_stringofintegers_keys(self): 396 | a = {'1': '1', '2': '2', '3': '3'} 397 | b = {'1': '1', '2': '2', '3': '99', '4': '100'} 398 | 399 | assert list(diff(a, b, ignore={'3', '4'})) == [] 400 | 401 | def test_ignore_integers_keys(self): 402 | a = {1: 1, 2: 2, 3: 3} 403 | b = {1: 1, 2: 2, 3: 99, 4: 100} 404 | 405 | assert len(list(diff(a, b, ignore={3, 4}))) == 0 406 | 407 | def test_ignore_with_ignorecase(self): 408 | class IgnoreCase(set): 409 | def __contains__(self, key): 410 | return set.__contains__(self, str(key).lower()) 411 | 412 | assert list(diff({'a': 1, 'b': 2}, {'A': 3, 'b': 4}, 413 | ignore=IgnoreCase('a'))) == [('change', 'b', (2, 4))] 414 | 415 | def test_complex_diff(self): 416 | """Check regression on issue #4.""" 417 | from decimal import Decimal 418 | 419 | d1 = { 420 | 'id': 1, 421 | 'code': None, 422 | 'type': u'foo', 423 | 'bars': [ 424 | {'id': 6934900}, 425 | {'id': 6934977}, 426 | {'id': 6934992}, 427 | {'id': 6934993}, 428 | {'id': 6935014}], 429 | 'n': 10, 430 | 'date_str': u'2013-07-08 00:00:00', 431 | 'float_here': 0.454545, 432 | 'complex': [{ 433 | 'id': 83865, 434 | 'goal': Decimal('2.000000'), 435 | 'state': u'active'}], 436 | 'profile_id': None, 437 | 'state': u'active' 438 | } 439 | 440 | d2 = { 441 | 'id': u'2', 442 | 'code': None, 443 | 'type': u'foo', 444 | 'bars': [ 445 | {'id': 6934900}, 446 | {'id': 6934977}, 447 | {'id': 6934992}, 448 | {'id': 6934993}, 449 | {'id': 6935014}], 450 | 'n': 10, 451 | 'date_str': u'2013-07-08 00:00:00', 452 | 'float_here': 0.454545, 453 | 'complex': [{ 454 | 'id': 83865, 455 | 'goal': Decimal('2.000000'), 456 | 'state': u'active'}], 457 | 'profile_id': None, 458 | 'state': u'active' 459 | } 460 | 461 | assert len(list(diff(d1, {}))) > 0 462 | assert d1['id'] == 1 463 | assert d2['id'] == u'2' 464 | assert d1 is not d2 465 | assert d1 != d2 466 | assert len(list(diff(d1, d2))) > 0 467 | 468 | def test_list_change(self): 469 | """Produced diffs should not contain empty list instructions (#30).""" 470 | first = {"a": {"b": [100, 101, 201]}} 471 | second = {"a": {"b": [100, 101, 202]}} 472 | result = list(diff(first, second)) 473 | assert len(result) == 1 474 | assert result == [('change', ['a', 'b', 2], (201, 202))] 475 | 476 | def test_list_same(self): 477 | """Diff for the same list should be empty.""" 478 | first = {1: [1]} 479 | assert len(list(diff(first, first))) == 0 480 | 481 | @unittest.skipIf(not HAS_NUMPY, 'NumPy is not installed') 482 | def test_numpy_array(self): 483 | """Compare NumPy arrays (#68).""" 484 | import numpy as np 485 | first = np.array([1, 2, 3]) 486 | second = np.array([1, 2, 4]) 487 | result = list(diff(first, second)) 488 | assert result == [('change', [2], (3, 4))] 489 | 490 | def test_dict_subclasses(self): 491 | class Foo(dict): 492 | pass 493 | 494 | first = Foo({2014: [ 495 | dict(month=6, category=None, sum=672.00), 496 | dict(month=6, category=1, sum=-8954.00), 497 | dict(month=7, category=None, sum=7475.17), 498 | dict(month=7, category=1, sum=-11745.00), 499 | dict(month=8, category=None, sum=-12140.00), 500 | dict(month=8, category=1, sum=-11812.00), 501 | dict(month=9, category=None, sum=-31719.41), 502 | dict(month=9, category=1, sum=-11663.00), 503 | ]}) 504 | 505 | second = Foo({2014: [ 506 | dict(month=6, category=None, sum=672.00), 507 | dict(month=6, category=1, sum=-8954.00), 508 | dict(month=7, category=None, sum=7475.17), 509 | dict(month=7, category=1, sum=-11745.00), 510 | dict(month=8, category=None, sum=-12141.00), 511 | dict(month=8, category=1, sum=-11812.00), 512 | dict(month=9, category=None, sum=-31719.41), 513 | dict(month=9, category=2, sum=-11663.00), 514 | ]}) 515 | 516 | diffed = next(diff(first, second)) 517 | assert ('change', [2014, 4, 'sum'], (-12140.0, -12141.0)) == diffed 518 | 519 | def test_collection_subclasses(self): 520 | class DictA(MutableMapping): 521 | 522 | def __init__(self, *args, **kwargs): 523 | self.__dict__.update(*args, **kwargs) 524 | 525 | def __setitem__(self, key, value): 526 | self.__dict__[key] = value 527 | 528 | def __getitem__(self, key): 529 | return self.__dict__[key] 530 | 531 | def __delitem__(self, key): 532 | del self.__dict__[key] 533 | 534 | def __iter__(self): 535 | return iter(self.__dict__) 536 | 537 | def __len__(self): 538 | return len(self.__dict__) 539 | 540 | class DictB(MutableMapping): 541 | 542 | def __init__(self, *args, **kwargs): 543 | self.__dict__.update(*args, **kwargs) 544 | 545 | def __setitem__(self, key, value): 546 | self.__dict__[key] = value 547 | 548 | def __getitem__(self, key): 549 | return self.__dict__[key] 550 | 551 | def __delitem__(self, key): 552 | del self.__dict__[key] 553 | 554 | def __iter__(self): 555 | return iter(self.__dict__) 556 | 557 | def __len__(self): 558 | return len(self.__dict__) 559 | 560 | class ListA(MutableSequence): 561 | 562 | def __init__(self, *args, **kwargs): 563 | self._list = list(*args, **kwargs) 564 | 565 | def __getitem__(self, index): 566 | return self._list[index] 567 | 568 | def __setitem__(self, index, value): 569 | self._list[index] = value 570 | 571 | def __delitem__(self, index): 572 | del self._list[index] 573 | 574 | def __iter__(self): 575 | for value in self._list: 576 | yield value 577 | 578 | def __len__(self): 579 | return len(self._list) 580 | 581 | def insert(self, index, value): 582 | self._list.insert(index, value) 583 | 584 | daa = DictA(a=ListA(['a', 'A'])) 585 | dba = DictB(a=ListA(['a', 'A'])) 586 | dbb = DictB(a=ListA(['b', 'A'])) 587 | assert list(diff(daa, dba)) == [] 588 | assert list(diff(daa, dbb)) == [('change', ['a', 0], ('a', 'b'))] 589 | assert list(diff(dba, dbb)) == [('change', ['a', 0], ('a', 'b'))] 590 | 591 | 592 | class DiffPatcherTests(unittest.TestCase): 593 | def test_addition(self): 594 | first = {} 595 | second = {'a': 'b'} 596 | assert second == patch( 597 | [('add', '', [('a', 'b')])], first) 598 | 599 | first = {'a': {'b': 'c'}} 600 | second = {'a': {'b': 'c', 'd': 'e'}} 601 | assert second == patch( 602 | [('add', 'a', [('d', 'e')])], first) 603 | 604 | def test_changes(self): 605 | first = {'a': 'b'} 606 | second = {'a': 'c'} 607 | assert second == patch( 608 | [('change', 'a', ('b', 'c'))], first) 609 | 610 | first = {'a': {'b': {'c': 'd'}}} 611 | second = {'a': {'b': {'c': 'e'}}} 612 | assert second == patch( 613 | [('change', 'a.b.c', ('d', 'e'))], first) 614 | 615 | def test_remove(self): 616 | first = {'a': {'b': 'c'}} 617 | second = {'a': {}} 618 | assert second == patch( 619 | [('remove', 'a', [('b', 'c')])], first) 620 | 621 | first = {'a': 'b'} 622 | second = {} 623 | assert second == patch( 624 | [('remove', '', [('a', 'b')])], first) 625 | 626 | def test_remove_list(self): 627 | first = {'a': [1, 2, 3]} 628 | second = {'a': [1, ]} 629 | assert second == patch( 630 | [('remove', 'a', [(2, 3), (1, 2), ]), ], first) 631 | 632 | def test_add_list(self): 633 | first = {'a': [1]} 634 | second = {'a': [1, 2]} 635 | assert second == patch( 636 | [('add', 'a', [(1, 2)])], first) 637 | 638 | first = {'a': {'b': [1]}} 639 | second = {'a': {'b': [1, 2]}} 640 | assert second == patch( 641 | [('add', 'a.b', [(1, 2)])], first) 642 | 643 | def test_change_list(self): 644 | first = {'a': ['b']} 645 | second = {'a': ['c']} 646 | assert second == patch( 647 | [('change', 'a.0', ('b', 'c'))], first) 648 | 649 | first = {'a': {'b': {'c': ['d']}}} 650 | second = {'a': {'b': {'c': ['e']}}} 651 | assert second == patch( 652 | [('change', 'a.b.c.0', ('d', 'e'))], first) 653 | 654 | first = {'a': {'b': {'c': [{'d': 'e'}]}}} 655 | second = {'a': {'b': {'c': [{'d': 'f'}]}}} 656 | assert second == patch( 657 | [('change', 'a.b.c.0.d', ('e', 'f'))], first) 658 | 659 | def test_remove_set(self): 660 | first = {'a': set([1, 2, 3])} 661 | second = {'a': set([1])} 662 | assert second == patch( 663 | [('remove', 'a', [(0, set([2, 3]))])], first) 664 | 665 | def test_add_set(self): 666 | first = {'a': set([1])} 667 | second = {'a': set([1, 2])} 668 | assert second == patch( 669 | [('add', 'a', [(0, set([2]))])], first) 670 | 671 | def test_dict_int_key(self): 672 | first = {0: 0} 673 | second = {0: 'a'} 674 | first_patch = [('change', [0], (0, 'a'))] 675 | assert second == patch(first_patch, first) 676 | 677 | def test_dict_combined_key_type(self): 678 | first = {0: {'1': {2: 3}}} 679 | second = {0: {'1': {2: '3'}}} 680 | first_patch = [('change', [0, '1', 2], (3, '3'))] 681 | assert second == patch(first_patch, first) 682 | assert first_patch[0] == list(diff(first, second))[0] 683 | 684 | def test_in_place_patch_and_revert(self): 685 | first = {'a': 1} 686 | second = {'a': 2} 687 | changes = list(diff(first, second)) 688 | patched_copy = patch(changes, first) 689 | assert first != patched_copy 690 | reverted_in_place = revert(changes, patched_copy, in_place=True) 691 | assert first == reverted_in_place 692 | assert patched_copy == reverted_in_place 693 | patched_in_place = patch(changes, first, in_place=True) 694 | assert first == patched_in_place 695 | 696 | 697 | class SwapperTests(unittest.TestCase): 698 | def test_addition(self): 699 | result = 'add', '', [('a', 'b')] 700 | swapped = 'remove', '', [('a', 'b')] 701 | assert next(swap([result])) == swapped 702 | 703 | result = 'remove', 'a.b', [('c', 'd')] 704 | swapped = 'add', 'a.b', [('c', 'd')] 705 | assert next(swap([result])) == swapped 706 | 707 | def test_changes(self): 708 | result = 'change', '', ('a', 'b') 709 | swapped = 'change', '', ('b', 'a') 710 | assert next(swap([result])) == swapped 711 | 712 | def test_revert(self): 713 | first = {'a': [1, 2]} 714 | second = {'a': []} 715 | diffed = diff(first, second) 716 | patched = patch(diffed, first) 717 | assert patched == second 718 | diffed = diff(first, second) 719 | reverted = revert(diffed, second) 720 | assert reverted == first 721 | 722 | def test_list_of_different_length(self): 723 | """Check that one can revert list with different length.""" 724 | first = [1] 725 | second = [1, 2, 3] 726 | result = list(diff(first, second)) 727 | assert first == revert(result, second) 728 | 729 | 730 | class DotLookupTest(unittest.TestCase): 731 | def test_list_lookup(self): 732 | source = {0: '0'} 733 | assert dot_lookup(source, [0]) == '0' 734 | 735 | def test_invalit_lookup_type(self): 736 | self.assertRaises(TypeError, dot_lookup, {0: '0'}, 0) 737 | 738 | 739 | @pytest.mark.parametrize( 740 | 'ignore,dot_notation,diff_size', [ 741 | (u'nifi.zookeeper.session.timeout', True, 1), 742 | (u'nifi.zookeeper.session.timeout', False, 0), 743 | ((u'nifi.zookeeper.session.timeout', ), True, 0), 744 | ((u'nifi.zookeeper.session.timeout', ), False, 0), 745 | ], 746 | ) 747 | def test_ignore_dotted_ignore_key(ignore, dot_notation, diff_size): 748 | key_to_ignore = u'nifi.zookeeper.session.timeout' 749 | config_dict = OrderedDict( 750 | [('address', 'devops011-slv-01.gvs.ggn'), 751 | (key_to_ignore, '3 secs')]) 752 | 753 | ref_dict = OrderedDict( 754 | [('address', 'devops011-slv-01.gvs.ggn'), 755 | (key_to_ignore, '4 secs')]) 756 | 757 | assert diff_size == len( 758 | list(diff(config_dict, ref_dict, 759 | dot_notation=dot_notation, 760 | ignore=[ignore]))) 761 | 762 | 763 | if __name__ == "__main__": 764 | unittest.main() 765 | -------------------------------------------------------------------------------- /tests/test_merge.py: -------------------------------------------------------------------------------- 1 | # This file is part of Dictdiffer. 2 | # 3 | # Copyright (C) 2015 CERN. 4 | # 5 | # Dictdiffer is free software; you can redistribute it and/or modify 6 | # it under the terms of the MIT License; see LICENSE file for more 7 | # details. 8 | 9 | import unittest 10 | 11 | from dictdiffer import patch 12 | from dictdiffer.merge import Merger, UnresolvedConflictsException 13 | 14 | 15 | class MergerTest(unittest.TestCase): 16 | def test_run(self): 17 | lca = {'changeme': 'Jo'} 18 | first = {'changeme': 'Joe'} 19 | second = {'changeme': 'John'} 20 | 21 | m = Merger(lca, first, second, {}) 22 | 23 | self.assertRaises(UnresolvedConflictsException, m.run) 24 | 25 | def test_continue_run(self): 26 | def take_first(conflict, _, __, ___): 27 | conflict.take = [('f', x) for x 28 | in range(len(conflict.first_patch.patches))] 29 | return True 30 | 31 | lca = {'changeme': 'Jo'} 32 | first = {'changeme': 'Joe'} 33 | second = {'changeme': 'John'} 34 | 35 | m = Merger(lca, first, second, {}) 36 | 37 | try: 38 | m.run() 39 | except UnresolvedConflictsException: 40 | pass 41 | 42 | m.continue_run(['f']) 43 | 44 | self.assertEqual(m.unified_patches, 45 | [('change', 'changeme', ('Jo', 'Joe'))]) 46 | 47 | def test_continue_run_multiple_conflicts_per_patch(self): 48 | lca = {'foo': [{'x': 1}, {'y': 2}]} 49 | first = {'foo': [{'x': 1}, {'y': 2}, {'z': 4}]} 50 | second = {'bar': 'baz'} 51 | 52 | expected = { 53 | 'f': {'foo': [{'x': 1}, {'y': 2}, {'z': 4}], 54 | 'bar': 'baz'}, 55 | 's': {'bar': 'baz'}} 56 | 57 | for resolution, expected_value in expected.items(): 58 | m = Merger(lca, first, second, {}) 59 | try: 60 | m.run() 61 | except UnresolvedConflictsException as e: 62 | m.continue_run([resolution for _ in e.content]) 63 | 64 | self.assertEqual(patch(m.unified_patches, lca), 65 | expected_value) 66 | 67 | def test_run_with_ignore(self): 68 | lca = {'changeme': 'Jo', 'ignore': 'Something'} 69 | first = {'changeme': 'Joe', 'ignore': 'Nothing'} 70 | second = {'changeme': 'Jo', 'ignore': ''} 71 | 72 | m = Merger(lca, first, second, {}, ignore={'ignore'}) 73 | 74 | try: 75 | m.run() 76 | except UnresolvedConflictsException: 77 | self.fail('UnresolvedConflictsException should not be raised') 78 | 79 | 80 | if __name__ == '__main__': 81 | unittest.main() 82 | -------------------------------------------------------------------------------- /tests/test_resolve.py: -------------------------------------------------------------------------------- 1 | # This file is part of Dictdiffer. 2 | # 3 | # Copyright (C) 2015 CERN. 4 | # 5 | # Dictdiffer is free software; you can redistribute it and/or modify 6 | # it under the terms of the MIT License; see LICENSE file for more 7 | # details. 8 | 9 | import unittest 10 | 11 | from dictdiffer.conflict import Conflict 12 | from dictdiffer.resolve import (NoFurtherResolutionException, Resolver, 13 | UnresolvedConflictsException) 14 | 15 | 16 | class UnresolvedConflictsExceptionTest(unittest.TestCase): 17 | def test_content(self): 18 | e = UnresolvedConflictsException(None) 19 | self.assertEqual(None, e.content) 20 | 21 | def test_message(self): 22 | e = UnresolvedConflictsException(None) 23 | m = ("The unresolved conflicts are stored in the *content* " 24 | "attribute of this exception or in the " 25 | "*unresolved_conflicts* attribute of the " 26 | "dictdiffer.merge.Merger object.") 27 | 28 | self.assertEqual(m, str(e)) 29 | self.assertEqual(m, e.__repr__()) 30 | self.assertEqual(m, e.__str__()) 31 | 32 | 33 | class ResolverTest(unittest.TestCase): 34 | def test_init(self): 35 | # Very basic 36 | r = Resolver({}) 37 | 38 | self.assertEqual(r.actions, {}) 39 | self.assertEqual(r.additional_info, None) 40 | self.assertEqual(r.unresolved_conflicts, []) 41 | 42 | # With additional_info 43 | r = Resolver({}, {}) 44 | 45 | self.assertEqual(r.actions, {}) 46 | self.assertEqual(r.additional_info, {}) 47 | self.assertEqual(r.unresolved_conflicts, []) 48 | 49 | def test_auto_resolve(self): 50 | r = Resolver({}) 51 | # Sucessful 52 | p1 = ('add', 'foo', [(0, 0)]) 53 | p2 = ('add', 'foo', [(0, 0)]) 54 | c = Conflict(p1, p2) 55 | 56 | self.assertTrue(r._auto_resolve(c)) 57 | self.assertEqual(c.take, 'f') 58 | 59 | # Fail 60 | p1 = ('add', 'foo', [(0, 0)]) 61 | p2 = ('add', 'foo', [(0, 1)]) 62 | c = Conflict(p1, p2) 63 | 64 | self.assertFalse(r._auto_resolve(c)) 65 | 66 | def test_find_conflicting_path(self): 67 | r = Resolver({}) 68 | 69 | # A = shortest 70 | p1 = ('delete', '', [('foo', [])]) 71 | p2 = ('add', 'foo', [(0, 0)]) 72 | c = Conflict(p1, p2) 73 | 74 | self.assertEqual(r._find_conflicting_path(c), ('foo',)) 75 | 76 | # Same 77 | p1 = ('add', 'foo', [(0, 0)]) 78 | p2 = ('add', 'foo', [(0, 0)]) 79 | c = Conflict(p1, p2) 80 | 81 | self.assertEqual(r._find_conflicting_path(c), ('foo', 0)) 82 | 83 | # B = shortest 84 | p1 = ('add', 'foo', [(0, 0)]) 85 | p2 = ('delete', '', [('foo', [])]) 86 | c = Conflict(p1, p2) 87 | 88 | self.assertEqual(r._find_conflicting_path(c), ('foo',)) 89 | 90 | def test_consecutive_slices(self): 91 | r = Resolver({}) 92 | 93 | slices = [['foo', 'bar', 'apple', 'banana'], ['foo', 'bar', 'apple'], 94 | ['foo', 'bar'], ['foo']] 95 | 96 | self.assertEqual(list(r._consecutive_slices(['foo', 97 | 'bar', 98 | 'apple', 99 | 'banana'])), slices) 100 | 101 | def test_resolve_conflicts(self): 102 | p1 = ('add', 'foo', [(0, 0)]) 103 | p2 = ('add', 'foo', [(0, 1)]) 104 | c = [Conflict(p1, p2)] 105 | 106 | # KeyError 107 | r = Resolver({}) 108 | 109 | self.assertRaises(UnresolvedConflictsException, 110 | r.resolve_conflicts, [p1], [p2], c) 111 | 112 | # Failing action 113 | r = Resolver({('foo', 0): lambda *args: False}) 114 | 115 | self.assertRaises(UnresolvedConflictsException, 116 | r.resolve_conflicts, [p1], [p2], c) 117 | 118 | # No further resolution exception 119 | def no_further(*args): 120 | raise NoFurtherResolutionException 121 | 122 | r = Resolver({('foo', 0): no_further}) 123 | self.assertRaises(UnresolvedConflictsException, 124 | r.resolve_conflicts, [p1], [p2], c) 125 | 126 | # Succesful 127 | r = Resolver({('foo', 0): lambda *args: True}) 128 | r.resolve_conflicts([p1], [p2], c) 129 | 130 | self.assertEqual(r.unresolved_conflicts, []) 131 | 132 | # Succesful auto resolve 133 | p1 = ('add', 'foo', [(0, 0)]) 134 | p2 = ('add', 'foo', [(0, 0)]) 135 | c = [Conflict(p1, p2)] 136 | 137 | r = Resolver({}) 138 | r.resolve_conflicts([p1], [p2], c) 139 | 140 | self.assertEqual(r.unresolved_conflicts, []) 141 | 142 | def test_manual_resolve_conflicts(self): 143 | p1 = ('add', 'foo', [(0, 0)]) 144 | p2 = ('add', 'foo', [(0, 0)]) 145 | c = Conflict(p1, p2) 146 | 147 | r = Resolver({}) 148 | r.unresolved_conflicts.append(c) 149 | 150 | r.manual_resolve_conflicts(['s']) 151 | 152 | self.assertEqual(c.take, 's') 153 | 154 | # Raise 155 | r = Resolver({}) 156 | r.unresolved_conflicts.append(c) 157 | 158 | self.assertRaises(UnresolvedConflictsException, 159 | r.manual_resolve_conflicts, 160 | []) 161 | -------------------------------------------------------------------------------- /tests/test_testing.py: -------------------------------------------------------------------------------- 1 | # This file is part of Dictdiffer. 2 | # 3 | # Copyright (C) 2021 CERN. 4 | # 5 | # Dictdiffer is free software; you can redistribute it and/or modify 6 | # it under the terms of the MIT License; see LICENSE file for more 7 | # details. 8 | 9 | import unittest 10 | 11 | import pytest 12 | 13 | from dictdiffer.testing import assert_no_diff 14 | 15 | 16 | class AssertNoDiffTest(unittest.TestCase): 17 | def test_passes(self): 18 | dict1 = {1: '1'} 19 | assert_no_diff(dict1, dict1) 20 | 21 | def test_raises_assertion_error(self): 22 | dict1 = {1: '1'} 23 | dict2 = {2: '2'} 24 | with pytest.raises(AssertionError): 25 | assert_no_diff(dict1, dict2) 26 | -------------------------------------------------------------------------------- /tests/test_unify.py: -------------------------------------------------------------------------------- 1 | # This file is part of Dictdiffer. 2 | # 3 | # Copyright (C) 2015 CERN. 4 | # 5 | # Dictdiffer is free software; you can redistribute it and/or modify 6 | # it under the terms of the MIT License; see LICENSE file for more 7 | # details. 8 | 9 | import unittest 10 | 11 | from dictdiffer import patch 12 | from dictdiffer.conflict import Conflict 13 | from dictdiffer.merge import Merger 14 | from dictdiffer.unify import Unifier 15 | from dictdiffer.utils import WildcardDict, nested_hash 16 | 17 | 18 | class TestUnifier(unittest.TestCase): 19 | 20 | def test_build_index(self): 21 | u = Unifier() 22 | 23 | p1 = ('add', 'foo', [(0, 0)]) 24 | p2 = ('add', 'foo', [(0, 1)]) 25 | c = Conflict(p1, p2) 26 | 27 | u._build_index([c]) 28 | 29 | self.assertEqual(u._index[nested_hash(p1)], c) 30 | self.assertEqual(u._index[nested_hash(p2)], c) 31 | 32 | def test_unify(self): 33 | u = Unifier() 34 | 35 | p1 = ('add', 'foo', [(0, 0)]) 36 | p2 = ('add', 'foo', [(0, 1)]) 37 | c = Conflict(p1, p2) 38 | c.take = 'f' 39 | 40 | u.unify([p1], [p2], [c]) 41 | 42 | self.assertEqual(u.unified_patches, [p1]) 43 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # This file is part of Dictdiffer. 2 | # 3 | # Copyright (C) 2015 CERN. 4 | # 5 | # Dictdiffer is free software; you can redistribute it and/or modify 6 | # it under the terms of the MIT License; see LICENSE file for more 7 | # details. 8 | 9 | import unittest 10 | 11 | from dictdiffer.utils import (PathLimit, WildcardDict, create_dotted_node, 12 | dot_lookup, get_path, is_super_path, nested_hash) 13 | 14 | 15 | class UtilsTest(unittest.TestCase): 16 | def test_wildcarddict(self): 17 | wd = WildcardDict() 18 | 19 | wd[('authors', '*')] = True 20 | 21 | self.assertRaises(KeyError, wd.__getitem__, ('authors',)) 22 | self.assertTrue(wd[('authors', 1)]) 23 | self.assertTrue(wd[('authors', 1, 'name')]) 24 | self.assertTrue(wd[('authors', 1, 'affiliation')]) 25 | 26 | del wd[('authors', '*')] 27 | 28 | wd[('authors', '+')] = True 29 | 30 | self.assertRaises(KeyError, wd.__getitem__, ('authors',)) 31 | self.assertTrue(wd[('authors', 1)]) 32 | self.assertRaises(KeyError, wd.__getitem__, ('authors', 1, 'name')) 33 | self.assertRaises(KeyError, wd.__getitem__, ('authors', 1, 34 | 'affiliation')) 35 | 36 | del wd[('authors', '+')] 37 | 38 | wd[('foo', 'bar')] = True 39 | 40 | self.assertRaises(KeyError, wd.__getitem__, ('foo',)) 41 | self.assertTrue(wd[('foo', 'bar')]) 42 | self.assertRaises(KeyError, wd.__getitem__, ('foo', 'bar', 'banana')) 43 | 44 | # query_path part 45 | wd = WildcardDict() 46 | wd[('authors', '*')] = True 47 | wd[('apple', '+')] = True 48 | wd[('foo', 'bar')] = True 49 | 50 | self.assertRaises(KeyError, wd.query_path, ('utz',)) 51 | self.assertRaises(KeyError, wd.query_path, ('foo',)) 52 | self.assertRaises(KeyError, wd.query_path, ('bar',)) 53 | self.assertRaises(KeyError, wd.query_path, ('apple', 'banana', 54 | 'mango')) 55 | self.assertEqual(('authors', '*'), wd.query_path(('authors', 1))) 56 | self.assertEqual(('authors', '*'), wd.query_path(('authors', 1, 1))) 57 | self.assertEqual(('authors', '*'), wd.query_path(('authors', 1, 1, 1))) 58 | self.assertEqual(('apple', '+'), wd.query_path(('apple', 'banana'))) 59 | self.assertEqual(('apple', '+'), wd.query_path(('apple', 'mango'))) 60 | self.assertEqual(('foo', 'bar'), wd.query_path(('foo', 'bar'))) 61 | 62 | def test_pathlimit(self): 63 | path_limit = PathLimit([('author', 'name')]) 64 | self.assertFalse(path_limit.path_is_limit(('author'))) 65 | self.assertTrue(path_limit.path_is_limit(('author', 'name'))) 66 | self.assertFalse(path_limit.path_is_limit(('author', 'name', 'foo'))) 67 | 68 | path_limit = PathLimit([('authors', '*')]) 69 | self.assertFalse(path_limit.path_is_limit(('authors'))) 70 | self.assertTrue(path_limit.path_is_limit(('authors', 'name'))) 71 | self.assertTrue(path_limit.path_is_limit(('authors', 1))) 72 | self.assertTrue(path_limit.path_is_limit(('authors', 2))) 73 | self.assertFalse(path_limit.path_is_limit(('authors', 'name', 'foo'))) 74 | 75 | def test_create_dotted_node(self): 76 | node = ('foo', 'bar') 77 | self.assertEqual('foo.bar', create_dotted_node(node)) 78 | 79 | node = ('foo', 1) 80 | self.assertEqual(['foo', 1], create_dotted_node(node)) 81 | 82 | node = ('foo', 1, 'bar') 83 | self.assertEqual(['foo', 1, 'bar'], create_dotted_node(node)) 84 | 85 | def test_get_path(self): 86 | patch = ('add/delete', '', [('author', 'Bob')]) 87 | self.assertEqual(('author',), get_path(patch)) 88 | patch = ('add/delete', 'authors', [('name', 'Bob')]) 89 | self.assertEqual(('authors', 'name'), get_path(patch)) 90 | patch = ('add/delete', 'foo.bar', [('name', 'Bob')]) 91 | self.assertEqual(('foo', 'bar', 'name'), get_path(patch)) 92 | patch = ('add/delete', ['foo', 1], [('name', 'Bob')]) 93 | self.assertEqual(('foo', 1, 'name'), get_path(patch)) 94 | 95 | patch = ('change', 'foo', [('John', 'Bob')]) 96 | self.assertEqual(('foo',), get_path(patch)) 97 | patch = ('change', 'foo.bar', [('John', 'Bob')]) 98 | self.assertEqual(('foo', 'bar'), get_path(patch)) 99 | patch = ('change', ['foo', 'bar'], [('John', 'Bob')]) 100 | self.assertEqual(('foo', 'bar'), get_path(patch)) 101 | patch = ('change', ['foo', 1], [('John', 'Bob')]) 102 | self.assertEqual(('foo', 1), get_path(patch)) 103 | 104 | def test_is_super_path(self): 105 | # # True 106 | path1 = ('authors', 1, 'name') 107 | path2 = ('authors', 1, 'name') 108 | self.assertTrue(is_super_path(path1, path2)) 109 | 110 | path1 = ('authors', 1) 111 | path2 = ('authors', 1, 'name') 112 | self.assertTrue(is_super_path(path1, path2)) 113 | 114 | path1 = ('authors',) 115 | path2 = ('authors', 1, 'name') 116 | self.assertTrue(is_super_path(path1, path2)) 117 | 118 | # # False 119 | path1 = ('authors', 1, 'name') 120 | path2 = ('authors', 1, 'surname') 121 | self.assertFalse(is_super_path(path1, path2)) 122 | 123 | path1 = ('authors', 2) 124 | path2 = ('authors', 1, 'surname') 125 | self.assertFalse(is_super_path(path1, path2)) 126 | 127 | path1 = ('author',) 128 | path2 = ('authors', 1, 'surname') 129 | self.assertFalse(is_super_path(path1, path2)) 130 | 131 | def test_dot_lookup(self): 132 | self.assertEqual(dot_lookup({'a': {'b': 'hello'}}, 'a.b'), 'hello') 133 | self.assertEqual(dot_lookup({'a': {'b': 'hello'}}, ['a', 'b']), 134 | 'hello') 135 | 136 | self.assertEqual(dot_lookup({'a': {'b': 'hello'}}, 'a.b', parent=True), 137 | {'b': 'hello'}) 138 | self.assertEqual(dot_lookup({'a': {'b': 'hello'}}, ''), 139 | {'a': {'b': 'hello'}}) 140 | 141 | def test_nested_hash(self): 142 | # No reasonable way to test this 143 | nested_hash([1, 2, 3]) 144 | nested_hash((1, 2, 3)) 145 | nested_hash(set([1, 2, 3])) 146 | nested_hash({'foo': 'bar'}) 147 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # This file is part of Dictdiffer. 2 | # 3 | # Copyright (C) 2014 CERN. 4 | # Copyright (C) 2017 ETH Zurich, Swiss Data Science Center, Jiri Kuncar. 5 | # 6 | # Dictdiffer is free software; you can redistribute it and/or modify 7 | # it under the terms of the MIT License; see LICENSE file for more 8 | # details. 9 | 10 | [tox] 11 | envlist = py35, py36, py37, py38, py39 12 | 13 | [testenv] 14 | extras = numpy, tests 15 | commands = 16 | {envpython} -m check_manifest --ignore ".*-requirements.txt" 17 | {envpython} -m sphinx.cmd.build -qnNW docs docs/_build/html 18 | {envpython} setup.py test 19 | {envpython} -m sphinx.cmd.build -qnNW -b doctest docs docs/_build/doctest 20 | --------------------------------------------------------------------------------