SimonB
SimonB

Reputation: 1055

Python JSON - finding elements on a dynamic path

I have to update a nested JSON object.
If I knew the specifics of which items were to be updated I could do :

json_object['basket']['items']['apple'] = 'new value'

However, my list of elements to target is dynamic.

> basket.items.apple = 'green'
> name = 'my shopping'
> basket.cost = '15.43'

I could do this by looping through elements.
Find 'basket' > then find 'items > then find 'apple' > set value
Find 'name' > set value

However, was hoping that there was a way to just reference directly/dynamicaly.
i.e. from a string 'basket.cost', build the expression :

json_object['basket']['cost']

P.s. it has to cope with lists of dictionaries too !

Any guidance appreciated :)

Upvotes: 7

Views: 1462

Answers (5)

Adam Sosnowski
Adam Sosnowski

Reputation: 1284

This is tricky and I would discourage it unless necessary as it is an easy thing to design and implmenet badly.

First: it's easy to split on path separator and follow the object tree to the desired key.

But after a while questions will start to appear. E.g.: what separator to split on?

A slash? It can appear in the JSON dictionary key... A dot? Same.

We'll need to either restrict legal / handled paths or implement some kind of escaping mechanism.

How do you handle empty strings?

Another goal: handle lists... Ok. So how do we interpret a path a.0? Is it ['a'][0] or ['a']['0'] ?

It seem that we'll have to complicate the language or drop the requirement.

So, in general -- I'd avoid it. Ultimately here's a quick implementation which desing choices may or may not satisfy you:

  • there's basic backslash escaping of path separator
  • empty string is allowed as a key
  • lists are not handled due to ambiguity
def deep_set(root: dict, path: str, value):
    segments = [*iter_segments(path, '.')]

    for k in segments[:-1]:
        root = root[k]

    root[segments[-1]] = value


def iter_segments(path: str, separator: str = '.'):
    segment = ''
    path_iter = iter(path)

    while True:
        c = next(path_iter, '')

        if c in ('.', ''):
            yield segment
            segment = ''

            if c == '':
                break

            continue

        elif '\\' == c:
            c = next(path_iter, '')

        segment += c

Upvotes: 0

phi
phi

Reputation: 11704

If using third party package is an option, you can try python-box. It comes with lots of options and utilities to load from json, yaml files. The implementation is optimized for speed using Cython.

from box import Box

test_data = {
    "basket": {
        "products": [
            {"name": "apple", "colour": "green"}
        ],
    }
}

a = Box(test_data)
a.basket.cost = 12.3
a.basket.products[0].colour = "pink"
a.basket.products.append({"name": "pineapple", "taste": "sweet"})
print(a.basket.products[1].taste)

Upvotes: 2

nonDucor
nonDucor

Reputation: 2083

You can get exactly what you want by overloading some python magic methods: __getattr__ and __setattr__. I'll show an example of the API to wet the appetite and then the full code:

test_data = {'basket': {'items': [{'name': 'apple', 'colour': 'green'},
                                  {'name': 'pineapple', 'taste': 'sweet',},
                                  ],
                        'cost': 12.3,
                        },
             'name': 'Other'}

o = wrap(test_data)  # This wraps with the correct class, depending if it is a dict or a list
print(o.name)  # Prints 'Other'
print(o.basket.items)   # Prints the list of items
print(o.basket.cost)    # Prints 12.3
o.basket.cost = 10.0    # Changes the cost
assert o.basket.cost == 10.0

assert len(o) == 2
assert len(o.basket.items) == 2
o.basket.items.append({'name': 'orange'})
o.basket.items[2].colour = 'yellow' # It works with lists!
assert o.basket.items[2].name == 'orange'
assert o.basket.items[2].colour == 'yellow'

# You can get a part of it and it holds a reference to the original
b = o.basket
b.type = 'groceries'
assert o.basket.type == 'groceries'

# It is also possible to create a separate wrapped part and then join:
employees = wrap({})
employees.Clara.id = 101
employees.Clara.age = 23
employees.Lucia.id = 102
employees.Lucia.age = 29
o.employees = employees

The implementation is based on special wrapper classes, one for dicts, another for lists. They all inherit from a base class. Note that the need to use super().__setattr__ instead of simply self._data is because we will override the __getattr__ and __setattr__ methods to look for the data inside _data. Of course it gives an infinite loop when you try to define _data.

from collections.abc import Mapping, Sequence, MutableSequence


class BaseWrapper:
    __slots__ = ('_data')

    def __init__(self, data):
        super().__setattr__('_data', data)

    def __repr__(self):
        return f'{self.__class__.__name__}({repr(self._data)})'

The wrapper for dictionaries is the most interesting: it uses __getattr__ to look for a key in the wrapped dictionary. This allows for a very natural API: if o is a wrapped dictionary, o.entry will give the same result as o['entry']. Most of the code should be self-explanatory, there are only two tricks: the first is that __getattr__ checks if the output is a dict or list and wraps it. This allows for chaining of calls like o.basket.cost. The downside is that a new wrapper is created every call. The second trick is when setting an attribute: it checks if what is being set is a wrapped instance and un-wraps it. Thus, wrapped dictionaries can be combined and the underlying dictionary is always "clean".

class MappingWrapper(BaseWrapper):
    """Wraps a dictionary and provides the keys of the dictionary as class members.
    Create new keys when they do not exist."""

    def __getattr__(self, name):
        # Note: these two lines allow automatic creation of attributes, e.g. in an object 'obj'
        # that doesn't have an attribute 'car', the following is possible:
        # >> o.car.colour = 'blue'
        # And all the missing levels will be automatically created
        if name not in self._data and not name.startswith('_'):
            self._data[name] = {}

        return wrap(self._data[name])

    def __setattr__(self, name, value):
        self._data[name] = unwrap(value)

    # Implements standard dictionary access
    def __getitem__(self, name):
        return wrap(self._data[name])

    def __setitem__(self, name, value):
        self._data[name] = unwrap(value)

    def __delitem__(self, name):
        del self._data[name]

    def __len__(self):
        return len(self._data)

The list wrapper is simpler, no need to mess around with attribute access. The only special care we have to take is to wrap and unwrap the list elements when one is requested/set. Note that, just like with the dictionary wrapper, the same wrap and unwrap functions are used (in __getitem__/__setitem__/insert).

class ListWrapper(BaseWrapper, MutableSequence):
    """Wraps a list. Essentially, provides wrapping of elements of the list."""

    def __getitem__(self, idx):
        return wrap(self._data[idx])

    def __setitem__(self, idx, value):
        self._data[idx] = unwrap(value)

    def __delitem__(self, idx):
        del self._data[idx]

    def __len__(self):
        return len(self._data)

    def insert(self, index, obj):
        self._data.insert(index, unwrap(obj))

Finally, the definition of wrap, which just selects the correct wrapper based on the type of the input, and unwrap, which extracts the raw data:

def wrap(obj):
    if isinstance(obj, dict):
        return MappingWrapper(obj)
    if isinstance(obj, list):
        return ListWrapper(obj)
    return obj


def unwrap(obj):
    if isinstance(obj, BaseWrapper):
        return obj._data
    return obj

The full code can be found in this gist.

An important caveat: to keep the implementation simple, wrapper objects are created at every access. Thus using this method inside large loops may cause performance issues (per my measurements, this method of access is between 12 to 30 times slower).

Upvotes: 1

Jonas V
Jonas V

Reputation: 776

I'm going to assume that you already know how to handle the value errors that will probably come up with this nested collection accessing, so I won't focus on it in my approach.

I would split this in two parts:

  1. Traversing a nested collection according to a list of keys for each level
  2. Getting a list of keys out of a string

The first one is quite trivial, where as you said simply looping through the keys and getting to the end of those gives you access to the collection element in question. A simple implementation of that could look something like this:

def get_nested(collection, key):
  for part in key:
    collection = collection[part]
  return collection

def set_nested(collection, key, value):
  for part in key[:-1]:
    collection = collection[part]
  collection[key[-1]] = value

Here the key is expected to be some iterable of keys, such as a tuple or list.
Of course that means there is an expectation that your string representing a path along the collection is already parsed. We can get to that next.


This step would also be very trivial, since one could simply expression.split(".") it. However, since you also want to be able to index nested lists along with dicts, it get's a little more complicated.
There is a tradeoff to be made here. One could simply say: "Any time that one of the items in expression.split(".") can be parsed to an int, we will do just that, and assume that it was ment as an index in a list", however naturally that isn't necessarily the case. There is nothing preventing you from using a number in string form as a key in a dict. However if you think this is never going to be the case for you, perhaps the you can just call it like this:

set_nested(
  collection, 
  (int(part) if part.isdigit() else part for part in expression.split(".")), 
  "target value",
)

(or of course wrap it in another function like this).

However if the consideration of using digit keys in dicts is important for you, there is another solution: Whenever traversing the nested collection downward, we check if the collection we are currently looking at is a list. Only if it is a list, do we actually try to parse the path part as an int.
This would be the respective set_nested and get_nested functions for that:

def get_nested(collection, key: str):
  for part in key.split("."):
    if type(collection) == list:
      part = int(part)
    collection = collection[part]
  return collection

def set_nested(collection, key: str, val):
  key = key.split(".")
  for i, part in enumerate(key):
    if type(collection) == list:
      part = int(part)
    if i == len(key) - 1:
      collection[part] = val
    else:
      collection = collection[part]

I believe that's the simplest solution to your problem, though of course it's important to keep in mind:

  • There is no error handling in this code, and indexing on dynamic paths is a topic where you are bound to run into errors. Depending on where and how you want to handle those it's going to be easy or very tedious.
  • There is no checking of setting values in dicts that don't exist yet, or for expanding arrays to a specific size, but since you didn't mention those that as a requirement I'm presuming it's not an issue. It might be for others reading this.

Upvotes: 0

pho
pho

Reputation: 25489

Once you have the string "basket.cost", you can split it on "." and it's pretty easy to drill down into json_object['basket']['cost'] using a loop. Functionally, there is no difference between doing this and doing it "directly": you are still getting the 'basket' key first, and then getting the 'cost' key from the value of json_object['basket'].

def get_element(d, path):
    # This function can take the string "basket.cost", or the list ["basket", "cost"]
    if isinstance(path, str):
        path = path.split(".")

    for p in path:
        d = d[p]

    return d

def set_element(d, path, value):
    path = path.split(".")
    dict_to_set = get_element(d, path[:-1])
    key_to_set = path[-1]
    dict_to_set[key_to_set] = value

set_element(json_object, "basket.items.apple", 100)

Now, this assumes all elements of your path already exist, so let's say you create a dictionary that looks like so:

json_object = {"basket": {"items": dict()}}
set_element(json_object, "basket.items.apple", 100)
set_element(json_object, "basket.cost", 10)
print(json_object) 
# Output: {'basket': {'items': {'apple': 100}, 'cost': 10}}

print(get_element(json_object, "basket.cost")) 
# Output: 10

If you try to access an element that doesn't already exist, you get a KeyError:

get_element(json_object, "basket.date")
# KeyError: 'date'

This also happens if you try to set a value in an element that doesn't exist:

set_element(json_object, "basket.date.day", 1)
# KeyError: 'date'

If we want to allow your function to create the dictionaries when they don't exist, we can modify the get_element function to account for this situation and add the key:

def get_element(d, path, create_missing=False):
    # This function can take the string "basket.cost", or an iterable containing the elements "basket" and "cost"
    if isinstance(path, str):
        path = path.split(".")

    for p in path:
        if create_missing and p not in d:
            d[p] = dict()

        d = d[p]

    return d

def set_element(d, path, value, create_missing=True):
    path = path.split(".")
    dict_to_set = get_element(d, path[:-1], create_missing)
    key_to_set = path[-1]
    dict_to_set[key_to_set] = value

set_element(json_object, "basket.date.day", 1)
print(json_object)
# Output: {'basket': {'items': {'apple': 100}, 'cost': 10, 'date': {'day': 1}}}

Upvotes: 2

Related Questions