6

Given an object that can be a nested structure of dict-like or array-like objects. What is the pythonic way to set a value on that object given a dotted path as a string?

Example:

obj = [
    {'a': [1, 2]},
    {'b': {
        'c': [3, 4],
    }},
]

path = '1.b.c.0'

# operation that sets a value at the given path, e.g.
obj[path] = 5

# or
set_value(obj, path, 5)

The above call/assignment should replace the 3 in the example with a 5.

Note: The path can contain list indices as well as keys. Every level of the object can be an array or a dict or something that behaves like that.

The solution should be roughly like what the npm package object-path does in javascript.

trixn
  • 15,761
  • 2
  • 38
  • 55
  • 1
    If you have the ability to alter the specification, you might want to consider making the path look something like `[1].b.c[0]` or something like that instead, as that would clearly distinguish which parts are being looked up by index versus being looked up by a string key. – Daniel Pryden Aug 16 '18 at 14:11
  • @DanielPryden Thanks for the suggestion. Unfortunately I can't alter it. It is defined in [this spec](https://github.com/jaydenseric/graphql-multipart-request-spec#server). I want to enable a python django backend to process apollo graphql mutations containing file uploads. To be compatible with the reference client implementation I have to keep with that spec. – trixn Aug 16 '18 at 14:16

2 Answers2

4

A non-recursive version, which also works for numeric dictionary keys:

from collections.abc import MutableMapping

def set_value_at_path(obj, path, value):
    *parts, last = path.split('.')

    for part in parts:
        if isinstance(obj, MutableMapping):
            obj = obj[part]
        else:
            obj = obj[int(part)]

    if isinstance(obj, MutableMapping):
        obj[last] = value
    else:
        obj[int(last)] = value
Eugene Yarmash
  • 142,882
  • 41
  • 325
  • 378
  • 2
    I like this approach better, for two reasons: first, because it avoids unnecessary recursion, and secondly, because it relies on the data structure to determine how to interpret the tokens in the path rather than the other way around. – Daniel Pryden Aug 16 '18 at 12:34
  • Very good solution thank you. One question: Is an instance of `Mapping` guaranteed to implement `__setitem__`? – trixn Aug 16 '18 at 12:43
  • I had a similar (but recursive) solution first. It enables the use of `"1"` as a dictionary key, while it makes usage of `1` as a dictionary key impossible. – L3viathan Aug 16 '18 at 12:51
  • 2
    To cover `__setitem__` I guess it shoud be `MutableMapping`, according to the table [here](https://docs.python.org/3.7/library/collections.abc.html). – Eugene Yarmash Aug 16 '18 at 12:51
3

Sounds like a job for recursion and str.partition:

def set_value(obj, path, val):
    first, sep, rest = path.partition(".")
    if first.isnumeric():
        first = int(first)
    if rest:
        new_obj = obj[first]
        set_value(new_obj, rest, val)
    else:
        obj[first] = val

Because 1 is not the same as "1", I'm making a compromise here: If something looks like a number, it's treated as a number.

You could get this to behave on custom objects, but not really on builtin types without awful hackery, because Python doesn't have an equivalent to object.prototype.

L3viathan
  • 26,748
  • 2
  • 58
  • 81
  • I would have done it with a loop rather than recursion (e.g. `for token in path.split('.')`) but this works too. – Daniel Pryden Aug 16 '18 at 12:27
  • Very clean solution. +1 for that. What do you think about trying to cast to an `int` and catch the `ValueError` instead of using `isnumeric()`? – trixn Aug 16 '18 at 12:31
  • 1
    @trixn I'd say that's a question of personal taste, although you might prefer catching the `ValueError` because "It's easier to ask forgiveness than it is to get permission". If the case where the exception is thrown is frequent, I believe it is slightly slower. – L3viathan Aug 16 '18 at 12:53