Decodificar mensajes JSON concatenados

Python dispone de funciones para codificar y decodificar JSON sin necesidad de instalar ninguna librería adicional, al menos si no necesitamos un rendimiento extremo. Su uso es bastante simple:

>>> import json
>>> json.dumps({1:'hola'})
'{"1": "hola"}'
>>> json.loads('{"1": "hola"}')
{'1': 'hola'}

El uso es simple, sí, pero la traducción entre la estructura Python y JSON es imperfecta debido a limitaciones del estándar JSON. Es un tema a tener en cuenta, pero que no nos interesa ahora mismo.

El problema consiste en decodificar secuencias JSON concatenadas. La forma evidente no funciona:

>>> json.loads('{"1": "hola"}{"3":"adiós"}')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/lib/python3.7/json/__init__.py", line 348, in loads
    return _default_decoder.decode(s)
  File "/usr/local/lib/python3.7/json/decoder.py", line 340, in decode
    raise JSONDecodeError("Extra data", s, end)
json.decoder.JSONDecodeError: Extra data: line 1 column 14 (char 13)

Esta función requiere decodificar un único objeto JSON completo.

¿Qué ocurre si tenemos una secuencia de objetos JSON? Lo evidente es llevar la cuenta de llaves abiertas y cerradas ({ y }), pero eso supone considerar también que una llave puede formar parte de una cadena de texto. La aproximación directa es frágil y con varios casos especiales. ¿Qué ocurre además si estamos recibiendo los objetos JSON a través de la red y tenemos fragmentos incompletos?

La solución a todo esto es el método json.JSONDecoder.raw_decode(). Este método intenta decodificar un objeto JSON. Esto puede fallar con una excepción si el objeto JSON está incompleto o bien proporcionarnos el objeto decodificado y la posición donde termina en la cadena de entrada. Con ese valor sabremos dónde empieza el siguiente objeto JSON.

Veamos un ejemplo real de código en producción:

 class stream_json():
     def __init__(self, reader):
         self.reader = reader
         self.buf = ''
         self.JSONDecoder = json.JSONDecoder()

     async def get_next(self):
         while True:
             # Puede haber más de una operación por
             # lectura de red.
             if self.buf:
                 try :
                     obj, idx = self.JSONDecoder.raw_decode(self.buf)
                     self.buf = self.buf[idx:]
                     return obj
                 except json.JSONDecodeError:  # Necesitamos más datos
                     pass

             data = await self.reader.read(1000)
             if not data:
                 raise EOFError()
             # Esto puede fallar si la lectura termina
             # en un carácter incompleto.
             data = data.decode('utf-8')

             self.buf += data

Esta clase Python real utiliza asyncio y hace lo que requerimos.

Las líneas 1-5 son el constructor del objeto. Se toma nota del objeto a utilizar para pedir más datos a la red (vía asyncio) y creamos el búfer que contendrá los objetos JSON pendientes de ser decodificados (el último posiblemente incompleto).

El método asíncrono get_next() proporciona el siguiente objeto JSON. Su funcionamiento es simple:

Si tenemos datos en el búfer (línea 11), se intentan decodificar. Si tenemos éxito, se devuelve el objeto decodificado y lo eliminamos del búfer (líneas 14-15). Si ocurre un error, será porque hemos recibido un objeto JSON incompleto y tenemos que recibir más datos (línea 19). Por supuesto, el canal de entrada puede cerrarse en cualquier momento (líneas 20-21). Recibimos datos en binario, los pasamos a UTF-8 (línea 24), los añadimos al búfer de lectura e intentamos una nueva decodificación.

Esta rutina gestiona correctamente el recibir un objeto JSON de forma parcial o bien el recibir varios objetos JSON en una única lectura de red.