2

From an external webservice I receive a JSON response that looks like this (for convenience, it is already deserialized below):

alist = [
    {
        'type': 'type1',
        'name': 'dummy',
        'oid': 'some_id'
    },
    {
        'type': 'type2',
        'name': 'bigdummy',
        'anumber': 10
    }
]

For each type there exists a class. What I want is to instantiate objects of the respective class without using a long list of if-elifs.

I tried as follows:

Define classes A (for type1) and class B (for type2) using a from_dict classmethod:

class A():
    def __init__(self, name, oid, optional_stuff=None):
        self.name = name
        self.oid = oid
        self.optional_stuff = optional_stuff

    @classmethod
    def from_dict(cls, d):
        name = d['name']
        oid = d['oid']
        optional_stuff = d.get('optional_stuff')

        return cls(name, oid, optional_stuff)

    def foo(self):
        print('i am class A')


class B():
    def __init__(self, name, anumber):
        self.name = name
        self.number = anumber

    @classmethod
    def from_dict(cls, d):
        name = d['name']
        anumber = d['anumber']

        return cls(name, anumber)

Then I define a mapping dictionary:

string_class_map = {
    'type1': A,
    'type2': B
}

and finally convert alist to something the from_dict functions can easily consume:

alist2 = [
    {
        di['type']: {k: v for k, v in di.items() if k != 'type'}
    }
    for di in alist
]

[{'type1': {'name': 'dummy', 'oid': 'some_id'}},
 {'type2': {'name': 'bigdummy', 'anumber': 10}}]

object_list = [
    string_class_map[k].from_dict(v) for d in alist2 for k, v in d.items()
]

That gives me the desired output; when I do:

a = object_list[0]
a.name

will indeed print 'dummy'.

Question is whether there is a better way of getting from alist (this input I cannot change) to object_list.

Cleb
  • 25,102
  • 20
  • 116
  • 151
  • What are the requirements of classes `A` and `B` and drives their design? Are there constraints on them? Could more potentially be added in the future? – martineau Jul 28 '19 at 14:08
  • @martineau: Yes, there could be many more of them; I just chose two to keep it simple. That's also one of the reason why I look for a solution that avoids a lot of `if type == 'type1': ob = A(...) elif type = 'type2':...`. remaining questions are a bit harder to answer: there are no actual constraints. The design will be similar between classes, they will all have a name and common properties and functions, however, their actual implementations will differ. In the actual case, `A` and `B` will be implementations of an abstract class. – Cleb Jul 28 '19 at 14:18

3 Answers3

1
  • As long as the parameter names match up exactly, you don't need the from_dict classmethods - although you might still prefer to work through them as a place to add extra error handling. All we do is use argument unpacking.

  • I would wrap up the process of creating a single object, first. Which is to say, a single "from_dict"-y method should handle the determination of the type, preparing the dict of the other parameters, and invoking the class factory.

  • It seems useful to have a base class for these classes created from the factory - they at least have in common that they can be created this way, after all; you could add debugging stuff at that level; and it's a convenient place for the factory logic itself.

  • You can use a decorator or metaclass to take care of the creation of the lookup map, to avoid having a separate chunk of data to maintain.

Putting that together, I get:

class JsonLoadable:
    _factory = {}


    def __str__(self):
        return f'{self.__class__.__name__}(**{{{self.__dict__}}})'


    @staticmethod # this is our decorator.
    def register(cls):
        # We use the class' __name__ attribute to get the lookup key.
        # So as long as the classes are named to match the JSON, this
        # automatically builds the correct mapping.
        JsonLoadable._factory[cls.__name__] = cls
        return cls


    @staticmethod
    def from_dict(d):
        d = d.copy()
        cls = JsonLoadable._factory[d.pop('type')]
        # this is the magic that lets us avoid class-specific logic.
        return cls(**d) 


# I'm pretty sure there's a way to streamline this further with metaclasses,
# but I'm not up to figuring it out at the moment...
@JsonLoadable.register
class A(JsonLoadable):
    def __init__(self, name, oid, optional_stuff=None):
        self.name = name
        self.oid = oid
        self.optional_stuff = optional_stuff


@JsonLoadable.register
class B(JsonLoadable):
    def __init__(self, name, anumber):
        self.name = name
        self.number = anumber


# And now our usage is simple:
objects = [JsonLoadable.from_dict(d) for d in alist]
Karl Knechtel
  • 62,466
  • 11
  • 102
  • 153
  • As a bonus, consider using `namedtuple` to avoid the boilerplate `__init__`s. This has its own limitations, of course. A fully designed-out solution is probably worthy of a PyPI package ;) – Karl Knechtel Jul 27 '19 at 22:46
  • This will take me a while to comprehend ;) This solution gives me a `KeyError` in `cls = JsonLoadable._factory[d.pop('type')]`: `KeyError: 'type1'`. I guess one needs the mapping from `type1` to `class A` somewhere!? I will get back to this answer tomorrow (including upvote and accept), now it's slightly to late to work through it :) – Cleb Jul 27 '19 at 22:56
  • Works fine when I use `A` and `B` instead of `type1` and `type2`. Thanks, I learned quite a bit here. – Cleb Jul 28 '19 at 13:23
  • 1
    The names of the classes need to match the keys used in the JSON for the approach as I wrote it. This is because the `register` decorator grabs the class' `__name__` attribute as the key for the lookup dict. I probably should have made that more explicit in the first place; edited now. – Karl Knechtel Aug 01 '19 at 22:20
1

Instead of writing a custom class for every 'type' that might be encounterd in alist, it seems like it would be simpler to use a generic class that would allow you to access their attributes (which is all you do with your sample classes).

To accomplish this the code below defines a subclass of the built-in dict class that will allow the value in it to be accessed as though they were instance attributes.

Here's what I mean:

from collections import Iterable, Mapping
from pprint import pprint, pformat


class AttrDict(dict):
    def __init__(self, d):
        for k, v in d.items():
            if isinstance(v, Mapping):
                d[k] = AttrDict(v)
            elif isinstance(v, Iterable) and not isinstance(v, str):
                d[k] = [AttrDict(x) if isinstance(x, Mapping) else x
                            for x in v]
        self.__dict__.update(d)

    def __repr__(self):
        return 'AttrDict({})'.format(repr(self.__dict__))


alist = [
    {
        'type': 'type1',
        'name': 'dummy',
        'oid': 'some_id'
    },
    {
        'type': 'type2',
        'name': 'bigdummy',
        'anumber': 10
    }
]

object_list = [AttrDict(obj) for obj in alist]
pprint(object_list)
print()  # -> [AttrDict({'type': 'type1', 'name': 'dummy', 'oid': 'some_id'}),
         # ->  AttrDict({'type': 'type2', 'name': 'bigdummy', 'anumber': 10})]


a = object_list[0]
print(repr(a.name))  # -> 'dummy'
martineau
  • 119,623
  • 25
  • 170
  • 301
  • Nice solution. In my case, I actually need individual classes, just difficult to explain/show using a minimal example. :) – Cleb Jul 31 '19 at 20:03
  • @Cleb: Thanks. If you could explain _what_ you want these classes to do — because [classes](https://en.wikipedia.org/wiki/Class_(computer_programming)) are after all combinations of data and methods - then I might be able to illustrate how to have them created automatically or at least semi-automatically. Surely there's some sort of commonality between them or at least broad similarities. You can do that sort of thing in Python because classes are themselves instances of metaclasses. – martineau Jul 31 '19 at 21:47
  • But at that point, why not just use plain dicts and lists? It really does depend on what, if any, functionality we're adding :) – Karl Knechtel Aug 01 '19 at 22:24
  • @Karl: The point would be to eliminate redundantly coding bunches of almost-the-same classes. In other words, to apply the [DRY principle](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) of software development. – martineau Aug 03 '19 at 17:06
  • @Cleb: I think my [other answer](https://stackoverflow.com/a/57337666/355230) addresses your need for individual classes and would appreciate some feedback on it. – martineau Aug 04 '19 at 17:27
  • @martineau; Thanks for the other answer; will take a look soon. We have had a sunny weekend, so I did not spend too much time on looking at code :) Will get back to you soon. – Cleb Aug 04 '19 at 18:45
1

Based on your feedback to my first answer, here's another — completely different — one that would allow you create however many more-or-less independent classes as you wish very easily. I think it's an improvement over than @Karl Knechtel's answer because there's no need to have a decorator and use it to "register" each of the subclasses — that effectively happens automatically by deriving each of them from a common base class.

Basically it's just an adaption of the pattern I used in my answer to the question:

    Improper use of __new__ to generate classes?

class Base:
    class Unknown(Exception): pass

    @classmethod
    def _get_all_subclasses(cls):
        """ Recursive generator of all class' subclasses. """
        for subclass in cls.__subclasses__():
            yield subclass
            for subclass in subclass._get_all_subclasses():
                yield subclass

    def __new__(cls, d):
        """ Create instance of appropriate subclass using type id. """
        type_id = d['type']
        for subclass in cls._get_all_subclasses():
            if subclass.type_id == type_id:
                # Using "object" base class method avoids recursion here.
                return object.__new__(subclass)
        else:  # No subclass with matching type_id found.
            raise Base.Unknown(f'type: {type_id!r}')

    def __repr__(self):
        return f'<{self.__class__.__name__} instance>'


class A(Base):
    type_id = 'type1'

    def __init__(self, d):
        self.name = d['name']
        self.oid = d['oid']
        self.optional_stuff = d.get('optional_stuff')

    def foo(self):
        print('I am class A')


class B(Base):
    type_id = 'type2'

    def __init__(self, d):
        self.name = d['name']
        self.anumber = d['anumber']


alist = [
    {
        'type': 'type1',
        'name': 'dummy',
        'oid': 'some_id'
    },
    {
        'type': 'type2',
        'name': 'bigdummy',
        'anumber': 10
    }
]


object_list = [Base(obj) for obj in alist]
print(f'object_list: {object_list}') # -> [<A instance>, <B instance>]
a = object_list[0]
print(repr(a.name))  # -> 'dummy'
b = object_list[1]
print(repr(b.name))  # -> 'bigdummy'

If you're using Python 3.6+, a more succinct implementation is possible using the object.__init_subclass__() classmethod which was added in that version:

class Base:
    _registry = {}

    @classmethod
    def __init_subclass__(cls, **kwargs):
        type_id = kwargs.pop('type_id', None)
        super().__init_subclass__(**kwargs)
        if type_id is not None:
            cls._registry[type_id] = cls

    def __new__(cls, d):
        """ Create instance of appropriate subclass. """
        type_id = d['type']
        subclass = Base._registry[type_id]
        return object.__new__(subclass)

    def __repr__(self):
        return f'<{self.__class__.__name__} instance>'


class A(Base, type_id='type1'):
    def __init__(self, d):
        self.name = d['name']
        self.oid = d['oid']
        self.optional_stuff = d.get('optional_stuff')

    def foo(self):
        print('I am class A')


class B(Base, type_id='type2'):
    def __init__(self, d):
        self.name = d['name']
        self.anumber = d['anumber']


alist = [
    {
        'type': 'type1',
        'name': 'dummy',
        'oid': 'some_id'
    },
    {
        'type': 'type2',
        'name': 'bigdummy',
        'anumber': 10
    }
]


object_list = [Base(obj) for obj in alist]
print(f'object_list: {object_list}') # -> [<A instance>, <B instance>]
a = object_list[0]
print(repr(a.name))  # -> 'dummy'
b = object_list[1]
print(repr(b.name))  # -> 'bigdummy'
martineau
  • 119,623
  • 25
  • 170
  • 301
  • Seems to work; will check how to use it for my actual problem. – Cleb Aug 07 '19 at 07:58
  • Cleb: I think you'll find that it would provide a good, low maintenance, basis for whatever you're doing. I don't know how realistic the two sample classes are, but what they do in their `__init__()` method could be automated to a large degree as well. – martineau Aug 07 '19 at 16:21