7.5. JSON Decoder

7.5.1. Problem

  • Problem with date, datetime, time, timedelta

  • Python does not decode values automatically

>>> from datetime import date
>>> import json
>>>
>>>
>>> DATA = """
...     {"firstname": "Mark",
...      "lastname": "Watney",
...      "born": "1994-10-12"}"""
>>>
>>>
>>> result = json.loads(DATA)
>>>
>>> print(result)  
{'firstname': 'Mark',
 'lastname': 'Watney',
 'born': '1994-10-12'}

7.5.2. Function Decoder

>>> from datetime import date
>>> import json
>>>
>>>
>>> DATA = """
...     {"firstname": "Mark",
...      "lastname": "Watney",
...      "born": "1994-10-12"}"""
>>>
>>>
>>> def decoder(data: dict) -> dict:
...     for field, value in data.items():
...         if field == 'born':
...             data[field] = date.fromisoformat(value)
...     return data
>>>
>>>
>>> result = json.loads(DATA, object_hook=decoder)
>>>
>>> print(result)  
{'firstname': 'Mark',
 'lastname': 'Watney',
 'born': datetime.date(1994, 10, 12)}

7.5.3. Context Dependency Injection

>>> from datetime import date
>>> import json
>>>
>>>
>>> DATA = """
...     {"firstname": "Mark",
...      "lastname": "Watney",
...      "born": "1994-10-12"}"""
>>>
>>>
>>> class Decoder(json.JSONDecoder):
...     def __init__(self):
...         super().__init__(object_hook=self.default)
...
...     def default(self, data: dict) -> dict:
...         for field, value in data.items():
...             if field == 'born':
...                 data[field] = date.fromisoformat(value)
...         return data
>>>
>>>
>>> result = json.loads(DATA, cls=Decoder)
>>>
>>> print(result)  
{'firstname': 'Mark',
 'lastname': 'Watney',
 'born': datetime.date(1994, 10, 12)}

7.5.4. Use Case - 0x01

>>> from datetime import datetime, date, time, timedelta
>>> import json
>>>
>>>
>>> DATA = """
...     {"name": "Mark Watney",
...      "born": "1994-10-12",
...      "launch": "1969-07-21T02:56:15",
...      "landing": "12:30:00",
...      "duration": 13}"""
>>>
>>>
>>> class MyDecoder(json.JSONDecoder):
...     def __init__(self):
...         super().__init__(object_hook=lambda data: {
...                 field: self.default(field, value)
...                 for field, value in data.items()})
...
...     def default(self, field, value):
...         result = {
...             'born': lambda x: date.fromisoformat(x),
...             'launch': lambda x: datetime.fromisoformat(x),
...             'landing': lambda x: time.fromisoformat(x),
...             'duration': lambda x: timedelta(days=x),
...         }.get(field, value)
...         return result(value) if callable(result) else result
>>>
>>>
>>> result = json.loads(DATA, cls=MyDecoder)
>>>
>>> print(result)  
{'name': 'Mark Watney',
 'born': datetime.date(1994, 10, 12),
 'launch': datetime.datetime(1969, 7, 21, 2, 56, 15),
 'landing': datetime.time(12, 30),
 'duration': datetime.timedelta(days=13)}

7.5.5. Use Case - 0x02

>>> from datetime import date, time, datetime, timedelta
>>> import json
>>>
>>>
>>> DATA = """
...     {"name": "Mark Watney",
...      "born": "1994-10-12",
...      "launch": "1969-07-21T02:56:15",
...      "landing": "12:30:00",
...      "duration": 13}"""
>>>
>>> class MyDecoder(json.JSONDecoder):
...      name: str
...      born: date
...      launch: datetime
...      landing: time
...      duration: timedelta
...
...      def __init__(self) -> None:
...          super().__init__(object_hook=lambda data: {
...                  field: getattr(self, method)(value)
...                  for field, value in data.items()
...                  if (method := self.__annotations__.get(field, str).__name__)})
...
...      def datetime(self, value: str) -> date:
...          return datetime.fromisoformat(value)
...
...      def date(self, value: str) -> date:
...          return date.fromisoformat(value)
...
...      def time(self, value: str) -> date:
...          return time.fromisoformat(value)
...
...      def timedelta(self, value: str) -> date:
...          return timedelta(days=value)
...
...      def str(self, value: str) -> str:
...          return str(value)
>>>
>>>
>>> result = json.loads(DATA, cls=MyDecoder)
>>>
>>> print(result)  
{'name': 'Mark Watney',
 'born': datetime.date(1994, 10, 12),
 'launch': datetime.datetime(1969, 7, 21, 2, 56, 15),
 'landing': datetime.time(12, 30),
 'duration': datetime.timedelta(days=13)}

7.5.6. Use Case - 0x03

>>> import json
>>> from dataclasses import dataclass, field
>>> from pprint import pprint
>>>
>>>
>>> @dataclass
... class Mission:
...     year: int
...     name: str
>>>
>>>
>>> @dataclass
... class Astronaut:
...     lastname: str
...     firstname: str
...     missions: list[Mission] = field(default_factory=list)
>>>
>>>
>>> CREW = [
...     Astronaut('Mark', 'Watney', missions=[
...         Mission(1973, 'Apollo18'),
...         Mission(2035, 'Ares3'),
...     ]),
...
...     Astronaut('Melissa', 'Lewis', missions=[
...         Mission(2035, 'Ares3'),
...     ]),
...
...     Astronaut('Rick', 'Martinez'),
... ]
>>>
>>>
>>> class MyEncoder(json.JSONEncoder):
...     def default(self, obj):
...         data = vars(obj)
...         data['__clsname__'] = obj.__class__.__name__
...         return data
>>>
>>>
>>> class MyDecoder(json.JSONDecoder):
...     def __init__(self):
...         super().__init__(object_hook=self.default)
...
...     def default(self, data: dict) -> dict:
...         clsname = data.pop('__clsname__')
...         cls = globals()[clsname]
...         return cls(**data)
>>> result = json.dumps(CREW, cls=MyEncoder)
>>>
>>> pprint(result, width=72)
('[{"lastname": "Mark", "firstname": "Watney", "missions": [{"year": '
 '1973, "name": "Apollo18", "__clsname__": "Mission"}, {"year": 2035, '
 '"name": "Ares3", "__clsname__": "Mission"}], "__clsname__": '
 '"Astronaut"}, {"lastname": "Melissa", "firstname": "Lewis", '
 '"missions": [{"year": 2035, "name": "Ares3", "__clsname__": '
 '"Mission"}], "__clsname__": "Astronaut"}, {"lastname": "Rick", '
 '"firstname": "Martinez", "missions": [], "__clsname__": '
 '"Astronaut"}]')
>>> result = json.loads(result, cls=MyDecoder)
>>>
>>> pprint(result)  
[Astronaut(lastname='Mark',
           firstname='Watney',
           missions=[Mission(year=1973, name='Apollo18'),
                     Mission(year=2035, name='Ares3')]),
 Astronaut(lastname='Melissa',
           firstname='Lewis',
           missions=[Mission(year=2035, name='Ares3')]),
 Astronaut(lastname='Rick', firstname='Martinez', missions=[])]

7.5.7. Assignments

Code 7.15. Solution
"""
* Assignment: JSON Decoder Martian
* Complexity: medium
* Lines of code: 11 lines
* Time: 13 min

English:
    1. Define `result: dict` with decoded `DATA` from JSON
    2. Run doctests - all must succeed

Polish:
    1. Zdefiniuj `result: dict` z odkodowanym `DATA` z JSON
    2. Uruchom doctesty - wszystkie muszą się powieść

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from inspect import isclass

    >>> assert isclass(Decoder), \
    'Decoder must be a class'

    >>> assert issubclass(Decoder, json.JSONDecoder), \
    'Decoder must inherit from `json.JSONDecoder`'

    >>> assert type(result) is dict, \
    'Result must be a dict'

    >>> assert len(result) > 0, \
    'Result cannot be empty'

    >>> assert all(type(key) is str
    ...            and type(value) in (str, datetime, list)
    ...            for key, value in result.items()), \
    'All keys must be str and all values must be either str, datetime or list'


    >>> result  # doctest: +NORMALIZE_WHITESPACE
    {'mission': 'Ares 3',
     'launch_date': datetime.datetime(2035, 6, 29, 0, 0),
     'destination': 'Mars',
     'destination_landing': datetime.datetime(2035, 11, 7, 0, 0),
     'destination_location': 'Acidalia Planitia',
     'crew': [{'name': 'Melissa Lewis', 'born': datetime.date(1995, 7, 15)},
              {'name': 'Rick Martinez', 'born': datetime.date(1996, 1, 21)},
              {'name': 'Alex Vogel', 'born': datetime.date(1994, 11, 15)},
              {'name': 'Chris Beck', 'born': datetime.date(1999, 8, 2)},
              {'name': 'Beth Johansen', 'born': datetime.date(2006, 5, 9)},
              {'name': 'Mark Watney', 'born': datetime.date(1994, 10, 12)}]}
"""

import json
from datetime import datetime


DATA = """
    {"mission": "Ares 3",
     "launch_date": "2035-06-29T00:00:00",
     "destination": "Mars",
     "destination_landing": "2035-11-07T00:00:00",
     "destination_location": "Acidalia Planitia",
     "crew": [{"name": "Melissa Lewis", "born": "1995-07-15"},
              {"name": "Rick Martinez", "born": "1996-01-21"},
              {"name": "Alex Vogel", "born": "1994-11-15"},
              {"name": "Chris Beck", "born": "1999-08-02"},
              {"name": "Beth Johansen", "born": "2006-05-09"},
              {"name": "Mark Watney", "born": "1994-10-12"}]}"""


class Decoder:
    ...


# dict[str, str|list|datetime]: with decoded DATA
result = ...