Skip to content

Commit

Permalink
Merge branch 'objclass' (closes #25)
Browse files Browse the repository at this point in the history
  • Loading branch information
avian2 committed Mar 20, 2017
2 parents c245a60 + 88adb79 commit f45481e
Show file tree
Hide file tree
Showing 4 changed files with 219 additions and 7 deletions.
54 changes: 52 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,16 @@ objectMerge
(e.g. in *properties*, *patternProperties* or *additionalProperties*
schema keywords).

The *objClass* option allows one to request a different dictionary class
to be used to hold the JSON object. The possible values are names that
correspond to specific Python classes. Built-in names include
*OrderedDict*, to use the collections.OrderedDict class, or *dict*,
which uses the Python's dict built-in. If not specified, *dict* is
used by default. (*OrderedDict* is not available in Python 2.6.)

Note that additional classes or a different default can be configured via
the Merger() constructor (see below).

version
Changes the type of the value to an array. New values are appended to the
array in the form of an object with a *value* property. This way all
Expand All @@ -188,10 +198,50 @@ version
*ignoreDups* option to *false*.

If a merge strategy is not specified in the schema, *objectMerge* is used
to objects and *overwrite* for all other values.
for objects and *overwrite* for all other values.

You can implement your own strategies by making subclasses of
jsonmerge.strategies.Strategy and passing them to Merger() constructor.
jsonmerge.strategies.Strategy and passing them to Merger() constructor
(see below).


The Merger Class
----------------

The Merger class allows you to further customize the merging of JSON
data by allowing you to:

- set the schema containing the merge stategy configuration
- provide additional strategy implementations
- set a default class to use for holding JSON object data; *OrderedDict*
is provided as built-in option in Python 2.7 or later.
- configure additional JSON object classes selectable via the *objClass*
merge option.

The Merger constructor takes the following arguments:

schema
The JSON Schema that contains the merge strategy directives
provided as a JSON object. An empty dictionary should be provided
if no strategy configuration is needed.

strategies
A dictionary mapping strategy names to instances of Strategy
classes. These will be combined with the built-in strategies
(overriding them with the instances having the same name).

objclass_def
The name of a supported dictionary-like class to hold JSON data by
default in the merged result. The name must match a built-in name or one
provided in the *objclass_menu* parameter.

objclass_menu
A dictionary providing additional classes to use as JSON object
containers. The keys are names that can be used as values for the
*objectMerge* strategy's *objClass* option or the *objclass_def*
argument. Each value is a function or class that produces an instance of
the JSON object container. It must support an optional dictionary-like
object as a parameter which initializes its contents.


Limitations
Expand Down
35 changes: 33 additions & 2 deletions jsonmerge/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
from jsonschema.validators import Draft4Validator, RefResolver
import logging

try:
# OrderedDict does not exist before python 2.7
from collections import OrderedDict
except:
import warnings
warnings.warn("Support for Python <2.7 in jsonmerge will be removed soon", DeprecationWarning)

OrderedDict = None

log = logging.getLogger(name=__name__)

#logging.basicConfig(level=logging.DEBUG)
Expand Down Expand Up @@ -101,7 +110,7 @@ def work(self, strategy, schema, base, head, meta, **kwargs):
with self.head_resolver.resolving(head.ref) as resolved:
assert head.val == resolved

rv = strategy.merge(self, base, head, schema, meta, **kwargs)
rv = strategy.merge(self, base, head, schema, meta, objclass_menu=self.merger.objclass_menu, **kwargs)

assert isinstance(rv, JSONValue)
return rv
Expand Down Expand Up @@ -178,14 +187,28 @@ class Merger(object):
"arrayMergeById": strategies.ArrayMergeById()
}

def __init__(self, schema, strategies=()):
def __init__(self, schema, strategies=(), objclass_def='dict', objclass_menu=None):
"""Create a new Merger object.
schema -- JSON schema to use when merging.
strategies -- Any additional merge strategies to use during merge.
objclass_def -- Name of the default class for JSON objects.
objclass_menu -- Any additional classes for JSON objects.
strategies argument should be a dict mapping strategy names to
instances of Strategy subclasses.
objclass_def specifies the default class used for JSON objects when one
is not specified in the schema. It should be 'dict' (dict built-in),
'OrderedDict' (collections.OrderedDict) or one of the names specified
in the objclass_menu argument. If not specified, 'dict' is used.
Note: OrderedDict is not available in Python 2.6.
objclass_menu argument should be a dictionary that maps a string name
to a function or class that will return an empty dictionary-like object
to use as a JSON object. The function must accept either no arguments
or a dictionary-like object.
"""

self.schema = schema
Expand All @@ -194,6 +217,14 @@ def __init__(self, schema, strategies=()):
self.strategies = dict(self.STRATEGIES)
self.strategies.update(strategies)

self.objclass_menu = { 'dict': dict }
if OrderedDict:
self.objclass_menu['OrderedDict'] = OrderedDict
if objclass_menu:
self.objclass_menu.update(objclass_menu)

self.objclass_menu['_default'] = self.objclass_menu[objclass_def]

def cache_schema(self, schema, uri=None):
"""Cache an external schema reference.
Expand Down
39 changes: 36 additions & 3 deletions jsonmerge/strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,17 +197,50 @@ def get_schema(self, walk, schema, meta, **kwargs):


class ObjectMerge(Strategy):
def merge(self, walk, base, head, schema, meta, **kwargs):
"""A Strategy for merging objects.
Resulting objects have properties from both base and head. Any
properties that are present both in base and head are merged based
on the strategy specified further down in the hierarchy (e.g. in
properties, patternProperties or additionalProperties schema
keywords).
walk -- WalkInstance object for the current context.
base -- JSONValue being merged into.
head -- JSONValue being merged.
schema -- Schema used for merging (also JSONValue)
meta -- Meta data, as passed to the Merger.merge() method.
objclass_menu -- A dictionary of classes to use as a JSON object.
kwargs -- Any extra options given in the 'mergeOptions' keyword.
objclass_menu should be a dictionary that maps a string name to a function
or class that will return an empty dictionary-like object to use as a JSON
object. The function must accept either no arguments or a dictionary-like
object. The name '_default' represents the default object to use if not
overridden by the objClass option.
One mergeOption is supported:
objClass -- a name for the class to use as a JSON object in the output.
"""
def merge(self, walk, base, head, schema, meta, objclass_menu=None, objClass='_default', **kwargs):
if not walk.is_type(head, "object"):
raise HeadInstanceError("Head for an 'object' merge strategy is not an object")

if objclass_menu is None:
objclass_menu = { '_default': dict }

objcls = objclass_menu.get(objClass)
if objcls is None:
raise SchemaError("objClass '%s' not recognized" % objClass)

if base.is_undef():
base = JSONValue({}, base.ref)
base = JSONValue(objcls(), base.ref)
else:
if not walk.is_type(base, "object"):
raise BaseInstanceError("Base for an 'object' merge strategy is not an object")

base = JSONValue(dict(base.val), base.ref)
base = JSONValue(objcls(base.val), base.ref)

for k, v in head.items():

Expand Down
98 changes: 98 additions & 0 deletions tests/test_jsonmerge.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# vim:ts=4 sw=4 expandtab softtabstop=4
import unittest

try:
from collections import OrderedDict
except:
# OrderedDict not available in python <2.7
OrderedDict = None

import jsonmerge
import jsonmerge.strategies
from jsonmerge.exceptions import (
Expand All @@ -11,6 +18,18 @@

import jsonschema

# workaround for Python < 2.7
if not hasattr(unittest, 'skipIf'):
def skipIf(condition, reason):
def d(f):
def df(*args):
if condition:
print("skipped %r" % (reason,))
else:
return f(*args)
return df
return d
unittest.skipIf = skipIf

class TestMerge(unittest.TestCase):

Expand Down Expand Up @@ -171,6 +190,7 @@ def test_merge_trivial(self):
base = jsonmerge.merge(base, {'a': "a"}, schema)
base = jsonmerge.merge(base, {'b': "b"}, schema)

self.assertTrue(isinstance(base, dict))
self.assertEqual(base, {'a': "a", 'b': "b"})

def test_merge_null(self):
Expand Down Expand Up @@ -210,6 +230,80 @@ def test_merge_overwrite(self):

self.assertEqual(base, {'a': "b"})

@unittest.skipIf(OrderedDict is None, "Not supported on Python <2.7")
def test_merge_objclass(self):
schema = {'mergeStrategy': 'objectMerge', 'mergeOptions': { 'objClass': 'OrderedDict'}}

merger = jsonmerge.Merger(schema)

base = None
base = merger.merge(base, OrderedDict([('c', "a"), ('a', "a")]), schema)
self.assertIsInstance(base, OrderedDict)
self.assertEquals([k for k in base], ['c', 'a'])

base = merger.merge(base, {'a': "b"}, schema)
self.assertIsInstance(base, OrderedDict)
self.assertEquals([k for k in base], ['c', 'a'])

self.assertEqual(base, {'a': "b", 'c': "a"})

@unittest.skipIf(OrderedDict is None, "Not supported on Python <2.7")
def test_merge_objclass2(self):
schema = {'mergeStrategy': 'objectMerge',
'properties': {
'a': {'mergeStrategy': 'objectMerge',
'mergeOptions': { 'objClass': 'OrderedDict'}}}}

merger = jsonmerge.Merger(schema)

base = None
base = merger.merge(base, {'a': {'b': 'c'}, 'd': {'e': 'f'}}, schema)

self.assertIsInstance(base, dict)
self.assertIsInstance(base['a'], OrderedDict)
self.assertIsInstance(base['d'], dict)

@unittest.skipIf(OrderedDict is None, "Not supported on Python <2.7")
def test_merge_objclass_bad_cls(self):
schema = {'mergeStrategy': 'objectMerge', 'mergeOptions': { 'objClass': 'foo'}}

merger = jsonmerge.Merger(schema)

base = None
self.assertRaises(SchemaError, merger.merge, base, OrderedDict([('c', "a"), ('a', "a")]), schema)

def test_merge_objclass_menu(self):
schema = {'mergeStrategy': 'objectMerge', 'mergeOptions': { 'objClass': 'foo'}}

class MyDict(dict):
pass

objclass_menu = {'foo': MyDict}

merger = jsonmerge.Merger(schema, objclass_menu=objclass_menu)

base = None
base = merger.merge(base, {'c': "a", 'a': "a"}, schema)

self.assertTrue(isinstance(base, MyDict))

@unittest.skipIf(OrderedDict is None, "Not supported on Python <2.7")
def test_merge_objclass_def(self):
schema = {'mergeStrategy': 'objectMerge'}

merger = jsonmerge.Merger(schema, objclass_def='OrderedDict')

base = None
base = merger.merge(base, OrderedDict([('c', "a"), ('a', "a")]), schema)
self.assertIsInstance(base, OrderedDict)
self.assertEquals([k for k in base], ['c', 'a'])

base = merger.merge(base, {'a': "b"}, schema)
self.assertIsInstance(base, OrderedDict)
self.assertEquals([k for k in base], ['c', 'a'])

self.assertEqual(base, {'a': "b", 'c': "a"})

def test_merge_append(self):

schema = {'mergeStrategy': 'objectMerge',
Expand Down Expand Up @@ -1436,3 +1530,7 @@ def test_merge_append_additional(self):
schema2 = merger.get_schema()

self.assertEqual(schema2, expected)

if __name__ == '__main__':
unittest.main()

0 comments on commit f45481e

Please sign in to comment.