Python кодогенерация на благо ETL

Kate

Administrator
Команда форума
В процессе разработки весьма часто встаёт задача преобразования данных, будь то данные от внешнего источника на пути в базу или данные из базы на пути в отчеты и т.п. Если описывать все необходимые преобразования императивно, то можно довольно скоро загрустить. Можно постараться и сделать всё декларативно, скажем, в виде некоторых dict-ов, в которых задать правила (функции?) по работе с каждым отдельным полем. Но уже на этом этапе появляется несколько проблем:

  • даже если красиво уместить описание необходимых агрегаций в вышеупомянутый dict, то встроенный itertools.groupby требует предварительно отсортированных данных (порой проще считать, что его нет)
  • при обработке, скажем, табличного отчета, на каждой строке будет запускаться дополнительный цикл обхода dict-а с правилами по работе с каждым полем, да еще и дополнительные вызовы лишних ф-й на каждом поле — это замедляет процедуру
  • в python отсутствует встроенный функционал join (пока не завернете во что-нибудь громоздкое и непредсказуемое)
  • обслуживающий код сего декларативного обработчика лениво писать, может быть проблематично переиспользовать и уж тем более тяжело объединять с другими обработчиками в более сложные pipeline-ы
  • сложно (если и вовсе возможно) динамически изменять заданную процедуру обработки

pip install convtools​

Проблемы, описанные выше, наталкивают на мысли: "Было бы неплохо иметь возможность из питона задавать некие конверсии, которые можно цеплять друг за друга, а когда необходимая конверсия уже на руках, вызвать метод и получить узкоспециализированный код. Ну и хорошо бы заиметь group_by & join функциональность."

С этими мыслями и была создана библиотека convtools (conversion tools).

Приведу для примера несколько примитивов, которыми оперирует библиотека:

  • c.item(key_or_index) - задает операцию для обращения по индексам и ключам (может принимать более одного и поддерживает default=... )
  • c.attr(attr_name) - операция обращения к аттрибутам
  • c.call_func(datetime.strptime, c.item("dt"), "%Y-%m-%d") - операция вызова функции (часть аргументов заранее инициализированы)
  • c.iter(c.item("id")) - эквивалентно (item["id"] for item in input_data)
  • c.this().call_method("replace", "abc", "cde") - эквивалентно input_data.replace("abc", "cde")
  • c.item("object_list").pipe(any_other_conversion) - выхлоп любой конверсии можно направить в другую конверсию (в том числе group_by / join) или ф-ю
Перейдем, наконец, к: group_by

from convtools import conversion as c

input_data = [
{"a": 5, "b": "foo"},
{"a": 10, "b": "foo"},
{"a": 10, "b": "bar"},
{"a": 10, "b": "bar"},
{"a": 20, "b": "bar"},
]
# Давайте сгруппируемся по "a" и найдем суммы и первые значения "b"
conv = (
c.group_by(c.item("b"))
.aggregate(
{
"b": c.item("b"),
"a_first": c.ReduceFuncs.First(c.item("a")),
"a_sum": c.ReduceFuncs.Sum(c.item("a")),
} # этот dict можно собрать динамически, можно использовать
# конверсии в качестве ключей, а можно и вовсе поменять на tuple
)
.gen_converter() # в этом месте генерируется и компилируется код конверсии
# если установить black и передать сюда debug=True, то в консоль выведет
# форматированный код
)

assert conv(input_data) == [
{'b': 'foo', 'a_first': 5, 'a_sum': 15},
{'b': 'bar', 'a_first': 10, 'a_sum': 40}]
]
код group_by, сгенерированный в момент вызова gen_converter()
Следующий на очереди: join

from convtools import conversion as c

collection_1 = [
{"id": 1, "name": "Nick"},
{"id": 2, "name": "Joash"},
{"id": 3, "name": "Bob"},
]
collection_2 = [
{"ID": "3", "age": 17, "country": "GB"},
{"ID": "2", "age": 21, "country": "US"},
{"ID": "1", "age": 18, "country": "CA"},
]
input_data = (collection_1, collection_2)

converter = (
c.join(
c.item(0), # collection_1 (т.к. на входе tuple)
c.item(1), # collection_2
c.and_(
c.LEFT.item("id") == c.RIGHT.item("ID").as_type(int),
c.RIGHT.item("age") >= 18,
), # условия для join
how="left",
) # на выходе генератор tuple-ов (left_item, right_item)
.iter({ # итерируемся преобразуем каждый элемент в дикт
"id": c.item(0, "id"), # от левого возьмем id
"name": c.item(0, "name"), # name левого
"age": c.item(1, "age", default=None), # age правого
"country": c.item(1, "country", default=None), # country правого
})
.as_type(list) # приведем к листу, т.к. до сих пор работали с генератором
.gen_converter() # создаём конвертер и храним где удобно
)

assert converter(input_data) == [
{'id': 1, 'name': 'Nick', 'age': 18, 'country': 'CA'},
{'id': 2, 'name': 'Joash', 'age': 21, 'country': 'US'},
{'id': 3, 'name': 'Bob', 'age': None, 'country': None}]
Доступного функционала значительно больше, чем представлено в примерах выше. Ссылки ниже:

Заключение​

Используя эту библиотеку, можно навернуть большое количество сахара в своём коде, при этом соблюдая DRY-принцип и не делая конечный код медленнее, т.к. в нем не будет лишних условий, циклов и вызовов ф-й.

Источник статьи: https://habr.com/ru/post/569398/
 
Сверху