В процессе разработки весьма часто встаёт задача преобразования данных, будь то данные от внешнего источника на пути в базу или данные из базы на пути в отчеты и т.п. Если описывать все необходимые преобразования императивно, то можно довольно скоро загрустить. Можно постараться и сделать всё декларативно, скажем, в виде некоторых 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"},
]
# Давайте сгруппируемся по "b" и найдем суммы и первые значения "a"
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()
def group_by__eu(data_):
global labels_
_none = v_rc
signature_to_agg_data__eu = defaultdict(AggData__eu)
for row__eu in data_:
agg_data__eu = signature_to_agg_data__eu[row__eu["b"]]
if agg_data__eu.v0 is _none:
agg_data__eu.v0 = row__eu["a"]
agg_data__eu.v1 = row__eu["a"] or 0
else:
agg_data__eu.v1 = agg_data__eu.v1 + (row__eu["a"] or 0)
return [
{
"b": signature__eu,
"a_first": (None if agg_data__eu.v0 is _none else agg_data__eu.v0),
"a_sum": (0 if agg_data__eu.v1 is _none else agg_data__eu.v1),
}
for signature__eu, agg_data__eu in signature_to_agg_data__eu.items()
]
def converter_6s(data_):
global labels_
return group_by__eu(data_)
Следующий на очереди: 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-принцип и не делая конечный код медленнее, т.к. в нем не будет лишних условий, циклов и вызовов ф-й.
Пожалуйста, делитесь мыслями / пожеланиями. Спасибо!