Как я переизобрел словари в Python

    В нашем Django-приложении необходимо было разработать отчет (расчет) бонусов.
    Отчет должен иметь вложенную структуру с подведением итогов по пользователям, подразделениям и по всей компании. Схематично его логику можно представить:

    print total
    for department in departments:
        print department.total
        for user in department.users:
            print user.total
            for row in user.rows:
                print row.data

    У этого отчета было два осложняющих момента:

    1. В роли "row" могли выступать разные модели (и располагаться вперемежку), что не позволяет использовать итераторы по QuerySet'ам.
    2. Время построение отчета. Сбор данных занимает существенное время (несколько секунд). Данные в отчете могут меняться. Говоря на чистоту, это не статический отчет, а инструмент для контроля и корректировки начисленных бонусов в виде отчета. Но данные меняются не очень часто, скажем на каждые 100 просмотров придется одно изменение, после которого нужно перестроить отчет. Т.е. данные можно кэшировать.

    Структура из вложенных словарей отлично решает обе задачи: в них можно сложить все требуемые скаляры (числа, строки, даты), сериализовать и сложить в кэш.

    Структура данных для отчета приобрела вид (упрощена):

    {
        'total': {
            'income': 1234,
            'bonus': 123,
            'expense': 1234,
            'penalty': 123
        },
        'departments': {
            '{dept_id}': {
                'department': {
                    'title': 'Mega Department'
                }
                'total': {
                    'income': 1234,
                    'bonus': 123,
                    'expense': 1234,
                    'penalty': 123
                },
                'users': {
                    '{user_id}': {
                        'user': {
                            'name': 'John Smith'
                        },
                        'total': {
                            'income': 1234,
                            'bonus': 123,
                            'expense': 1234,
                            'penalty': 123
                        },
                        'rows': {
                            '{sale_id}': {        //  Одна модель
                                'type': 'sale'
                                'base_income': 1234,
                                'bonus': 123,
                                'comment': 'some description'
                            },
                            '{expense_id}': {     //  Другая модель !!!
                                'type': 'expense'
                                'expense': 1234,
                                'penalty': 123,
                                'comment': 'some description'
                            },
                            ...
                        }
                    },
                    ...
                }
            },
            ...
        }
    }

    И вот тут-то я столкнулся с проблемой, что заполнение такой структуры из словарей не столь удобно, как мне того хотелось. Проверка словарей на наличие ключей или использование setdefatult(key, {}) превращает код в нечитабельную кашу.

    Эта структура чем-то напоминает XML. И мне бы хотелось использовать что-то подобное тому, как строятся XPath-выражения для адресации узлов XML-дерева:

    /departments/{dept_id}/users/{user_id}/rows/{row_id}/base_income

    или на языке Python что-то вида:

    data.departments.{dept_id}.users.{user_id}.rows.{row_id}.base_income

    Учтывая, что {dept_id} и прочие другие {id} — целые числа, то я разрешил себе использование квадратных скобок: [].

    data.departments[{dept_id}].users[{user_id}].rows[{row_id}].base_income

    Собственно мне нужен был такой класс, который бы вел себя в основном, как словарь, но при этом:

    1. доступ к атрибутам можно было делать без квадратных скобочек
    2. автоматически создавались отсутствующие аттрибуты

    Так появился ElasticDict

    В итоге


    Код по подготовке данных выглядит приблизительно так:

    data = ElasticDict()
    for sale in Sale.objects.filter(...).prefetch_related(...):
        data.departments[sale.user.department.pk].users[sale.user.pk].rows[sale.pk] = {'base_income': sale.amount, 'bonus': sale.calc_bonus()}
    
    # или в другой форме, кому как больше нравится
    for expense in Expense.objects.filter(...).prefetch_related(...):
        data.departments[sale.user.department.pk].users[sale.user.pk].rows[expense.pk].base_expense = expense.amount
        data.departments[sale.user.department.pk].users[sale.user.pk].rows[expense.pk].penalty = expense.calc_penalty()

    Код в шаблоне так:

    {{ data.total }}
    {% for dept_id, department in data.departments.items %}
        {{ department.total }}
        {% for user_id, user in department.users.items %}
            {{ user.total }}
            {% for row_id, row in user.rows.items %}:
                {{ row.data }}
            {% endfor %}
        {% endfor %}
    {% endfor %}

    Заключение


    Надо отметить, что ElasticDict() это подкласс обычного dict()'а, т.е. в нем доступно все то, что и в обычном словаре. В тот момент, когда потребуется "зафиксировать" структуру (снова захотим получать KeyError'ы при обращении к несуществующим ключам), экземпляр ElasticDict можно экспортировать в обчный dict(). Делается рекурсивный обход ElasticDict(), где все экземпляры этого класа заменяются на обычные словари. Есть и обратное преобразование — на вход подаем словарь, на выходе получаем ElasticDict также с рекурсивным обходом.

    Замечания/предложения приветствуются!

    UPDATE из англоговорящей тусовки подсказали, что уже есть аналог addict. Думаю, тем, кто проголосовал "мне надо" следует переключиться на него, как на более стабильный (проверенный).

    Only registered users can participate in poll. Log in, please.

    Оно вам надо?

    • 14.7%Конечно, давно хотел!14
    • 36.8%Ну не знаю…35
    • 48.4%Ерунда какая-то46

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 8

      0
      Батенька да вы же гигант мысли, да это же просто гениальное изобретение, не знаю, как я мог жить без этого, ведь {}.get('key',None) так не удобен…

      Дамс дожили, написал велосипед в ~100 строках — выложил на хабр. Что дальше? Будем стандартную библиотек изобретать?
        –1
        Этот класс не претендует на вселенское открытие или всепоглощающую сингулярность.

        Просто мне показалось, что вот так писать — это не по-питоновски:

        data = {}
        data.setdefault('departments', {}).setdefault(sale.user.department.pk, {}).setdefault('users', {}).setdefault('sale.user.pk', {}).setdefault('rows', {}).setdefault(sale.pk, {}) = {...}

        Хотелось вот так:

        data = ElasticDict()
        data.departments[sale.user.department.pk].users[sale.user.pk].rows[sale.pk] = {...}

        P.S. {}.get('key',None) — не то делает, что мне нужно.
          +4
          Выглядит, конечно, диковато, но

          from collections import defaultdict
          
          data = defaultdict(lambda: defaultdict(lambda: defaultdict(lambda: defaultdict(lambda: defaultdict(dict)))))
          data['departments'][sale.user.department.pk]['users']['sale.user.pk']['rows'][sale.pk] = {...}
            +4
            Можно рекурсивно:

            from collections import defaultdict
            
            recdict = lambda: defaultdict(recdict)
            
            data = recdict()
            data["qwe"]["asd"] = 123
              0
              Спасибо за идеи, мне вот еще одну подкинули в issue:

              from collections import defaultdict
              
              class DotDict(defaultdict):
                  def __getattr__(self, attr):
                      return self.__getitem__(attr)
              
                  def __setattr__(self, attr, val):
                      return self.__setitem__(attr, val)
              
              def ElasticDict():
                  return DotDict(ElasticDict)
              
              data = ElasticDict()
              data.divisions.sales.persons[123].name = 'Alex'
              print data.divisions.sales.persons[123].name

              И дали ссылку на более продвинутую реализацию моего подхода: addict
            –3
            >P.S. {}.get('key',None) — не то делает, что мне нужно.

            в статье:
            >>1. доступ к атрибутам можно было делать без квадратных скобочек
            >> 2. автоматически создавались отсутствующие аттрибуты
          +2
          Проверка словарей на наличие ключей или использование setdefatult(key, {}) превращает код в нечитабельную кашу.

          Окей, давайте сравним. Ваше решение:

          from elasticdict import ElasticDict
          
          data = ElasticDict()
          
          for sale in Sale.objects.filter(...).prefetch_related(...):
              data.departments[sale.user.department.pk].users[sale.user.pk].rows[sale.pk] = {
                  'base_income': sale.amount, 
                  'bonus': sale.calc_bonus()
              }

          Решение в лоб, без каких-либо зависимостей, даже без стандартного defaultdict:

          data = {'departaments': {}}
          
          for sale in Sale.objects.filter(...).prefetch_related(...):
              departament = data['deparaments'].setdefault(sale.user.department.pk, {'users': {}})
              user = departament['users'].setdefault(sale.user.pk, {'rows': {}})
              user['rows'][sale.pk] = {
                  'base_income': sale.amount, 
                  'bonus': sale.calc_bonus()
              }

          Вопрос автору: оно того стоило?
            +1
            Согласен, ради конкретно такого примера не стоило бы.

            В моей задаче строчек для вставки в словарь сильно больше (30+), вдобавок, есть внутренняя логика if/else.
            Создание промежуточных переменных не добавляет читаемости.
            Я не настаиваю, что мой код абсолютно более читаемый, но найдутся те, кто со мной согласятся.
            Равно как найдутся те, кто скажет, что это отстой — и последних в 4 раза больше :)

          Only users with full accounts can post comments. Log in, please.