Класс O: работаем с JSON как с объектом, а не как со словарём

Класс O: работаем с JSON как с объектом, а не как со словарём

Когда пишешь интеграцию с незнакомым API и SDK для него нет, основной способ работать с ответом — dict и квадратные скобки. Читается это быстро только у авторов доков контракта, для всех остальных хочется оформить цепочку payload["data"]["items"][0]["price"] как payload.data.items[0].price. Класс O — крошечный хелпер, который умеет именно это.

Что делает класс

O — наследник dict с двумя возможностями:

  • десериализация: превратить сырые dict/list из json.loads в вложенные объекты с атрибутным доступом — O.load(...);
  • сериализация: объяснить json.dumps, как превращать значение обратно в обычные типы, — через O.default.

Вот весь код:

from typing import Any


class O(dict):
    @staticmethod
    def default(x: Any) -> Any:
        if isinstance(x, O):
            return dict(x.items())
        return x

    @classmethod
    def load(cls, d: Any) -> Any:
        if isinstance(d, list):
            return [cls.load(x) for x in d]
        if isinstance(d, dict):
            return cls((x, cls.load(y)) for x, y in d.items())
        return d

    def __getattr__(self, key: str) -> Any:
        try:
            return self[key]
        except KeyError:
            raise AttributeError(key) from None

    def __setattr__(self, key: str, value: Any) -> None:
        self[key] = value

Разбор по кусочкам:

  • __getattr__ — вызывается, когда у объекта не нашли атрибут. Мы заворачиваем его в self[key]: o.user превращается в o["user"]. Если ключа нет — честный AttributeError, чтобы промахи не падали молча.
  • __setattr__ — зеркальная запись: o.user = x пишет в o["user"] = x.
  • load(cls, d) — рекурсивно обходит dict и list и строит из них O. Строки, числа и bool проходят как есть.
  • default(x) — колбэк для json.dumps(..., default=...): умеет превращать O обратно в обычный dict.

Десериализация: читаем ответ API

Пусть некий гипотетический сервис вернул такой JSON:

{
  "user": {
    "name": "Аня",
    "email": "anya@example.com",
    "roles": ["admin", "editor"]
  },
  "plan": {
    "name": "pro",
    "price": 990
  }
}
import json

raw = '{"user": {"name": "Аня", "roles": ["admin", "editor"]}, "plan": {"price": 990}}'
data = O.load(json.loads(raw))

print(data.user.name)            # Аня
print(data.user.roles[0])        # admin
print(data.plan.price)           # 990
Аня
admin
990

Привычные скобки и кавычки остаются только внутри json.loads. Дальше — сплошные атрибуты.

Промахнулись ключом — получите AttributeError

Нет ключа — O не молчит, а бросает AttributeError. Это удобно: опечатка видна сразу, а не отдаёт None, который где-то тихо поломает логику.

data = O.load({"user": {"name": "Аня"}})
print(data.user.name)
print(data.user.surname)  # AttributeError: surname
Аня
AttributeError: surname

Сериализация: собираем объект и отдаём JSON

O можно собирать с нуля либо через конструктор, либо через присваивание атрибутов — и отдавать json.dumps.

import json

payload = O()
payload.user = O()
payload.user.name = "Борис"
payload.user.roles = O.load(["reader", "editor"])
payload.plan = O(price=990, name="pro")   # dict(**kwargs) тоже работает

print(json.dumps(payload, ensure_ascii=False, default=O.default))
{"user": {"name": "Борис", "roles": ["reader", "editor"]}, "plan": {"price": 990, "name": "pro"}}

Так как O — это dict, json.dumps и без default= сам отдаст корректный объект. O.default нужен там, где во вложенных значениях встречается что-то нестандартное, — правда, он умеет превращать в обычный dict только сам класс O. Для произвольного типа вроде Money придётся дописать собственный колбэк, иначе json.dumps упадёт.

class Money:
    def __init__(self, rub, kop):
        self.rub, self.kop = rub, kop

payload = O(price=Money(990, 50))
print(json.dumps(payload, default=O.default))  # TypeError! O.default не знает Money
TypeError: Object of type Money is not JSON serializable

Круг замкнулся: load и round-trip

Десериализовали → дозаполнили → сериализовали → снова распарсили. Объект переживает путешествие без потерь.

import json

payload = O.load(json.loads('{"user": {"name": "Аня"}}'))
payload.user.city = "Москва"

wire = json.dumps(payload, default=O.default)
again = O.load(json.loads(wire))

print(again.user.name, again.user.city)
Аня Москва

Когда это реально удобно

  • Чужие API без SDK. Один O.load(json.loads(r.text)) превращает незнакомый ответ в объект, по которому точкой добраться до любого поля. IDE не подскажет пути внутри json-структур, зато код становится короче, а «квадратные скобки + кавычки» исчезают.
  • Быстрые скрипты и прототипы. Написать парсер ответа в одну строчку вместо вложенных get() и ["..."].
  • Тесты и глубокая вложенность. Сравнивать a.deep.path вместо цепочек индексов, меньше шума при рефакторинге структуры.

Единственный нюанс: цепочка вида data.plan.price работает только для ключей-идентификаторов, так что для полей вроде "123" или "created-at" придётся вернуться к data["123"]. Но это редкий случай, а доступ по атрибуту остаётся основным.

Вывод

Десять строчек класса — и работа с незнакомым API перестаёт жонглировать скобками и кавычками. O.load десериализует, O.default сериализует, а сам объект остаётся обычным dict, совместимым со всей экосистемой json. Держите под рукой для любой интеграции.

Комментарии

Пока нет комментариев.

Войдите, чтобы комментировать.