Python Заметки
2.31K subscribers
58 photos
2 videos
2 files
212 links
Интересные заметки и обучающие материалы по Python

Контакт: @paulwinex

⚠️ Рекламу на канале не делаю!⚠️

Хештеги для поиска:
#tricks
#libs
#pep
#basic
#regex
#qt
#django
#2to3
#source
#offtop
Download Telegram
Исследуем бинарный файл с изображением. Файл примера можно забрать сразу после этого поста.
Для простоты эксперимента, я сделал BMP файл размером 1х1 пиксель, сохранённый без сжатия.
Наша задача — достать RGB информацию этого единственного пикселя.
Файл я сделал в Photoshop и закрасил пиксель цветом [255, 128, 50]. Сохранил с глубиной цвета 24 бит (по 8 бит на канал, то есть 1 байт).

Вооружившись спецификацией формата BMP мы можем рассчитать где что записано в таком файле.

В начале файла записаны заголовки с различной информацией. Для её отображения можете использовать этот код.
Эти данные активно используют программы-просмотрщики. Например, первые два байта это сигнатура файла (вспоминаем посты по этой тему).
Полезное для нас поле - DataOffset, которое говорит где начинаются данные относительно начала файла.

Offset: 54

То есть, с начала файла надо пропустить 54 байта, после чего пойдут пиксели! Так и сделаем.

Открываем файл

file = open('one_pixel.bmp', 'rb')

Пропускаем 54 байта

file.seek(54)

В разделе Additional Info в спецификации написано, что порядок записи каналов такой: BGR. Поэтому забираем наши данные о каналах в таком же порядке

b, g, r = struct.unpack('BBB', file.read(3))
file.close()

Почему "B"? Потому что в спецификации указано, что на канал использовано по 1 байту.
Проверяем

print(f'R:{r} G:{g} B:{b}')
R:255 G:128 B:50

Отлично, мы добыли то что нам требовалось 😊

PS:
Кто в теме, может покопаться в скрипте для парсинга бинарника mb-файла (файл сцены Autodesk Maya)
https://github.com/westernx/mayatools/blob/master/mayatools/binary.py

#libs
one_pixel.bmp
60 B
BMP пиксель для тестов
Пора нам придумать свой бинарный формат😉
В качестве примера я запишу в файл анимационный канал из объекта Autodesk Maya.

Можете открыть мой код↗️ и следить по тексту.
Если у вас есть Maya, то можно даже запустить код и посмотреть на результат.

🔸Начнём запись!
Сначала запишем имя канала, это будет имя атрибута, с которого пишется анимация. Сделаем предел в 64 байта.

struct.pack('=64s', channel_name.encode())

Далее диапазон кадров, это два числа типа long

struct.pack('=2L', start_frame, end_frame)

Потом пишем анимацию в виде массива float значений. В примере запись идёт покадрово, то есть мы не загружаем весь массив ключей в память.

for i in range(start_frame, end_frame + 1):
val = obj.attr(channel_name).get(t=i)
f.write(struct.pack('=f', val))

Всё, файл готов! 😉

Итого у нас получился такой формат "=64s2L{N}f", где {N} это количество записанных значений.

🔸Теперь чтение.
Считываем первые 64 байта, это имя канала.
Первое с чем столкнёмся, это нулевые байты в имени канала, которые заполняют свободное пространство в выделенных 64 байтах. Просто удаляем их.

struct.unpack('=64s', f.read(64))[0].rstrip(b'\x00')

Читаем диапазон кадров, записанный в этот файл.

frange_len = struct.calcsize('=2L')

Функция struct.calcsize() возвращает размер данных в зависимости от указанного формата. Используем это чтобы прочитать нужное количество байт из файла.

start_frame, end_frame = struct.unpack('=2L', f.read(frange_len))

Из диапазона рассчитаем длину анимации и забираем массив float значений. В коде есть вариант чтения по одному значению и полностью весь массив.

key_count = end_frame - start_frame + 1
frmt = f'={key_count}f'
keys = struct.unpack(frmt, f.read(struct.calcsize(frmt)))

Всё, данные прочитаны!😎

Остальной код примера связан с манипуляцией объектами в Maya, чтобы визуально можно было увидеть, что анимация корректно восстановилась из файла.

Вот таким образом мы придумали свой бинарный формат данных.
Возможно, такие сложности вам покажутся излишними, но представьте когда данных действительно много, и один кадр содержит миллионы позиций 3D точек, и записать требуется 50000 кадров!
Всё это в оперативку явно не поместится, придётся для каждого кадра делать отдельный файл. Если же мы можем писать данные постепенно, то это не проблема. Можно постепенно заполнять файл или писать несколько файлов паралельно.

#libs #tricks
В модуле struct есть класс Struct, специально для тех то любит в ООП.
Возможно, кому-то будет удобней работать с классом вместо функций.

Один раз указываем формат в конструкторе класса и получаем удобные свойства и методы.

>>> st_head = struct.Struct('<20s')
>>> st_head.format
'<20s'
>>> st_values = struct.Struct('=100i')
>>> st_values.size
400

Для запаковки или распаковки просто передаём данные в соответствующие методы.

>>> st_head.pack(b'some_name')
b'some_name\x00\x00...'
>>> st_values.pack(*range(100))
b'\x00\x00\x00\x00\x01\x00\x00...'

#libs #tricks
Формат структуры поддерживает две удобные фишки

▫️ Вместо дублирования токена можно указать цифру и сразу после неё нужный токен (это вы уже знаете по прошлым постам).

struct.pack('=10s', data)

▫️ Для визуального удобства токены можно разделять пробелами, но не каунтеры (цифры перед токеном)

struct.pack('= 10s I I 100Q', *items)

#libs #tricks
Чем отличается тип bytes от bytearray? Всё просто, bytes неизменяемый тип, а bytearray изменяемый.

Что это нам даёт? Как известно, строка это неизменяемый тип. Всякий раз когда вы делаете любые манипуляции со строкой вы создаёте новую строку.
Если же её преобразовать в bytearray то все изменения будут происходить с оригинальным объектом без копирования.

Создаём массив

>>> arr = bytearray(struct.pack('=11s', b'Hello World'))
bytearray(b'Hello World')

Можем добавить элемент в массив

>>> arr.append(0)
bytearray(b'Hello World\x00')

Или удалить лишний элемент по индексу

>>> del arr[-1]
bytearray(b'Hello World')

Для добавления в строку используем extend

>>> arr.extend(b'!')
bytearray(b'Hello World!')

С помощью pack_into() вставляем данные в имеющийся массив заменяя данные

>> struct.pack_into("=6s", arr, 6, b'Python')
bytearray(b'Hello Python')

Достаём результат

>>> struct.unpack("=12s", arr)[0]
b'Hello Python'

И всё это мы сделали не создавая новых объектов! Это и экономит память, и выполняется быстрей, так как мы работаем с одним и тем же объектом.

#tricks #libs
­Уже полтора года как Python2 отправился на пенсию.
Как идёт процесс перехода на 3ю ветку?
В вебе всё более менее нормально. Django начиная с 2.0 и недавно вышедший Flask 2.0 официально больше не поддерживают Python 2.
На странице Qt for Python вторая ветка пропала из таблицы поддерживаемых версий. Теперь минимальная версия 3.8.

Но меня больше интересует готовность CG-софта. Я предполагал, что период перехода займёт от 3 до 5 лет. При том что резко, как с web, перескочить не получится и какое-то время придётся поддерживать обе ветки (как это сделали с Houdini и Maya). А ведь переделывать там ой как много.
Но, к счастью, процесс идёт достаточно бодро! Судя по этой статистике три четверти приложений уже на Ру3!😊 Остальные догоняют.

Надеюсь план по переходу на 3й Python будет завершён к концу 2021 года.

#2to3
Библиотека pstray поможет легко создать иконку в системном трее максимально нативными средствами системы без тяжеловесного Qt и ему подобных. Здесь же есть средства создать меню, нотификации и даже radio button.

#libs
Что-нибудь слышали про Лабораторию динамики флуоресценции?
Если не интересуетесь конкретно этой наукой то здесь вам ловить нечего. Кроме одного момента! Сотрудник лаборатории Christoph Gohlke поддерживает неофициальную библиотеку бинарников для Python под Windows. Большая коллекция скомпиленных библиотек под разные версии Python.

Именно здесь я долгое время качал старую версию PySide под Python2 и OpenImageIO, пока не потребовалось собрать её иначе.

В общем, всем тем кто на Windows, советую страничку в закладки. Также будет полезно тем кто еще на Python2.
Кстати, эта коллекция всё еще обновляется.

#libs #2to3
Тип строки в Python имеет очень много удобных методов. Сегодня пост про два таких метода которые чаще всего используются "однобоко". Это методы startswith() и endswith()

Самый обычный сценарий использования — проверка, начинается ли строка с указанной подстроки?

>>> "some_string".startswith("some")
True

И аналогичная ситуация с зеркальным вариантом этой функции, проверка совпадения с конца

>>> "some_string".endswith("some")
False

Так они используются в большинстве случаев что я видел. Но у этих функций есть еще два варианта использования.

🔸 Сравнение нескольких подстрок
Для проверки нескольких подстрок в одной строке обычно вызывают эти функции несколько раз. Но на самом деле достаточно передать кортеж со всеми строками один раз. Если будет хоть одно совпадение то функция вернёт True.

>>>"my_image.png".endswith(("jpg", "png", "exr"))
True

🔸 Диапазон поиска
Вторым аргументом можно передать индекс символа с которого следует начать сравнение, а третий аргумент это индекс последнего символа.

>>> ".filename.ext".startswith("file", 1)
True
>>> "file_###.ext".endswith('#', 0, -4)
True

Индексы можно указать отрицательными, что означает отсчёт с конца.

#trics #basic
Модуль ensurepip, стал стандартным начиная с версии 3.4 и портирован в 2.7

Это встроенная альтернатива файлу get-pip.py. Модуль позволяет установить или обновить pip.

🔸Установка pip:

python -m ensurepip

🔸Обновление до актуальной версии

python -m ensurepip --upgrade

🔸Установка в директорию юзера, если вас не устраивает системный или просто нет доступа для обновления (когда не используем venv, то есть ставим глобально)

python -m ensurepip --user

#libs #basic
Недавно писал тесты для модуля, который рисует на картинках текст и разные фигуры.
Обычные ошибки в коде можно поймать простым исключением. Но как убедиться что нарисовано именно то что надо? Например цвет правильный или шрифт выбран верно. Для этого нужно визуально сравнивать правильный рендер и тест.

Чтобы авто тесты оставались "авто", я использовал библиотеку imgcompare
С помощью неё достаточно просто сравнить два изображения и получить процентное соотношение различий между картинками.

Очень удобно проверять расхождения даже в мелочах. Например если что-то пошло не так и использовался шрифт по умолчанию. К тому же мелкие различия глазами не так уж просто заметить. Видите разницу в 1 процент на картинке к посту? Нет? А она есть🐹!

➡️ https://github.com/datenhahn/imgcompare

#libs #tricks
👍1
Для проверки целостности или идентичности файлов всегда используется проверка контрольной суммы.
Это работает в большинстве случаев, но не всегда. Давайте сделаем простой тест.
Создадим несколько рандомных файлов

import os

# create random test files
files_to_archive = []
for i in range(5):
name = f'example_file{i}.txt'
open(name, 'wb').write(os.urandom(10**7))
files_to_archive.append(name)

Я создал 5 файлов с рандомными бинарными данными. Нам сейчас неважно что там находится, главное что это некоторые файлы по 10мб.

Добавим их в архив два раза

import tarfile
def create_tar(archive_path, files):
with tarfile.open(archive_path, 'w:gz') as tar:
for file in files:
tar.add(file)

create_tar('archive1.tar.gz', files_to_archive)
create_tar('archive2.tar.gz', files_to_archive)

И проверим хеш сумму

>>> hashlib.md5(open("archive1.tar.gz", "rb").read()).hexdigest()
'ded8771a6ba57281f52a0e0ec38c29b8'
>>> hashlib.md5(open("archive2.tar.gz", "rb").read()).hexdigest()
'2a70bd3137a174393197cf67cbe91a8d'

Несмотря на то, что мы сделали два одинаковых архива, внутри он не очень-то и одинаковы! Причина тут в алгоритме сжатия, который может зависеть от некоего рандома, и в записываемых мета-данных, например время создания файла архива. Даже отличие в один байт делает хеш сумму совершенно другой, несмотря на то, что файлы внутри полностью идентичны.

Чтобы решить проблему следует проверять хеш сумму самих файлов внутри архива. То есть разархивировать данные без сохранения на диск и посчитать хеш для них.

def get_hash_tar(path):
hsum = hashlib.md5()
with tarfile.open(path) as tar:
for file in tar.getmembers():
hsum.update(tar.extractfile(file).read())
return hsum.hexdigest()

>>> get_hash_tar('archive1.tar.gz')
'0b27c443737b0a84381b827e1d9a913b'
>>> get_hash_tar('archive2.tar.gz')
'0b27c443737b0a84381b827e1d9a913b'

Таким образом мы обошли те байты архива которые отличаются и посчитали только фактические данные файлов.

#libs #tricks
В прошлом примере мы добились совпадения хеш-суммы двух архивов. Но не даёт покоя тот факт, что делается это слишком долго. Давайте сравним скорость.

>>> from timeit import timeit
>>> timeit("hashlib.md5(
open('archive1.tar.gz', 'rb').read()
).hexdigest()", number=100, globals=globals())
# 8.6
>>> timeit("get_hash_tar1('archive1.tar.gz')",
number=100, globals=globals())
# 29.8

Разница больше чем в 3 раза! Видимо потому, что кроме простого чтения байтиков мы еще применяем алгоритм разжатия данных?

Кажется что для 100 итераций это время нормальное, но представьте что архив будет размером не 50 Мб а 10Гб. Время возрастёт серьезно!

Попробуем сократить разрыв. Давайте считать не все данные а только хеш файлов, который посчитает сам модуль tarfile.

def get_hash_tar2(path):
hsum = hashlib.md5()
with tarfile.open(path) as tar:
for file in tar.getmembers():
hsum.update(file.chksum.to_bytes(8, byteorder='big'))
return hsum.hexdigest()

>>> timeit.timeit("get_hash_tar2('archive1.tar.gz')",
number=100, globals=globals())
11.5

Прирост скорости x3! Уже неплохо, почти как просчет хеша для архива без разжатия.
Почему так, можно почитать в комментарии. Если коротко, мы считываем только заголовки элементов архива. Но на сколько я понял, это не отменяет чтение всего буфера из архива.

А можно быстрей? Можно...

#libs #tricks
На самом деле архивы TAR оказались менее удобными в нашей теме проверки идентичности. Давайте сделаем всё тоже самое для ZIP.

Допустим, тестовые файлы мы уже создали используя код из прошлого поста.
Теперь создадим архивы.

import zipfile

def create_zip(archive_path, files):
with zipfile.ZipFile(archive_path, "w") as zf:
for file in files:
zf.write(file)

create_zip('archive1.zip', files_to_archive)
create_zip('archive2.zip', files_to_archive)

Проверим хеш
>>> hashlib.md5(open("archive1.zip", "rb").read()).hexdigest()
'd54670be5e01e483797ee4ae30089423'
>>> hashlib.md5(open("archive2.zip", "rb").read()).hexdigest()
'd54670be5e01e483797ee4ae30089423'

Отлично! ZIP создаёт одинаковые архивы и сразу выдаёт одинаковую хеш-сумму!
Ну всё, на этом расходимся...


Хотя подождите ка, часто ли вы проверяете один и тот же файл на идентичность?
Давайте имитируем ситуацию когда файл был перезаписан или "модифицирован" но при этом фактически не изменился. То есть изменились только его атрибуты. Для этого можно использовать Linux-команду touch, которая обновляет время последнего доступа к файлу.

touch example_file0.txt
touch example_file1.txt
...

Либо альтернативу на Python

from pathlib import Path
for f in files_to_archive:
Path(f).touch()

Содержимое файлов не изменилось! Но изменились атрибуты. Пересоздаём второй архив.

create_zip('archive2.zip', files_to_archive)

Проверяем

>>> hashlib.md5(open("archive1.zip", "rb").read()).hexdigest()
'd54670be5e01e483797ee4ae30089423'
>>> hashlib.md5(open("archive2.zip", "rb").read()).hexdigest()
'aa508dbba4e223abe45e16dba4ad6e1f'

Вот это более правдивая ситуация.

Давайте теперь сделаем функцию для проверки файлов внутри архивов, которая считывает непосредственно данные файлов в разжатом виде.

def get_hash_zip(path):
hash_md5 = hashlib.md5()
with zipfile.ZipFile(path, "r") as z:
for f_name in z.namelist():
with z.open(f_name) as f:
hash_md5.update(f.read())
return hash_md5.hexdigest()

Сравним теперь хеш-суммы архивов

>>> get_hash_zip('archive1.zip')
'0b27c443737b0a84381b827e1d9a913b'
>>> get_hash_zip('archive2.zip')
'0b27c443737b0a84381b827e1d9a913b'

Всё чётко сработало!
А что по времени?

>>> timeit.timeit("get_hash_zip('archive1.zip')", number=100, globals=globals())
10.8

Ну тоже неплохо.

⭐️ А теперь самая главная фишка ZIP - при создании архива он СРАЗУ записывает контрольную сумму файла в заголовки!
А это значит что мы можем просто считать готовые хеш-суммы и сравнить их! Это называется CRC (cyclic redundancy check)

def get_hash_zip2(path):
h = hashlib.md5()
for info in zipfile.ZipFile(path).infolist():
h.update(info.CRC.to_bytes(8, byteorder='big'))
return h.hexdigest()

>>> timeit.timeit("get_hash_zip2('archive1.zip')", number=100, globals=globals())
0.008

То есть даже буфер никакой не считывается, только несколько байт из заголовков каждого файла. Выполняется моментально. В моем случае 100 итераций за 8 мс!

🌐 Полный листинг тестов в Jupyter (для экспериментов жмём Open in Colab)
📌 И просто в Gists

#libs #tricks
🔖 Подводя итоги по прошлым постам плюс пара заметок:

🔸 Если требуется проверять идентичность содержимого архивов, лучше использовать ZIP и проверять только CRC.

🔸 Указанный способ может помочь проверить отдельные файлы в архиве с возможностью перекачать только новые а не весь архив

🔸 Для проверки хеш-суммы файла можно использовать утилиту md5sum (она не умеет проверять хеш внутри ахрхивов)

# Linux:
md5sum filename

# Windows:
python md5sum.py filename

(находится в директории скриптов /Tools/scripts/md5sum.py)

#libs #tricks
Если вы писали когда-либо мультипоточные или мультипроцессорные приложения то вы знаете какая самая большая проблема у таких программ.
Это конечно же синхронизация (мы сейчас не про GIL).

Что такое синхронизация? Это когда параллельно работающий код может делать расчёты абсолютно независимо, но вот к общим данным они должны обращаться последовательно в определённом порядке. То есть нужна синхронность вместо асинхронности.
Пока один процесс открыл файл, другие просто ждут своей очереди. Пока один поток обновляет переменную, другие просто спят, опять же ждут своей очереди.

В Python есть стандартные средства для синхронизации. Это так называемые мьютексы с различной логикой. Например Lock, Semaphore или очередь Queue. Все они работают в контексте одного интерпретатора. То есть все потоки или процессы должны быть запущены из одной программы. Тогда можно использовать один Lock между потоками и общую память между процессами.

Но что же делать, если процессы независимы? То есть я просто запускаю два разных интерпретатора с разным кодом. Возможно даже разных версий Python. В моём случае мне потребовалось писать некоторый кеш из одного модуля, который работает совершенно в разных приложениях как вспомогательный инструмент.

Сначала я пытался что-то сделать на основе shelve, но потом нашел отличную библиотеку diskcache.

Этот проект покрыл все мои потребности:

▫️ thread-safe и process-safe
То есть можно выполнять команды из разных процессов и потоков и они всегда будут синхронизированы.
Можно писать из разных процессов не опасаясь получить ошибку о том что файл занят другим процессом (как это бывает с shelve)
▫️всегда атомарные операции
Это значит что любое действие выполняется в один запрос.
▫️ безсерверный
Не требуется отдельный процесс для синхронизации. Всё решается через базу данных.

Полный список возможностей

Из удобных фичей можно еще отметить встроенный Lock, позволяющий синхронизировать независимые процессы. Это как раз то что я искал!

На что стоит обратить внимание:
🔸 несовместимы версии python 2 и 3 из-за разницы протоколов pickle
🔸 надёжность работы с сетевыми дисками под вопросом, я бы не стал. Но тут скорей вопрос к сети чем к софту.
🔸 при создании инстанса diskcache.Cache() все данные пишутся в рандомную директорию в temp. Чтобы синхронизировать разные процессы следует указывать одинаковый путь diskcache.Cache(some_path).

#libs
­Возможно, стоит пояснить разницу между синхронизацией из thread/process-safe и синхронизацией с помощью Lock 🤔

Наша задача — заставить разные процессы и потоки обращаться к базе данных (или любым другим ресурсам) последовательно. Чтобы не случилось так называемого race condition, то есть состояние гонки. Это когда разные потоки или процессы пытаются одновременно что-то сделать с одним и тем же ресурсом.
В этом случае нам нужна какая-то логика ограничения. Пока один процесс не завершил своё действие, другие не могут получить доступ к ресурсу.

Так вот, thread-safe и process-safe означает что отдельно взятые операции записи в БД гарантированно будут последовательны. Запросы из разных процессов или потоков выстроятся в очередь и не будут мешать друг другу. Лучше всего когда этот блок реализован на уровне БД в виде атомарных операций или ещё как-то.

Но зачем нам тогда еще дополнительный Lock?
Этот способ синхронизации используется когда процесс никак не укладывается в одно действие и должен сделать множество операций прежде чем дать доступ следующему. В этом случае процесс ставит некий глобальный Lock на ресурс и никто другой, даже получив законное право на доступ, не может ничего сделать. Все ждут пока этот Lock не будет снят.
Это решается на уровне приложения и правильность реализации полностью в вашей ответственности. Например, если забыли разблокировать или сделали перекрёстный Lock (Deadlock как на картинке), то всё зависнет в бесконечном ожидании.

#basic
От многопоточных вычислений переходим к распределённым. То есть вычисления, происходящие на нескольких компьютерах.
Конечно, в зависимости от задачи, вы можете взять готовые решения вроде CGRU или Deadline для рендеринга, charm4py или Dask для ML, или замутить что-то на AWS С2. Но хотелось бы чего-то попроще, попитоничней что ли)

А ведь в Python есть средства "из коробки" для синхронизации нескольких процессов на разных хостах.
Вот простой пример кода, который синхронизирует работу двух процессов на разных компьютерах.

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

Если у вас есть несколько машин, то можете попробовать это запустить по сети (нужно заменить 'localhost' на IP-адрес сервера). Но и на локальной машине сработает.

Gist 🌎

#libs #source #tricks
Самый большой минус синхронизации из прошлого поста - нестабильность. Не знаю как у вас а у меня эта штука падала несколько раз)
По моему не очень production-ready.
Что же делать? Отправлять disckcache на сетевой диск? Не, я бы не стал. Ведь есть отличная альтернатива! Это Redis.

Redis это жутко быстрая in-memory база данных. Запись в неё похожа на документоориентированные NoSQL базы данных. То есть без схемы, без таблиц. Просто ключ=значение.

Redis используется для кэширования и как брокер для передачи сообщений. Имеется подписка на изменения и время жизни записей.
Вот пример кода:

import redis
R = redis.Redis()
R.set('key', 'value')
R.get('key')
# b'value'

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

Что он делает?
Создаёт очередь сообщений которые может забирать другой клиент.

Как это организовать?
Для начала поднимаем cервер Redis.
Потом на одном хосте создаём очередь для отправки.

queue = RedisSMQ(
host=host,
port=port,
qname='example'
)
queue.exceptions(False).\
createQueue(delay=0).\
vt(message_live_time).\
execute()

На другом хосте содаём клиента для прослушивания очереди

on_receive = lambda msg: print(msg)

consumer = RedisSMQConsumer(
'example', on_receive
host=host, port=port)

Начинаем прослушивание очереди

consumer.run()

Теперь можем отправлять сообщения

queue.sendMessage(delay=0).message(msg).execute()

Теперь все сообщения, отправленные в очередь, будут попадать в наш колбек
on_receive
.

▫️Обязательное условие — наличие поднятого Redis-сервера.
▫️Единственное ограничение, обусловленное спецификой инструментов, данные должны быть JSON serializable.

#libs