diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..6647631 --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] +# The GitHub editor is 127 chars wide +max-line-length = 127 +exclude = .git,__pycache__,build,dist,doc/_build diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 848c4ce..0161148 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -23,11 +23,8 @@ jobs: python -m pip install --upgrade pip pip install flake8 - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + # configured in .flake8 + run: flake8 . --count --show-source --statistics build: name: "Build and check distributions" diff --git a/MANIFEST.in b/MANIFEST.in index e5a5b30..b437c22 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,8 +1,13 @@ include requirements.txt +include requirements-dev.txt include LICENSE include README.md include tests.py include doc/*.rst +include doc/conf.py +include doc/Makefile +include doc/requirements.txt +include makefile include property_tests.py include pytest.ini include ext_tests.py diff --git a/README.md b/README.md index 42deae1..72d6779 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ python-json-patch ================= -[![PyPI version](https://img.shields.io/pypi/v/jsonpatch.svg)](https://pypi.org/project/jsonpatch//) -[![Supported Python versions](https://img.shields.io/pypi/pyversions/jsonpatch.svg)](https://pypi.org/project/jsonpatch//) +[![PyPI version](https://img.shields.io/pypi/v/jsonpatch.svg)](https://pypi.org/project/jsonpatch/) +[![Supported Python versions](https://img.shields.io/pypi/pyversions/jsonpatch.svg)](https://pypi.org/project/jsonpatch/) [![Build Status](https://github.com/stefankoegl/python-json-patch/actions/workflows/test.yaml/badge.svg?branch=master)](https://github.com/stefankoegl/python-json-patch/actions/workflows/test.yaml) [![Coverage Status](https://coveralls.io/repos/github/stefankoegl/python-json-patch/badge.svg?branch=master)](https://coveralls.io/github/stefankoegl/python-json-patch?branch=master) @@ -12,11 +12,12 @@ Applying JSON Patches in Python Library to apply JSON Patches according to [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902) -See source code for examples +See the [tutorial](https://python-json-patch.readthedocs.io/en/latest/tutorial.html) +for examples. * Website: https://github.com/stefankoegl/python-json-patch * Repository: https://github.com/stefankoegl/python-json-patch.git -* Documentation: https://python-json-patch.readthedocs.org/ +* Documentation: https://python-json-patch.readthedocs.io/ * PyPI: https://pypi.org/project/jsonpatch/ * CI: https://github.com/stefankoegl/python-json-patch/actions * Coveralls: https://coveralls.io/github/stefankoegl/python-json-patch @@ -28,7 +29,8 @@ non-RFC string `append` operation. Its `from_diff()` method sends only the new suffix when an object member's string grows. `JsonPatch`, `make_patch()` and `apply_patch()` retain their standard RFC 6902 behavior. -See the [tutorial](doc/tutorial.rst#string-append-extension) for examples. +See the [tutorial](https://python-json-patch.readthedocs.io/en/latest/tutorial.html#string-append-extension) +for examples. Both sender and receiver must support the extension. Type hints @@ -45,6 +47,11 @@ typical usage in `typetests/usage.py` type-checks: python -m mypy.stubtest --mypy-config-file pyproject.toml jsonpatch cd typetests && mypy --config-file ../pyproject.toml usage.py +Running tests +------------- + pip install -r requirements.txt -r requirements-dev.txt + python tests.py + Running external tests ---------------------- To run external tests (such as those from https://github.com/json-patch/json-patch-tests) use ext_tests.py @@ -54,8 +61,7 @@ To run external tests (such as those from https://github.com/json-patch/json-pat Running property-based tests ---------------------------- Property-based tests using [Hypothesis](https://hypothesis.readthedocs.io/) -are in property_tests.py. Properties that the implementation does not satisfy -yet are marked as expected failures. +are in property_tests.py. pip install hypothesis python property_tests.py diff --git a/doc/_static/.empty b/doc/_static/.empty deleted file mode 100644 index e69de29..0000000 diff --git a/doc/conf.py b/doc/conf.py index 8cb3b38..3108d5f 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -11,19 +11,20 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys, os +import sys +import os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.join(os.path.abspath('.'), '..')) -import jsonpatch +import jsonpatch # noqa: E402, needs the path set above # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. @@ -34,20 +35,20 @@ nitpick_ignore = [('py:class', 'JsonPointer')] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = [] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. -project = u'python-json-patch' -copyright = jsonpatch.__author__ +project = 'python-json-patch' +copyright = '2011, Stefan Kögl' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -61,37 +62,37 @@ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['_build'] # The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- @@ -103,72 +104,72 @@ # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'python-json-patchdoc' @@ -177,42 +178,42 @@ # -- Options for LaTeX output -------------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', + # The paper size ('letterpaper' or 'a4paper'). + # 'papersize': 'letterpaper', -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', + # The font size ('10pt', '11pt' or '12pt'). + # 'pointsize': '10pt', -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # Additional stuff for the LaTeX preamble. + # 'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'python-json-patch.tex', u'python-json-patch Documentation', - u'Stefan Kögl', 'manual'), + ('index', 'python-json-patch.tex', 'python-json-patch Documentation', + 'Stefan Kögl', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output -------------------------------------------- @@ -220,12 +221,12 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'python-json-patch', u'python-json-patch Documentation', - [u'Stefan Kögl'], 1) + ('index', 'python-json-patch', 'python-json-patch Documentation', + ['Stefan Kögl'], 1) ] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ @@ -234,16 +235,16 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'python-json-patch', u'python-json-patch Documentation', - u'Stefan Kögl', 'python-json-patch', 'One line description of project.', + ('index', 'python-json-patch', 'python-json-patch Documentation', + 'Stefan Kögl', 'python-json-patch', 'Apply JSON Patches (RFC 6902)', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' diff --git a/doc/mod-jsonpatch.rst b/doc/mod-jsonpatch.rst index 0a0102b..f84bde4 100644 --- a/doc/mod-jsonpatch.rst +++ b/doc/mod-jsonpatch.rst @@ -1,4 +1,4 @@ -.. mod-jsonpatch: +.. _mod-jsonpatch: The ``jsonpatch`` module ======================== diff --git a/doc/tutorial.rst b/doc/tutorial.rst index b4c14e1..3da2d05 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -7,7 +7,7 @@ patch syntax. Creating a Patch ---------------- -Patches can be created in two ways. One way is to explicitly create a +Patches can be created in two ways. One way is to explicitly create a ``JsonPatch`` object from an iterable of operation dictionaries, such as a list, tuple, iterator, or generator. Non-sequence iterables are materialized before validation so that the patch can be applied repeatedly. @@ -198,7 +198,7 @@ given: Ignoring Missing Locations ~~~~~~~~~~~~~~~~~~~~~~~~~~ -`RFC 6902 `_ requires a +`RFC 6902 `_ requires a ``remove`` to fail if the location it removes doesn't exist, and this library follows it: such an operation raises ``JsonPatchPathNotFound``. A ``replace`` of a missing location, and a ``move`` or ``copy`` from one, raise it as well. @@ -469,7 +469,7 @@ Dealing with Custom Types ------------------------- Custom JSON dump and load functions can be used to support custom types such as -`decimal.Decimal`. The following examples shows how the +``decimal.Decimal``. The following example shows how the `simplejson `_ package, which has native support for Python's ``Decimal`` type, can be used to create a custom ``JsonPatch`` subclass with ``Decimal`` support: @@ -512,7 +512,7 @@ a dumps function to ``to_string``: >>> serialized_patch '[{"op": "add", "path": "/bar", "value": 1.10}]' -and load function to ``from_string``: +and a load function to ``from_string``: .. code-block:: python diff --git a/ext_tests.py b/ext_tests.py index d3115fe..4faca14 100755 --- a/ext_tests.py +++ b/ext_tests.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- # # python-json-patch - An implementation of the JSON Patch format # https://github.com/stefankoegl/python-json-patch @@ -34,7 +33,7 @@ """ Script to run external tests, eg from https://github.com/json-patch/json-patch-tests """ -import doctest +import json import unittest import jsonpatch import sys @@ -44,7 +43,7 @@ class TestCaseTemplate(unittest.TestCase): """ A generic test case for running external tests """ def _test(self, test): - if not 'doc' in test or not 'patch' in test: + if 'doc' not in test or 'patch' not in test: # incomplete return @@ -85,56 +84,18 @@ def add_test_method(cls, name, test): setattr(cls, name, lambda self: self._test(test)) - -modules = ['jsonpatch'] -coverage_modules = [] - - def get_suite(filenames): suite = unittest.TestSuite() for testfile in filenames: - with open(testfile) as f: - # we use the (potentially) patched version of json.load here - tests = jsonpatch.json.load(f) - cls = make_test_case(tests) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(cls)) + with open(testfile, encoding='utf-8') as f: + tests = json.load(f) + cls = make_test_case(tests) + suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(cls)) return suite -suite = get_suite(sys.argv[1:]) - -for module in modules: - m = __import__(module, fromlist=[module]) - coverage_modules.append(m) - suite.addTest(doctest.DocTestSuite(m)) - -runner = unittest.TextTestRunner(verbosity=1) - -try: - import coverage - cov = coverage.Coverage() -except ImportError: - cov = None - -if cov is not None: - cov.erase() - cov.start() - -result = runner.run(suite) - -if not result.wasSuccessful(): - sys.exit(1) - -if cov is not None: - cov.stop() - cov.report(coverage_modules) - cov.erase() - -if cov is None: - sys.stderr.write(""" -No coverage reporting done (Python module "coverage" is missing) -Please install the python-coverage package to get coverage reporting. -""") - sys.stderr.flush() +if __name__ == '__main__': + result = unittest.TextTestRunner(verbosity=1).run(get_suite(sys.argv[1:])) + sys.exit(not result.wasSuccessful()) diff --git a/jsonpatch-stubs/__init__.pyi b/jsonpatch-stubs/__init__.pyi index 6f251cc..f347490 100644 --- a/jsonpatch-stubs/__init__.pyi +++ b/jsonpatch-stubs/__init__.pyi @@ -65,7 +65,6 @@ class PatchOperation: def apply(self, obj: Any) -> Any: ... def __hash__(self) -> int: ... def __eq__(self, other: object) -> bool: ... - def __ne__(self, other: object) -> bool: ... @property def path(self) -> str: ... @property @@ -104,8 +103,6 @@ class JsonPatch: def __iter__(self) -> Iterator[dict[str, Any]]: ... def __hash__(self) -> int: ... def __eq__(self, other: object) -> bool: ... - def __ne__(self, other: object) -> bool: ... - __nonzero__ = __bool__ @classmethod def from_string( cls, diff --git a/jsonpatch.py b/jsonpatch.py index d8de4a0..d1e4e5f 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # python-json-patch - An implementation of the JSON Patch format # https://github.com/stefankoegl/python-json-patch # @@ -78,7 +76,7 @@ def __init__(self, *args, **kwargs): :type op: PatchOperation or dict or None """ self.op = kwargs.pop('op', None) - super(JsonPatchException, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) class InvalidJsonPatch(JsonPatchException): @@ -294,7 +292,7 @@ def path_join(parts, pointer_cls=JsonPointer): return from_parts(parts).path -class _NullJournal(object): +class _NullJournal: """Performs the changes of patch operations without recording them.""" @staticmethod @@ -317,7 +315,7 @@ def append(container, value): _NO_JOURNAL = _NullJournal() -class _Journal(object): +class _Journal: """Performs the changes of patch operations and records how to undo them. Each undo step refers to the changed container itself rather than to a @@ -437,7 +435,7 @@ def _reorder(mapping, keys): mapping[key] = mapping.pop(key) -class PatchOperation(object): +class PatchOperation: """A single operation inside a JSON Patch.""" def __init__(self, operation, pointer_cls=JsonPointer): @@ -499,9 +497,6 @@ def __eq__(self, other): # e.g. tests for 1 and for true behave differently return _json_equal(self.operation, other.operation) - def __ne__(self, other): - return not(self == other) - @property def path(self): """The location of the object or array that contains the target of @@ -625,7 +620,7 @@ def _apply(self, obj, journal): raise InvalidJsonPatch( "The operation does not contain a 'value' member", op=self) - # copied for the same reason as in AddOperation.apply + # copied for the same reason as in AddOperation._apply value = copy.deepcopy(value) subobj, part = self.pointer.to_last(obj) @@ -871,7 +866,7 @@ def _supports_journal(operation): return True -class JsonPatch(object): +class JsonPatch: """A JSON Patch is an iterable of Patch Operations. Accepts sequences, iterators, generators, and other iterables of operation @@ -975,8 +970,6 @@ def __str__(self): def __bool__(self): return bool(self.patch) - __nonzero__ = __bool__ - def __iter__(self): return iter(self.patch) @@ -988,9 +981,6 @@ def __eq__(self, other): return False return self._ops == other._ops - def __ne__(self, other): - return not(self == other) - @classmethod def from_string(cls, patch_str, loads=None, pointer_cls=JsonPointer): """Creates JsonPatch instance from string source. @@ -1187,7 +1177,7 @@ def from_diff(cls, src, dst, optimization=True, dumps=None, return cls(ops, pointer_cls=pointer_cls) -class DiffBuilder(object): +class DiffBuilder: def __init__(self, src_doc, dst_doc, dumps=json.dumps, pointer_cls=JsonPointer, optimization=True, generate_test_ops=False): @@ -1395,9 +1385,9 @@ def _insert_test(self, location, value): """ Inserts a test operation that checks the value at location, if test operations are generated """ if self.generate_test_ops: - # a copy, so that changing src afterwards does not change the test + # like all values, it is copied when the patch is created return self.insert({'op': 'test', 'path': location, - 'value': copy.deepcopy(value)}) + 'value': value}) def _item_replaced(self, path, key, item, old_item): location = self._location(_path_join(path, key)) @@ -1609,7 +1599,7 @@ def _sorted_members(value): # change, but their locations do not. -class _ArrayItem(object): +class _ArrayItem: """ An item that an array has at some point of the diff """ __slots__ = ('array', 'label', 'initial') @@ -1626,7 +1616,7 @@ def index(self): return bisect.bisect_left(self.array.present_labels, self.label) -class _ArrayItems(object): +class _ArrayItems: """ All items that an array has at some point of the diff, and the items that it has at the moment, both ordered by their labels. diff --git a/jsonpatch_cli.py b/jsonpatch_cli.py index f0d5bf2..bd64115 100644 --- a/jsonpatch_cli.py +++ b/jsonpatch_cli.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """ The jsondiff and jsonpatch command line programs """ import sys @@ -26,6 +24,16 @@ version='%(prog)s ' + jsonpatch.__version__) +def _input_files(*streams): + """ Closes the input files when the returned context is left; stdin is + left open, as it is not ours """ + inputs = ExitStack() + for stream in streams: + if stream is not sys.stdin: + inputs.enter_context(stream) + return inputs + + def jsondiff_main(): try: diff_files() @@ -36,15 +44,12 @@ def jsondiff_main(): def diff_files(): """ Diffs two JSON files and prints a patch """ args = jsondiff_parser.parse_args() - with ExitStack() as inputs: - for stream in (args.FILE1, args.FILE2): - if stream is not sys.stdin: - inputs.enter_context(stream) + with _input_files(args.FILE1, args.FILE2): doc1 = json.load(args.FILE1) doc2 = json.load(args.FILE2) patch = jsonpatch.make_patch(doc1, doc2, generate_test_ops=args.test_ops) if patch.patch: - print(json.dumps(patch.patch, indent=args.indent, ensure_ascii=not(args.preserve_unicode))) + print(json.dumps(patch.patch, indent=args.indent, ensure_ascii=not args.preserve_unicode)) sys.exit(1) @@ -67,6 +72,7 @@ def diff_files(): jsonpatch_parser.add_argument('-u', '--preserve-unicode', action='store_true', help='Output Unicode character as-is without using Code Point') + def jsonpatch_main(): try: patch_files() @@ -77,12 +83,13 @@ def jsonpatch_main(): def patch_files(): """ Applies a JSON patch on a JSON file """ args = jsonpatch_parser.parse_args() - doc = json.load(args.ORIGINAL) - result = jsonpatch.apply_patch(doc, args.PATCH.read()) + # The inputs are closed before anything is written, as Windows cannot + # replace ORIGINAL while it is open. + with _input_files(args.ORIGINAL, args.PATCH): + doc = json.load(args.ORIGINAL) + result = jsonpatch.apply_patch(doc, args.PATCH.read()) if args.in_place: - # 2026-10-11: Release the input handle before replacing it on Windows. - args.ORIGINAL.close() dirname = os.path.abspath(os.path.dirname(args.ORIGINAL.name)) fd = None @@ -91,7 +98,7 @@ def patch_files(): # creating a temporary file in the same directory as the # original file so we can atomically move the new file over # the original later. (This is done in the same directory - # because atomic renames do not work across mount points.) + # because atomic renames do not work across mount points.) fd, pathname = tempfile.mkstemp(dir=dirname) fp = os.fdopen(fd, 'w', encoding='utf-8') @@ -124,11 +131,11 @@ def patch_files(): fp = sys.stdout - # By this point we have some sort of file object we can write the + # By this point we have some sort of file object we can write the # modified JSON to. - + try: - json.dump(result, fp, indent=args.indent, ensure_ascii=not(args.preserve_unicode)) + json.dump(result, fp, indent=args.indent, ensure_ascii=not args.preserve_unicode) fp.write('\n') if args.in_place: fp.close() diff --git a/property_tests.py b/property_tests.py index 6d19078..d7e4cee 100644 --- a/property_tests.py +++ b/property_tests.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """ Property-based tests for jsonpatch, using Hypothesis @@ -7,11 +6,8 @@ properties (e.g. "applying make_patch(src, dst) to src yields dst") against many generated documents and patches. -Properties that do not hold yet are marked as expected failures. Each of them -carries the minimal counterexample Hypothesis found as an explicit @example, -so it fails deterministically, and turns into an "unexpected success" (which -fails the run) once the property holds and the marker can be removed. Tests -that must pass meanwhile avoid those inputs and say so. +Inputs on which a property did not hold in the past are kept as explicit +@example, with the issue they were fixed in, so they are checked on each run. Run more examples with HYPOTHESIS_PROFILE=thorough. """ @@ -161,18 +157,6 @@ def json_values(scalars, keys): ) -def safe_json_values(scalars, keys): - """ Documents that avoid the inputs on which make_patch is currently known - to fail (see test_roundtrip): object keys that are '-' """ - return json_values(scalars, keys.filter(lambda key: key != '-')) - - -safe_json_docs = safe_json_values(json_scalars, json_keys) -small_safe_json_docs = safe_json_values( - st.sampled_from([None, True, False, 0, 1, 'a']), - st.sampled_from(['a', 'b', '0', '1'])) - - def pairs_of(*doc_strategies): """ Two documents, both drawn from the same one of doc_strategies """ return st.sampled_from(doc_strategies).flatmap( @@ -180,7 +164,6 @@ def pairs_of(*doc_strategies): doc_pairs = pairs_of(json_docs, small_json_docs) -safe_doc_pairs = pairs_of(safe_json_docs, small_safe_json_docs) @st.composite @@ -344,6 +327,9 @@ def test_diff_of_equal_documents_is_empty(self, doc): @given(doc_pairs) # replace of the object member '-' was rejected, #212 @example(docs=({'-': 0}, {'-': 1})) + # the diff considered e.g. 1 and true equal, #180 + @example(docs=([0], [False])) + @example(docs=({'a': [1]}, {'b': [True]})) def test_roundtrip(self, docs): self.check_roundtrip(*docs) @@ -375,13 +361,6 @@ def test_generate_test_ops(self, docs, optimization): self.assertEqual([(op['op'], op['path']) for op in patch], expected) assert_json_equal(patch.apply(src), dst) - @given(safe_doc_pairs) - # the diff considered e.g. 1 and true equal, #180 - @example(docs=([0], [False])) - @example(docs=({'a': [1]}, {'b': [True]})) - def test_roundtrip_of_safe_documents(self, docs): - self.check_roundtrip(*docs) - @given(doc_pairs) def test_does_not_modify_inputs(self, docs): saved = copy.deepcopy(docs) @@ -389,7 +368,7 @@ def test_does_not_modify_inputs(self, docs): outcome(jsonpatch.make_patch, *docs) assert_json_equal(list(docs), list(saved)) - @given(safe_doc_pairs) + @given(doc_pairs) @example(docs=({}, {'a': []})) def test_result_shares_nothing_with_dst(self, docs): src, dst = docs @@ -595,12 +574,11 @@ class PatchOperationMachine(RuleBasedStateMachine): each result with a direct implementation of RFC 6902. Inputs covered by separate tests in OperationProperties are left - out: the whole document as target or source of an operation, moving a - location into its own children, replacing an object member '-', and - testing values which are equal in Python but not in JSON. """ + out: the whole document as target or source of an operation, and moving + a location into its own children. """ def __init__(self): - super(PatchOperationMachine, self).__init__() + super().__init__() self.initial = {} self.doc = {} self.patch = [] @@ -637,11 +615,7 @@ def remove(self, data): @precondition(lambda self: self.doc) @rule(data=st.data(), value=json_docs) def replace(self, data, value): - candidates = [parts for parts in locations(self.doc) if parts and not ( - isinstance(resolve(self.doc, parts[:-1]), dict) and parts[-1] == '-')] - if not candidates: - return - parts = list(data.draw(st.sampled_from(candidates))) + parts = data.draw(existing_locations(self.doc)) expected = copy.deepcopy(self.doc) resolve(expected, parts[:-1])[parts[-1]] = copy.deepcopy(value) self.apply({'op': 'replace', 'path': to_pointer(parts), @@ -682,7 +656,7 @@ def test_equal(self, data): @rule(data=st.data(), value=json_docs) def test_different(self, data, value): parts = data.draw(st.sampled_from(list(locations(self.doc)))) - if value == resolve(self.doc, parts): + if json_equal(value, resolve(self.doc, parts)): return operation = {'op': 'test', 'path': to_pointer(parts), 'value': value} result = outcome(jsonpatch.apply_patch, self.doc, [operation]) diff --git a/requirements-dev.txt b/requirements-dev.txt index bda790f..2320ebe 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,3 @@ coverage -wheel hypothesis simplejson diff --git a/setup.py b/setup.py index e365115..52c3406 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - import re from setuptools import setup -src = open('jsonpatch.py', encoding='utf-8').read() +with open('jsonpatch.py', encoding='utf-8') as f: + src = f.read() metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", src)) docstrings = re.findall('"""([^"]*)"""', src, re.MULTILINE | re.DOTALL) @@ -15,7 +13,11 @@ 'jsonpatch_cli', ) -REQUIREMENTS = list(open('requirements.txt')) +with open('requirements.txt') as f: + REQUIREMENTS = [line.strip() for line in f if line.strip()] + +with open('README.md', encoding='utf-8') as f: + LONG_DESCRIPTION = f.read() AUTHOR_EMAIL = metadata['author'] VERSION = metadata['version'] @@ -50,7 +52,7 @@ setup(name=PACKAGE, version=VERSION, description=DESCRIPTION, - long_description=open('README.md', encoding='utf-8').read(), + long_description=LONG_DESCRIPTION, long_description_content_type='text/markdown', author=AUTHOR, author_email=EMAIL, @@ -60,7 +62,7 @@ # jsonpatch is a single-file module, which can't be marked as typed # with py.typed (PEP 561), so its type stubs are a stub-only package packages=['jsonpatch-stubs'], - package_data={'': ['requirements.txt'], 'jsonpatch-stubs': ['*.pyi']}, + package_data={'jsonpatch-stubs': ['*.pyi']}, entry_points={ 'console_scripts': [ 'jsondiff = jsonpatch_cli:jsondiff_main', @@ -72,10 +74,10 @@ project_urls={ 'Website': 'https://github.com/stefankoegl/python-json-patch', 'Repository': 'https://github.com/stefankoegl/python-json-patch.git', - 'Documentation': "https://python-json-patch.readthedocs.org/", + 'Documentation': 'https://python-json-patch.readthedocs.io/', 'PyPI': 'https://pypi.org/pypi/jsonpatch', 'Tests': 'https://github.com/stefankoegl/python-json-patch/actions', 'Test Coverage': 'https://coveralls.io/github/stefankoegl/python-json-patch', }, install_requires=REQUIREMENTS, -) + ) diff --git a/tests.py b/tests.py index 730b663..f0d7581 100644 --- a/tests.py +++ b/tests.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- import collections import contextlib @@ -31,6 +30,7 @@ def test_operation_iterators_remain_reusable_after_validation(self): {'op': 'add', 'path': '/a', 'value': 1}, {'op': 'replace', 'path': '/a', 'value': 2}, ] + class OneShotIterable: def __init__(self, ops): self.iterator = iter(ops) @@ -89,9 +89,9 @@ def test_diff_without_optimization_uses_basic_operations(self): self.assertEqual(patch.apply(src), dst) self.assertEqual(src, original) - def test_js_file(self): - with open('./tests.js', 'r') as f: + here = os.path.dirname(os.path.abspath(__file__)) + with open(os.path.join(here, 'tests.js'), encoding='utf-8') as f: tests = json.load(f) for test in tests: with self.subTest(comment=test.get('comment'), patch=test['patch']): @@ -319,7 +319,7 @@ def test_replace_whole_document_does_not_share_value(self): def test_move_object_keyerror(self): obj = {'foo': {'bar': 'baz'}, 'qux': {'corge': 'grault'}} - patch_obj = [ {'op': 'move', 'from': '/foo/non-existent', 'path': '/qux/thud'} ] + patch_obj = [{'op': 'move', 'from': '/foo/non-existent', 'path': '/qux/thud'}] self.assertRaises(jsonpatch.JsonPatchConflict, jsonpatch.apply_patch, obj, patch_obj) def test_move_object_key(self): @@ -331,7 +331,7 @@ def test_move_object_key(self): 'foo': {'bar': 'baz'}}) def test_move_array_item(self): - obj = {'foo': ['all', 'grass', 'cows', 'eat']} + obj = {'foo': ['all', 'grass', 'cows', 'eat']} res = jsonpatch.apply_patch(obj, [{'op': 'move', 'from': '/foo/1', 'path': '/foo/3'}]) self.assertEqual(res, {'foo': ['all', 'cows', 'eat', 'grass']}) @@ -362,16 +362,15 @@ def test_copy_object_key(self): 'foo': {'bar': 'baz', 'waldo': 'fred'}}) def test_copy_array_item(self): - obj = {'foo': ['all', 'grass', 'cows', 'eat']} + obj = {'foo': ['all', 'grass', 'cows', 'eat']} res = jsonpatch.apply_patch(obj, [{'op': 'copy', 'from': '/foo/1', 'path': '/foo/3'}]) self.assertEqual(res, {'foo': ['all', 'grass', 'cows', 'grass', 'eat']}) - def test_copy_mutable(self): """ test if mutable objects (dicts and lists) are copied by value """ obj = {'foo': [{'bar': 42}, {'baz': 3.14}], 'boo': []} # copy object somewhere - res = jsonpatch.apply_patch(obj, [{'op': 'copy', 'from': '/foo/0', 'path': '/boo/0' }]) + res = jsonpatch.apply_patch(obj, [{'op': 'copy', 'from': '/foo/0', 'path': '/boo/0'}]) self.assertEqual(res, {'foo': [{'bar': 42}, {'baz': 3.14}], 'boo': [{'bar': 42}]}) # modify original object res = jsonpatch.apply_patch(res, [{'op': 'add', 'path': '/foo/0/zoo', 'value': 255}]) @@ -412,19 +411,17 @@ def test_copy_document_to_itself(self): res['foo'].append(2) self.assertEqual(obj, {'foo': [1]}) - def test_test_success(self): - obj = {'baz': 'qux', 'foo': ['a', 2, 'c']} + obj = {'baz': 'qux', 'foo': ['a', 2, 'c']} jsonpatch.apply_patch(obj, [{'op': 'test', 'path': '/baz', 'value': 'qux'}, {'op': 'test', 'path': '/foo/1', 'value': 2}]) def test_test_whole_obj(self): - obj = {'baz': 1} + obj = {'baz': 1} jsonpatch.apply_patch(obj, [{'op': 'test', 'path': '', 'value': obj}]) - def test_test_error(self): - obj = {'bar': 'qux'} + obj = {'bar': 'qux'} self.assertRaises(jsonpatch.JsonPatchTestFailed, jsonpatch.apply_patch, obj, [{'op': 'test', 'path': '/bar', 'value': 'bar'}]) @@ -503,7 +500,6 @@ def test_test_read_only_mapping_success_preserves_input(self): if nested: self.assertIs(result['nested'], mapping) - def test_test_read_only_sequences_preserve_boolean_types(self): for actual_type in (list, tuple): for expected_type in (list, tuple): @@ -629,45 +625,40 @@ def test_test_strings_and_bytes_remain_scalar(self): jsonpatch.apply_patch(doc, patch, in_place=True) def test_test_not_existing(self): - obj = {'bar': 'qux'} + obj = {'bar': 'qux'} self.assertRaises(jsonpatch.JsonPatchTestFailed, jsonpatch.apply_patch, obj, [{'op': 'test', 'path': '/baz', 'value': 'bar'}]) - def test_forgetting_surrounding_list(self): - obj = {'bar': 'qux'} + obj = {'bar': 'qux'} self.assertRaises(jsonpatch.InvalidJsonPatch, jsonpatch.apply_patch, obj, {'op': 'test', 'path': '/bar'}) def test_test_noval_existing(self): - obj = {'bar': 'qux'} + obj = {'bar': 'qux'} self.assertRaises(jsonpatch.InvalidJsonPatch, jsonpatch.apply_patch, obj, [{'op': 'test', 'path': '/bar'}]) - def test_test_noval_not_existing(self): - obj = {'bar': 'qux'} + obj = {'bar': 'qux'} self.assertRaises(jsonpatch.JsonPatchTestFailed, jsonpatch.apply_patch, obj, [{'op': 'test', 'path': '/baz'}]) - def test_test_noval_not_existing_nested(self): - obj = {'bar': {'qux': 2}} + obj = {'bar': {'qux': 2}} self.assertRaises(jsonpatch.JsonPatchTestFailed, jsonpatch.apply_patch, obj, [{'op': 'test', 'path': '/baz/qx'}]) - def test_unrecognized_element(self): obj = {'foo': 'bar', 'baz': 'qux'} res = jsonpatch.apply_patch(obj, [{'op': 'replace', 'path': '/baz', 'value': 'boo', 'foo': 'ignore'}]) self.assertTrue(res['baz'], 'boo') - def test_append(self): obj = {'foo': [1, 2]} res = jsonpatch.apply_patch(obj, [ @@ -692,11 +683,10 @@ def test_path_with_null_value(self): class EqualityTestCase(unittest.TestCase): def test_patch_equality(self): - patch1 = jsonpatch.JsonPatch([{ "op": "add", "path": "/a/b/c", "value": "foo" }]) - patch2 = jsonpatch.JsonPatch([{ "path": "/a/b/c", "op": "add", "value": "foo" }]) + patch1 = jsonpatch.JsonPatch([{"op": "add", "path": "/a/b/c", "value": "foo"}]) + patch2 = jsonpatch.JsonPatch([{"path": "/a/b/c", "op": "add", "value": "foo"}]) self.assertEqual(patch1, patch2) - def test_patch_unequal(self): patch1 = jsonpatch.JsonPatch([{'op': 'test', 'path': '/test'}]) patch2 = jsonpatch.JsonPatch([{'op': 'test', 'path': '/test1'}]) @@ -714,17 +704,15 @@ def test_patch_equal_numbers_by_value(self): self.assertEqual(patch1, patch2) def test_patch_hash_equality(self): - patch1 = jsonpatch.JsonPatch([{ "op": "add", "path": "/a/b/c", "value": "foo" }]) - patch2 = jsonpatch.JsonPatch([{ "path": "/a/b/c", "op": "add", "value": "foo" }]) + patch1 = jsonpatch.JsonPatch([{"op": "add", "path": "/a/b/c", "value": "foo"}]) + patch2 = jsonpatch.JsonPatch([{"path": "/a/b/c", "op": "add", "value": "foo"}]) self.assertEqual(hash(patch1), hash(patch2)) - def test_patch_hash_unequal(self): patch1 = jsonpatch.JsonPatch([{'op': 'test', 'path': '/test'}]) patch2 = jsonpatch.JsonPatch([{'op': 'test', 'path': '/test1'}]) self.assertNotEqual(hash(patch1), hash(patch2)) - def test_patch_hash_container_values(self): for value in ([], {}, [1, {'b': []}], {'b': [1]}): patch1 = jsonpatch.JsonPatch([{'op': 'add', 'path': '/a', 'value': value}]) @@ -732,28 +720,26 @@ def test_patch_hash_container_values(self): self.assertEqual(hash(patch1), hash(patch2)) self.assertEqual(len({patch1, patch2}), 1) - def test_patch_hash_ignored_container_member(self): # members an operation doesn't use are ignored, whatever their value patch = jsonpatch.JsonPatch([{'op': 'add', 'path': '/a', 'value': 1, 'from': []}]) self.assertIn(patch, {patch}) - def test_patch_neq_other_objs(self): p = [{'op': 'test', 'path': '/test'}] patch = jsonpatch.JsonPatch(p) # a patch will always compare not-equal to objects of other types self.assertFalse(patch == p) - self.assertFalse(patch == None) + self.assertFalse(patch == None) # noqa: E711, tests __eq__ # also a patch operation will always compare # not-equal to objects of other types op = jsonpatch.PatchOperation(p[0]) self.assertFalse(op == p[0]) - self.assertFalse(op == None) + self.assertFalse(op == None) # noqa: E711, tests __eq__ def test_str(self): - patch_obj = [ { "op": "add", "path": "/child", "value": { "grandchild": { } } } ] + patch_obj = [{"op": "add", "path": "/child", "value": {"grandchild": {}}}] patch = jsonpatch.JsonPatch(patch_obj) self.assertEqual(json.dumps(patch_obj), str(patch)) @@ -870,11 +856,11 @@ def test_array_add_remove(self): def test_add_nested(self): # see http://tools.ietf.org/html/draft-ietf-appsawg-json-patch-03#appendix-A.10 src = {"foo": "bar"} - patch_obj = [ { "op": "add", "path": "/child", "value": { "grandchild": { } } } ] + patch_obj = [{"op": "add", "path": "/child", "value": {"grandchild": {}}}] res = jsonpatch.apply_patch(src, patch_obj) - expected = { "foo": "bar", - "child": { "grandchild": { } } - } + expected = {"foo": "bar", + "child": {"grandchild": {}} + } self.assertEqual(expected, res) def test_should_just_add_new_item_not_rebuild_all_list(self): @@ -930,8 +916,8 @@ def test_issue40(self): def test_issue76(self): """ Make sure op:remove does not include a 'value' field """ - src = { "name": "fred", "friend": "barney", "spouse": "wilma" } - dst = { "name": "fred", "spouse": "wilma" } + src = {"name": "fred", "friend": "barney", "spouse": "wilma"} + dst = {"name": "fred", "spouse": "wilma"} expected = [{"path": "/friend", "op": "remove"}] patch = jsonpatch.make_patch(src, dst) self.assertEqual(patch.patch, expected) @@ -952,8 +938,8 @@ def test_json_patch(self): def test_arrays_one_element_sequences(self): """ Tests the case of multiple common one element sequences inside an array """ # see https://github.com/stefankoegl/python-json-patch/issues/30#issuecomment-155070128 - src = [1,2,3] - dst = [3,1,4,2] + src = [1, 2, 3] + dst = [3, 1, 4, 2] patch = jsonpatch.make_patch(src, dst) res = jsonpatch.apply_patch(src, patch) self.assertEqual(res, dst) @@ -972,14 +958,14 @@ def test_nested(self): """ Patch creation with nested dicts, as reported in #41 https://github.com/stefankoegl/python-json-patch/issues/41 """ - old = {'school':{'names':['Kevin','Carl']}} - new = {'school':{'names':['Carl','Kate','Kevin','Jake']}} + old = {'school': {'names': ['Kevin', 'Carl']}} + new = {'school': {'names': ['Carl', 'Kate', 'Kevin', 'Jake']}} patch = jsonpatch.JsonPatch.from_diff(old, new) new_from_patch = jsonpatch.apply_patch(old, patch) self.assertEqual(new, new_from_patch) def test_move_from_numeric_to_alpha_dict_key(self): - #https://github.com/stefankoegl/python-json-patch/issues/97 + # https://github.com/stefankoegl/python-json-patch/issues/97 src = {'13': 'x'} dst = {'A': 'a', 'b': 'x'} patch = jsonpatch.make_patch(src, dst) @@ -1099,15 +1085,27 @@ def test_values_that_contain_themselves(self): def test_issue119(self): """Make sure it avoids casting numeric str dict key to int""" - src = [ - {'foobar': {u'1': [u'lettuce', u'cabbage', u'bok choy', u'broccoli'], u'3': [u'ibex'], u'2': [u'apple'], u'5': [], u'4': [u'gerenuk', u'duiker'], u'10_1576156603109': [], u'6': [], u'8_1572034252560': [u'thompson', u'gravie', u'mango', u'coconut'], u'7_1572034204585': []}}, - {'foobar':{u'description': u'', u'title': u''}} - ] - dst = [ - {'foobar': {u'9': [u'almond'], u'10': u'yes', u'12': u'', u'16_1598876845275': [], u'7': [u'pecan']}}, - {'foobar': {u'1': [u'lettuce', u'cabbage', u'bok choy', u'broccoli'], u'3': [u'ibex'], u'2': [u'apple'], u'5': [], u'4': [u'gerenuk', u'duiker'], u'10_1576156603109': [], u'6': [], u'8_1572034252560': [u'thompson', u'gravie', u'mango', u'coconut'], u'7_1572034204585': []}}, - {'foobar': {u'description': u'', u'title': u''}} - ] + src = [{'foobar': {'1': ['lettuce', 'cabbage', 'bok choy', 'broccoli'], + '3': ['ibex'], + '2': ['apple'], + '5': [], + '4': ['gerenuk', 'duiker'], + '10_1576156603109': [], + '6': [], + '8_1572034252560': ['thompson', 'gravie', 'mango', 'coconut'], + '7_1572034204585': []}}, + {'foobar': {'description': '', 'title': ''}}] + dst = [{'foobar': {'9': ['almond'], '10': 'yes', '12': '', '16_1598876845275': [], '7': ['pecan']}}, + {'foobar': {'1': ['lettuce', 'cabbage', 'bok choy', 'broccoli'], + '3': ['ibex'], + '2': ['apple'], + '5': [], + '4': ['gerenuk', 'duiker'], + '10_1576156603109': [], + '6': [], + '8_1572034252560': ['thompson', 'gravie', 'mango', 'coconut'], + '7_1572034204585': []}}, + {'foobar': {'description': '', 'title': ''}}] patch = jsonpatch.make_patch(src, dst) res = jsonpatch.apply_patch(src, patch) self.assertEqual(res, dst) @@ -1115,39 +1113,39 @@ def test_issue119(self): def test_issue120(self): """Make sure it avoids casting numeric str dict key to int""" src = [{'foobar': {'821b7213_b9e6_2b73_2e9c_cf1526314553': ['Open Work'], - '6e3d1297_0c5a_88f9_576b_ad9216611c94': ['Many Things'], - '1987bcf0_dc97_59a1_4c62_ce33e51651c7': ['Product']}}, - {'foobar': {'2a7624e_0166_4d75_a92c_06b3f': []}}, - {'foobar': {'10': [], - '11': ['bee', - 'ant', - 'wasp'], - '13': ['phobos', - 'titan', - 'gaea'], + '6e3d1297_0c5a_88f9_576b_ad9216611c94': ['Many Things'], + '1987bcf0_dc97_59a1_4c62_ce33e51651c7': ['Product']}}, + {'foobar': {'2a7624e_0166_4d75_a92c_06b3f': []}}, + {'foobar': {'10': [], + '11': ['bee', + 'ant', + 'wasp'], + '13': ['phobos', + 'titan', + 'gaea'], '14': [], '15': 'run3', '16': 'service', '2': ['zero', 'enable']}}] dst = [{'foobar': {'1': [], '2': []}}, - {'foobar': {'821b7213_b9e6_2b73_2e9c_cf1526314553': ['Open Work'], - '6e3d1297_0c5a_88f9_576b_ad9216611c94': ['Many Things'], - '1987bcf0_dc97_59a1_4c62_ce33e51651c7': ['Product']}}, - {'foobar': {'2a7624e_0166_4d75_a92c_06b3f': []}}, - {'foobar': {'b238d74d_dcf4_448c_9794_c13a2f7b3c0a': [], - 'dcb0387c2_f7ae_b8e5bab_a2b1_94deb7c': []}}, - {'foobar': {'10': [], - '11': ['bee', - 'ant', - 'fly'], - '13': ['titan', - 'phobos', - 'gaea'], - '14': [], - '15': 'run3', - '16': 'service', - '2': ['zero', 'enable']}} - ] + {'foobar': {'821b7213_b9e6_2b73_2e9c_cf1526314553': ['Open Work'], + '6e3d1297_0c5a_88f9_576b_ad9216611c94': ['Many Things'], + '1987bcf0_dc97_59a1_4c62_ce33e51651c7': ['Product']}}, + {'foobar': {'2a7624e_0166_4d75_a92c_06b3f': []}}, + {'foobar': {'b238d74d_dcf4_448c_9794_c13a2f7b3c0a': [], + 'dcb0387c2_f7ae_b8e5bab_a2b1_94deb7c': []}}, + {'foobar': {'10': [], + '11': ['bee', + 'ant', + 'fly'], + '13': ['titan', + 'phobos', + 'gaea'], + '14': [], + '15': 'run3', + '16': 'service', + '2': ['zero', 'enable']}} + ] patch = jsonpatch.make_patch(src, dst) res = jsonpatch.apply_patch(src, patch) self.assertEqual(res, dst) @@ -1187,18 +1185,18 @@ def test_issue_138(self): def test_issue_138b(self): """Additionally tests escaping special characters.""" old = {"/": - [ + [ {"x": ["a", {"y": ["b"]}], "z": "a"}, {"x": ["c", {"d": ["d"]}], "z": "c"}, {}, - ] - } + ] + } new = {"/": - [ + [ {"x": ["c", {"y": ["d"]}], "z": "c"}, {}, - ] - } + ] + } patch = jsonpatch.make_patch(old, new) result = jsonpatch.apply_patch(old, patch) self.assertEqual(result, new) @@ -1738,14 +1736,13 @@ class InvalidInputTests(unittest.TestCase): def test_missing_op(self): # an "op" member is required src = {"foo": "bar"} - patch_obj = [ { "path": "/child", "value": { "grandchild": { } } } ] + patch_obj = [{"path": "/child", "value": {"grandchild": {}}}] self.assertRaises(jsonpatch.JsonPatchException, jsonpatch.apply_patch, src, patch_obj) - def test_invalid_op(self): # "invalid" is not a valid operation src = {"foo": "bar"} - patch_obj = [ { "op": "invalid", "path": "/child", "value": { "grandchild": { } } } ] + patch_obj = [{"op": "invalid", "path": "/child", "value": {"grandchild": {}}}] self.assertRaises(jsonpatch.JsonPatchException, jsonpatch.apply_patch, src, patch_obj) def test_operation_not_an_object(self): @@ -1759,7 +1756,7 @@ def test_invalid_from(self): src = {"foo": "bar"} for op in ("move", "copy"): for from_ in [0, None, ["foo"], {"foo": "bar"}]: - patch_obj = [ { "op": op, "from": from_, "path": "/baz" } ] + patch_obj = [{"op": op, "from": from_, "path": "/baz"}] self.assertRaises(jsonpatch.InvalidJsonPatch, jsonpatch.apply_patch, src, patch_obj) def test_duplicate_members_in_string_patch(self): @@ -1778,17 +1775,17 @@ class ConflictTests(unittest.TestCase): def test_remove_indexerror(self): src = {"foo": [1, 2]} - patch_obj = [ { "op": "remove", "path": "/foo/10"} ] + patch_obj = [{"op": "remove", "path": "/foo/10"}] self.assertRaises(jsonpatch.JsonPatchConflict, jsonpatch.apply_patch, src, patch_obj) def test_remove_keyerror(self): src = {"foo": [1, 2]} - patch_obj = [ { "op": "remove", "path": "/foo/b"} ] + patch_obj = [{"op": "remove", "path": "/foo/b"}] self.assertRaises(jsonpointer.JsonPointerException, jsonpatch.apply_patch, src, patch_obj) def test_remove_keyerror_dict(self): src = {'foo': {'bar': 'barz'}} - patch_obj = [ { "op": "remove", "path": "/foo/non-existent"} ] + patch_obj = [{"op": "remove", "path": "/foo/non-existent"}] self.assertRaises(jsonpatch.JsonPatchConflict, jsonpatch.apply_patch, src, patch_obj) def test_remove_from_immutable_sequence(self): @@ -1843,52 +1840,52 @@ def __delitem__(self, key): def test_insert_oob(self): src = {"foo": [1, 2]} - patch_obj = [ { "op": "add", "path": "/foo/10", "value": 1} ] + patch_obj = [{"op": "add", "path": "/foo/10", "value": 1}] self.assertRaises(jsonpatch.JsonPatchConflict, jsonpatch.apply_patch, src, patch_obj) def test_move_into_child(self): src = {"foo": {"bar": {"baz": 1}}} - patch_obj = [ { "op": "move", "from": "/foo", "path": "/foo/bar" } ] + patch_obj = [{"op": "move", "from": "/foo", "path": "/foo/bar"}] self.assertRaises(jsonpatch.JsonPatchException, jsonpatch.apply_patch, src, patch_obj) def test_move_whole_document_into_own_child(self): for src, path in [([], '/-'), ([1], '/0'), ({}, '/a'), ({'a': {}}, '/a/b')]: - patch_obj = [ { "op": "move", "from": "", "path": path } ] + patch_obj = [{"op": "move", "from": "", "path": path}] self.assertRaises(jsonpatch.JsonPatchConflict, jsonpatch.apply_patch, src, patch_obj) def test_move_whole_document_onto_itself(self): for src in [[1], {'a': 1}]: - res = jsonpatch.apply_patch(src, [ { "op": "move", "from": "", "path": "" } ]) + res = jsonpatch.apply_patch(src, [{"op": "move", "from": "", "path": ""}]) self.assertEqual(res, src) def test_move_onto_itself_must_exist(self): src = {'foo': [1]} for path in ['/bar', '/foo/1']: - patch_obj = [ { "op": "move", "from": path, "path": path } ] + patch_obj = [{"op": "move", "from": path, "path": path}] self.assertRaises(jsonpatch.JsonPatchConflict, jsonpatch.apply_patch, src, patch_obj) def test_replace_oob(self): src = {"foo": [1, 2]} - patch_obj = [ { "op": "replace", "path": "/foo/10", "value": 10} ] + patch_obj = [{"op": "replace", "path": "/foo/10", "value": 10}] self.assertRaises(jsonpatch.JsonPatchConflict, jsonpatch.apply_patch, src, patch_obj) def test_replace_oob_length(self): src = {"foo": [0, 1]} - patch_obj = [ { "op": "replace", "path": "/foo/2", "value": 2} ] + patch_obj = [{"op": "replace", "path": "/foo/2", "value": 2}] self.assertRaises(jsonpatch.JsonPatchConflict, jsonpatch.apply_patch, src, patch_obj) def test_replace_missing(self): src = {"foo": 1} - patch_obj = [ { "op": "replace", "path": "/bar", "value": 10} ] + patch_obj = [{"op": "replace", "path": "/bar", "value": 10}] self.assertRaises(jsonpatch.JsonPatchConflict, jsonpatch.apply_patch, src, patch_obj) def test_add_replace_in_immutable_sequence(self): # a tuple can be indexed like an array, but not be modified src = {"foo": (1, 2)} for patch_obj in [ - [ { "op": "add", "path": "/foo/0", "value": 0 } ], - [ { "op": "add", "path": "/foo/-", "value": 3 } ], - [ { "op": "replace", "path": "/foo/0", "value": 0 } ], + [{"op": "add", "path": "/foo/0", "value": 0}], + [{"op": "add", "path": "/foo/-", "value": 3}], + [{"op": "replace", "path": "/foo/0", "value": 0}], ]: self.assertRaises(jsonpatch.JsonPatchConflict, jsonpatch.apply_patch, src, patch_obj) @@ -1900,48 +1897,48 @@ def setUp(self): self.src = {"foo": "should-not-be-indexable"} def test_test(self): - patch_obj = [ { "op": "test", "path": "/foo/0", "value": "s" } ] + patch_obj = [{"op": "test", "path": "/foo/0", "value": "s"}] self.assertRaises(jsonpatch.JsonPatchTestFailed, jsonpatch.apply_patch, self.src, patch_obj) def test_test_nested(self): - patch_obj = [ { "op": "test", "path": "/foo/0/0", "value": "s" } ] + patch_obj = [{"op": "test", "path": "/foo/0/0", "value": "s"}] self.assertRaises(jsonpatch.JsonPatchTestFailed, jsonpatch.apply_patch, self.src, patch_obj) def test_copy(self): - patch_obj = [ { "op": "copy", "from": "/foo/0", "path": "/bar" } ] + patch_obj = [{"op": "copy", "from": "/foo/0", "path": "/bar"}] self.assertRaises(jsonpointer.JsonPointerException, jsonpatch.apply_patch, self.src, patch_obj) def test_move(self): - patch_obj = [ { "op": "move", "from": "/foo/0", "path": "/bar" } ] + patch_obj = [{"op": "move", "from": "/foo/0", "path": "/bar"}] self.assertRaises(jsonpointer.JsonPointerException, jsonpatch.apply_patch, self.src, patch_obj) def test_remove(self): - patch_obj = [ { "op": "remove", "path": "/foo/0" } ] + patch_obj = [{"op": "remove", "path": "/foo/0"}] self.assertRaises(jsonpointer.JsonPointerException, jsonpatch.apply_patch, self.src, patch_obj) def test_add(self): - patch_obj = [ { "op": "add", "path": "/foo/0", "value": "x" } ] + patch_obj = [{"op": "add", "path": "/foo/0", "value": "x"}] self.assertRaises(jsonpointer.JsonPointerException, jsonpatch.apply_patch, self.src, patch_obj) def test_replace(self): - patch_obj = [ { "op": "replace", "path": "/foo/0", "value": "x" } ] + patch_obj = [{"op": "replace", "path": "/foo/0", "value": "x"}] self.assertRaises(jsonpointer.JsonPointerException, jsonpatch.apply_patch, self.src, patch_obj) def test_root_string(self): - patch_obj = [ { "op": "test", "path": "/0", "value": "a" } ] + patch_obj = [{"op": "test", "path": "/0", "value": "a"}] self.assertRaises(jsonpatch.JsonPatchTestFailed, jsonpatch.apply_patch, "abc", patch_obj) def test_whole_string_value(self): patch_obj = [ - { "op": "test", "path": "/foo", "value": "should-not-be-indexable" }, - { "op": "copy", "from": "/foo", "path": "/bar" }, + {"op": "test", "path": "/foo", "value": "should-not-be-indexable"}, + {"op": "copy", "from": "/foo", "path": "/bar"}, ] res = jsonpatch.apply_patch(self.src, patch_obj) self.assertEqual(res, {"foo": "should-not-be-indexable", "bar": "should-not-be-indexable"}) def test_root_string_whole_document(self): - patch_obj = [ { "op": "test", "path": "", "value": "abc" } ] + patch_obj = [{"op": "test", "path": "", "value": "abc"}] self.assertEqual(jsonpatch.apply_patch("abc", patch_obj), "abc") @@ -2030,7 +2027,7 @@ def test_operation_normalizes_read_only_mapping(self): def test_creation_fails_with_invalid_patch(self): invalid_patches = [ - { 'path': '/foo', 'value': 'bar'}, + {'path': '/foo', 'value': 'bar'}, {'op': 0xADD, 'path': '/foo', 'value': 'bar'}, {'op': 'boo', 'path': '/foo', 'value': 'bar'}, {'op': 'add', 'value': 'bar'}, @@ -2547,7 +2544,7 @@ def test_make_patch(self): self.assertEqual(op.pointer_cls, CustomJsonPointer) def test_operations(self): - operations =[ + operations = [ ( jsonpatch.AddOperation, { 'op': 'add', 'path': '/foo', 'value': [1, 2, 3] @@ -2732,9 +2729,9 @@ def test_failed_operations_are_rolled_back(self): ({'a': 1, 'b': 2}, [{'op': 'add', 'path': '/a', 'value': 3}]), ({'a': 1, 'b': 2, 'c': 3}, [{'op': 'remove', 'path': '/a'}]), ({'a': 1, 'b': 2, 'c': 3}, [{'op': 'remove', 'path': '/b'}, - {'op': 'remove', 'path': '/a'}, - {'op': 'add', 'path': '/b', - 'value': 0}]), + {'op': 'remove', 'path': '/a'}, + {'op': 'add', 'path': '/b', + 'value': 0}]), ({'a': 1, 'b': 2}, [{'op': 'replace', 'path': '/a', 'value': 3}]), # members added before the first removal from the same object ({'a': 1, 'b': 2}, [{'op': 'add', 'path': '/c', 'value': 3}, @@ -3037,10 +3034,10 @@ class JsonPatch(jsonpatch.JsonPatch): patch = JsonPatch([{'op': 'remove', 'path': '/a'}, op]) expected = patch.apply(doc) - l = doc['l'] + original = doc['l'] res = patch.apply(doc, in_place=True, atomic=True) self.assertIs(res, doc) - self.assertIs(res['l'], l) + self.assertIs(res['l'], original) self.assertEqual(res, expected) def test_custom_operation_failing_on_original(self): @@ -4021,7 +4018,7 @@ class CustomPatch(jsonpatch.JsonPatchWithAppend): src = {'m': 'Hello', 'old': 1} dst = {'m': 'Hello!', 'new': 1, 'items': [2]} patch = CustomPatch.from_diff(src, dst, optimization=optimization, - pointer_cls=CustomJsonPointer, **kwargs) + pointer_cls=CustomJsonPointer, **kwargs) self.assertIsInstance(patch, CustomPatch) self.assertEqual(patch.apply(src), dst) self.assertEqual(any(op['op'] == 'move' for op in patch), optimization) @@ -4054,54 +4051,11 @@ def test_additional_members_are_not_aliases(self): {'message': 'canonical'}) +def load_tests(loader, tests, pattern): + """ Adds the doctests of jsonpatch to the tests of this module """ + tests.addTests(doctest.DocTestSuite(jsonpatch)) + return tests + + if __name__ == '__main__': - modules = ['jsonpatch'] - - - def get_suite(): - suite = unittest.TestSuite() - suite.addTest(doctest.DocTestSuite(jsonpatch)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(ApplyPatchTestCase)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(EqualityTestCase)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(MakePatchTestCase)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(ListTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(InvalidInputTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(ConflictTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(StringIndexingTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(OptimizationTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(GenerateTestOpsTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(ArrayItemsTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(JsonPointerTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(JsonPatchCreationTest)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(UtilityMethodTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(OperationLocationTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(PathUtilityTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(CustomPathSyntaxTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(PointerClassCompatibilityTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(CustomJsonPointerTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(CustomOperationTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(ExceptionOperationTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(AtomicApplyTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(PathNotFoundTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(CommandLineTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(DocumentationTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(StandardAppendIsolationTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(AppendOperationTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(AppendInterfaceTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(AppendAtomicTests)) - suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(AppendDiffTests)) - return suite - - - suite = get_suite() - - for module in modules: - m = __import__(module, fromlist=[module]) - suite.addTest(doctest.DocTestSuite(m)) - - runner = unittest.TextTestRunner(verbosity=1) - - result = runner.run(suite) - - if not result.wasSuccessful(): - sys.exit(1) + unittest.main()