Высокоуровневый дизайн
Что такое хуки?
Механизм хуков — это часть onETL, которая позволяет внедрять дополнительное поведение в существующие методы (почти) любого класса.
Возможности
Механизм хуков позволяет:
- Проверять и валидировать входные аргументы и результаты вызова метода
- Получать доступ, изменять или заменять результат вызова метода (но НЕ входные аргументы)
- Оборачивать вызовы методов контекстным менеджером и перехватывать возникающие исключения
Хуки можно размещать в плагинах, что позволяет изменять поведение onETL путем установки дополнительных пакетов.
Ограничения
- Хуки могут быть привязаны только к методам класса (не к функциям).
- Только методы, декорированные с помощью slot-decorator, реализуют механизм хуков. Такие классы и методы помечены как
support_hooks. - Хуки могут быть привязаны только к публичным методам.
Термины
- slot-decorator - метод класса со специальным декоратором
Callback- функция, которая реализует дополнительную логику, изменяющую поведение слота- hook-decorator - обертка вокруг функции обратного вызова, которая хранит состояние хука, приоритет и некоторые полезные методы
Механизм хуков- вызовSlot()вызовет все включенные хуки, привязанные к слоту. Реализовано через support-hooks-decorator.
Как реализовать хуки?
Краткое руководство
from onetl.hooks import support_hooks, slot, hook
@support_hooks # включение механизма хуков для класса
class MyClass:
def __init__(self, data):
self.data = data
# это слот
@slot
def method(self, arg):
pass
@MyClass.method.bind # привязка хука к слоту
@hook # это хук
def callback(obj, arg): # это функция обратного вызова
print(obj.data, arg)
obj = MyClass(1)
obj.method(2) # вызовет callback(obj, 1)
# выведет "1 2"
Определение слота
- Создайте класс с методом:
class MyClass:
def __init__(self, data):
self.data = data
def method(self, arg):
return self.data, arg
- Добавьте декоратор
slot-decoratorк методу:
from onetl.hooks import support_hooks, slot, hook
class MyClass:
@slot
def method(self, arg):
return self.data, arg
Если метод имеет другие декораторы, такие как @classmethod или @staticmethod, @slot должен быть размещен сверху:
from onetl.hooks import support_hooks, slot, hook
class MyClass:
@slot
@classmethod
def class_method(cls, arg):
return cls, arg
@slot
@staticmethod
def static_method(arg):
return arg
- Добавьте декоратор support-hooks-decorator к классу:
from onetl.hooks import support_hooks, slot, hook
@support_hooks
class MyClass:
@slot
def method(self, arg):
return self.data, arg
Слот создан.
Определение функции обратного вызова
Определите некоторую функцию (т.е. callback):
def callback(self, arg):
print(self.data, arg)
Она должна иметь сигнатуру, совместимую с MyClass.method. Совместимая не означает в точности такую же - например, вы можете переименовать позиционные аргументы:
def callback(obj, arg):
print(obj.data, arg)
Используйте *args и **kwargs для пропуска аргументов, которые вам не важны:
def callback(obj, *args, **kwargs):
print(obj.data, args, kwargs)
Также есть аргумент method_name, который имеет специальное значение - имя метода, к которому привязана функция обратного вызова, передается в этот аргумент:
def callback(obj, *args, method_name: str, **kwargs):
print(obj.data, args, method_name, kwargs)
Note
method_name всегда должен быть именованным аргументом, а НЕ позиционным.
Warning
Если сигнатура callback несовместима с сигнатурой слота, будет вызвано исключение, но ТОЛЬКО при вызове слота.
Определение хука
Добавьте декоратор hook-decorator для создания хука из вашей функции обратного вызова:
@hook
def callback(obj, arg):
print(obj.data, arg)
Вы можете передать больше опций декоратору @hook, такие как состояние или приоритет.
Подробнее смотрите в документации декоратора.
Привязка хука к слоту
Используйте метод Slot.bind для привязки хука к слоту:
@MyClass.method.bind
@hook
def callback(obj, arg):
print(obj, arg)
Вы можете привязать более одного хука к одному слоту, и привязать один и тот же хук к нескольким слотам:
@MyClass.method1.bind
@MyClass.method2.bind
@hook
def callback1(obj, arg):
"Будет вызван как MyClass.method1, так и MyClass.method2"
@MyClass.method1.bind
@hook
def callback2(obj, arg):
"Также будет вызван MyClass.method1"
Как вызываются хуки?
Общая информация
Просто вызовите метод, декорированный @slot, чтобы активировать хук:
obj = MyClass(1)
obj.method(2) # вызовет callback(obj, 2)
# выведет "1 2"
Есть некоторые специальные типы обратных вызовов, которые имеют несколько иное поведение.
Контекстные менеджеры
Декоратор @hook может быть размещен на классе контекстного менеджера:
@hook
class ContextManager:
def __init__(self, obj, arg):
self.obj = obj
self.arg = arg
def __enter__(self):
# делаем что-то при входе
print(obj.data, arg)
return self
def __exit__(self, exc_type, exc_value, traceback):
# делаем что-то при выходе
return False
Контекстный менеджер входит при вызове Slot() и выходит, когда вызов завершается.
Если присутствует, метод process_result имеет особое значение - он может получать результат вызова MyClass.method, а также изменять/заменять его:
@hook
class ContextManager:
def __init__(self, obj, arg):
self.obj = obj
self.arg = arg
def __enter__(self):
# делаем что-то при входе
print(obj.data, arg)
return self
def __exit__(self, exc_type, exc_value, traceback):
# делаем что-то при выходе
return False
def process_result(self, result):
# делаем что-то с результатом вызова метода
return modified(result)
Смотрите примеры ниже для получения дополнительной информации.
Функция-генератор
Декоратор @hook может быть размещен на функции-генераторе:
@hook
def callback(obj, arg):
print(obj.data, arg)
# вызывается до тела оригинального метода
yield # метод вызывается здесь
# вызывается после тела оригинального метода
Он преобразуется в контекстный менеджер, аналогично contextlib.contextmanager.
Тело генератора может быть обернуто в try..except..finally для перехвата исключений:
@hook
def callback(obj, arg):
print(obj.data, arg)
try:
# вызывается до тела оригинального метода
yield # метод вызывается здесь
except Exception as e:
process_exception(a)
finally:
# вызывается после тела оригинального метода
finalizer()
Существует также специальный синтаксис, который позволяет генератору получать доступ и изменять/заменять результат вызова метода:
@hook
def callback(obj, arg):
original_result = yield # метод вызывается здесь
new_result = do_something(original_result)
yield new_result # изменить/заменить результат
Вызов хуков подробно
-
Функция обратного вызова будет вызвана с теми же аргументами, что и оригинальный метод.
-
Если слот является обычным методом:
callback_result = callback(self, *args, **kwargs)Здесь
self— это экземпляр класса (obj). -
Если слот является методом класса:
callback_result = callback(cls, *args, **kwargs)Здесь
cls— это сам класс (MyClass). -
Если слот является статическим методом:
callback_result = callback(*args, **kwargs)В этом случае в функцию обратного вызова не передается ни объект, ни класс.
-
Если
callback_resultявляется контекстным менеджером, войдите в контекст. Контекстный менеджер может перехватывать все вызванные исключения.
Если к слоту привязано несколько хуков, каждый контекстный менеджер будет введен.
- Затем вызовите оригинальный метод, обернутый
@slot:
original_result = method(*args, **kwargs)
-
Обработайте
original_result: -
Если объект
callback_resultимеет методprocess_resultили является генератором, обернутым@hook, вызовите его:new_result = callback_result.process_result(original_result) -
В противном случае установите
new_result = callback_result. -
Если к методу привязано несколько хуков, пропустите
new_resultчерез цепочку:new_result = callback1_result.process_result(original_result) new_result = callback2_result.process_result(new_result or original_result) new_result = callback3_result.process_result(new_result or original_result) -
Наконец, верните:
return new_result or original_result
Все значения None игнорируются на каждом этапе выше.
- Выход из всех контекстных менеджеров, введенных во время вызова слота.
Приоритет хуков
Хуки выполняются в следующем порядке:
- Слот родительского класса + [
FIRST] - Слот унаследованного класса + [
FIRST] - Слот родительского класса + [
NORMAL] - Слот унаследованного класса + [
NORMAL] - Слот родительского класса + [
LAST] - Слот унаследованного класса + [
LAST]
Хуки с одинаковым приоритетом и наследованием будут выполняться в том же порядке, в котором они были зарегистрированы (вызов Slot.bind).
Note
Вызовы super() внутри методов унаследованного класса не вызывают вызов хуков. Хуки вызываются только при явном вызове метода.
Это позволяет обернуть хуком весь вызов слота, не влияя на его внутреннюю логику.
Типы хуков
Вот несколько примеров использования хуков. Эти типы не являются исключительными, их можно смешивать — например, хук может как изменять результат метода, так и перехватывать исключения.
Хук до (Before hook)
Может использоваться для проверки или валидации входных аргументов оригинальной функции:
@hook
def before1(obj, arg):
print(obj, arg)
# оригинальный метод вызывается после выхода из этой функции
@hook
def before2(obj, arg):
if arg == 1:
raise ValueError("arg=1 не разрешен")
return None # возврат None то же самое, что и отсутствие оператора return
Выполняется перед вызовом оригинального метода, обернутого @slot. Если хук вызывает исключение, метод вообще не будет вызван.
Хук после (After hook)
Может использоваться для выполнения некоторых действий после успешного выполнения оригинального метода:
@hook
def after1(obj, arg):
yield # оригинальный метод вызывается здесь
print(obj, arg)
@hook
def after2(obj, arg):
yield None # yield None то же самое, что и пустой yield
if arg == 1:
raise ValueError("arg=1 не разрешен")
Если оригинальный метод вызывает исключение, блок кода после yield не будет вызван.
Контекстный хук (Context hook)
Может использоваться для перехвата и обработки некоторых исключений или для определения того, что во время вызова слота не было исключения:
=== Синтаксис генератора
# Это то же самое, что и использование @contextlib.contextmanager
@hook
def context_generator(obj, arg):
try:
yield # оригинальный метод вызывается здесь
print(obj, arg) # <-- эта строка не будет вызвана, если метод вызвал исключение
except SomeException as e:
magic(e)
finally:
finalizer()
=== Синтаксис контекстного менеджера
@hook
class ContextManager:
def __init__(self, obj, args):
self.obj = obj
self.args = args
def __enter__(self):
return self
# оригинальный метод вызывается между __enter__ и __exit__
def __exit__(self, exc_type, exc_value, traceback):
result = False
if exc_type is not None and isinstance(exc_value, SomeException):
magic(exc_value)
result = True # подавить исключение
else:
print(self.obj, self.arg)
finalizer()
return result
Note
Контексты выходят в обратном порядке вызовов хуков. Таким образом, если какой-то хук вызвал исключение, оно будет передано в предыдущий хук, а не в следующий.
Рекомендуется указывать правильный приоритет для хука, например FIRST
Хук замены результата (Replacing result hook)
Заменяет выходной результат оригинального метода.
Может использоваться для делегирования некоторых деталей реализации сторонним расширениям.
См. hive и hdfs в качестве примера.
@hook
def replace1(obj, arg):
result = arg + 10 # любой не None результат
# результат вызова оригинального метода игнорируется, выход всегда будет arg + 10
return result
@hook
def replace2(obj, arg):
yield arg + 10 # то же, что и выше
Note
Если к одному слоту привязано несколько хуков, будет использоваться результат последнего хука.
Рекомендуется указывать правильный приоритет для хука, например [LAST]
Хук доступа к результату (Accessing result hook)
Может получать доступ к выходному результату оригинального метода и проверять или валидировать его:
=== Синтаксис генератора
@hook
def access_result(obj, arg):
result = yield # оригинальный метод вызывается здесь, и результат может использоваться в хуке
print(result)
yield # не изменяет результат
=== Синтаксис контекстного менеджера
@hook
class ModifiesResult:
def __init__(self, obj, args):
self.obj = obj
self.args = args
def __enter__(self):
return self
# оригинальный метод вызывается между __enter__ и __exit__
# результат передается в метод process_result контекстного менеджера, если он есть
def process_result(self, result):
print(result) # результат может использоваться в хуке
return None # не изменяет результат. то же самое, что и отсутствие оператора return в методе
def __exit__(self, exc_type, exc_value, traceback):
return False
Хук изменения результата (Modifying result hook)
Может получать доступ к выходному результату оригинального метода и возвращать измененный:
=== Синтаксис генератора
@hook
def modifies_result(obj, arg):
result = yield # оригинальный метод вызывается здесь, и результат может использоваться в хуке
yield result + 10 # изменить выходной результат. Значения None игнорируются
=== Синтаксис контекстного менеджера
@hook
class ModifiesResult:
def __init__(self, obj, args):
self.obj = obj
self.args = args
def __enter__(self):
return self
# оригинальный метод вызывается между __enter__ и __exit__
# результат передается в метод process_result контекстного менеджера, если он есть
def process_result(self, result):
print(result) # результат может использоваться в хуке
return result + 10 # изменить выходной результат. Значения None игнорируются
def __exit__(self, exc_type, exc_value, traceback):
return False
Note
Если к одному слоту привязано несколько хуков, будет использоваться результат последнего хука.
Рекомендуется указывать правильный приоритет для хука, например [LAST]
Как увидеть логи механизма хуков?
Регистрация хуков выводит логи с уровнем DEBUG:
from onetl.logs import setup_logging
setup_logging()
DEBUG |onETL| Registered hook 'mymodule.callback1' for 'MyClass.method' (enabled=True, priority=HookPriority.NORMAL)
DEBUG |onETL| Registered hook 'mymodule.callback2' for 'MyClass.method' (enabled=True, priority=HookPriority.NORMAL)
DEBUG |onETL| Registered hook 'mymodule.callback3' for 'MyClass.method' (enabled=False, priority=HookPriority.NORMAL)
Но большая часть логов выводится с еще более низким уровнем NOTICE, чтобы сделать вывод менее подробным:
from onetl.logs import NOTICE, setup_logging
setup_logging(level=NOTICE)
NOTICE |Hooks| 2 hooks registered for 'MyClass.method'
NOTICE |Hooks| Calling hook 'mymodule.callback1' (1/2)
NOTICE |Hooks| Hook is finished with returning non-None result
NOTICE |Hooks| Calling hook 'mymodule.callback2' (2/2)
NOTICE |Hooks| This is a context manager, entering ...
NOTICE |Hooks| Calling original method 'MyClass.method'
NOTICE |Hooks| Method call is finished
NOTICE |Hooks| Method call result (*NOT* None) will be replaced with result of hook 'mymodule.callback1'
NOTICE |Hooks| Passing result to 'process_result' method of context manager 'mymodule.callback2'
NOTICE |Hooks| Method call result (*NOT* None) is modified by hook!