Инструкция по написанию Python User Defined Functions (UDF) для YQL

Введение

Не все задачи хорошо укладываются в полностью декларативное описание результата в стиле SQL. Зачастую удобнее декларативно выразить, над какими данными выполнить join и как сгруппировать, а прикладную логику обработки фрагментов данных описать в императивном стиле. Для этого сценария использования YQL предоставляет механизм написания и вызова пользовательских функций (UDF) на Python 3.6 или Python 2.7.

Определение и вызов UDF

Чтобы использовать UDF на Python в YQL запросе, нужно выполнить три шага:

  1. Передать в запрос строку, содержащую скрипт на Python, в котором определена функция, которая будет вызвана в качестве UDF;
  2. Объявить в запросе имя и сигнатуру функции (типы принимаемых и возвращаемых значений);
  3. Вызвать функцию в теле запроса и передать ей аргументы.

В последующих разделах каждый из трех шагов описан подробно.

Строка, содержащая скрипт на Python

Скрипт можно задать тремя способами:

  • Обыкновенный строковый литерал. Например: "def foo(): return 'bar'". Этот вариант неудобен для сложных многострочных скриптов.
  • Многострочный строковый литерал (расширение YQL). Такой литерал ограничивается символами @@ и работает примерно как тройные кавычки в Python, а комментарий #py включает в редакторе YQL подсветку синтаксиса языка Python:
    @@#py
    def foo():
        return b'bar'
    @@
    
  • Приложенный к запросу именованный файл. Содержимое файла скрипта встраивается в запрос с помощью функции FileContent("foo.py").

Объявление имени и сигнатуры функции

Так как Python является динамически типизированным языком, а YQL — статически типизированным, необходим способ зафиксировать типы на их стыке.
Имя и сигнатура вызываемой из Python-скрипта функции должны быть известны заранее до выполнения YQL запроса. Потенциальное несоответствие объявленной сигнатуры и возвращаемых из указанной функции данных приведет к ошибке времени исполнения (runtime error) в ходе выполнения запроса, то есть лежит на совести пользователя.
Есть три способа указания сигнатуры функции:

  • в самом запросе;
  • в виде аннотации аргументов и выходного значения (только для Python 3);
  • в виде docstring в Python функции.

Так как в YQL небезопасно запускать произвольный Python код, второй и третий варианты требуют запуска отдельной Map операции на YT, что занимает порядка 30-60 секунд.

Вызов отдельной функции из тела скрипта, а не запуск скрипта целиком, реализован, чтобы обеспечить возможность вернуть в UDF не только строковое значение, но более сложные типы данных (подробнее о типах данных см. ниже). Поэтому популярный в консольных утилитах на Python подход if __name__ == '__main__': ... здесь не подходит, так как в этом случае возвращаемое значение можно получить только через стандартный вывод (stdout) скрипта.

Синтаксис объявления имени и сигнатуры функции рассмотрим на примере:
$f = Python3::foo(Callable<()->String>, $script);
Где

  • $f и $scriptименованные выражения YQL. В данном примере значения выражений:
    • $f — готовая к использованию в запросе функция (содержит имя функции, ее сигнатуру и тело скрипта);
    • $script — заданное одним из описанных в предыдущем разделе способов тело скрипта на Python.
  • Python3:: — фиксированное пространство имен (namespace) для функций на Python версии 3.x; нужно, чтобы отличать эту конструкцию от UDF на других интерпретируемых языках или на C++. Также доступен алиас Python::. Для использования Python 2.7 необходимо указывать Python2::.
  • foo — имя функции внутри скрипта $script, которая будет вызываться.
  • Callable<()->String> — описание сигнатуры функции. В данном случае пустые скобки означают отсутствие аргументов, а String после стрелки — строковый результат. Пример сигнатуры функции с аргументами: Callable<(String, Uint32)->Double>. Подробная документация о формате описания типов данных.

На правах альтернативы вместо строки с описанием сигнатуры функции можно передать сам тип вызываемого выражения, составленный через функции для работы с типами данных. Как правило, этот вариант удобнее, если в запросе используется несколько функций, оперирующих одинаковыми сложными типами данных, или если сложный тип данных может быть получен через TypeOf от колонки или строки таблицы.

Если опустить описание сигнатуры функции, то YQL будет извлекать ее либо из аннотаций, либо из начала docstring функции до первой пустой строки или завершения docstring.

При описании функции с аннотациями должны быть аннотированы все аргументы и выходное значение, нельзя использовать *args и **kwargs, а значениями по умолчанию могут быть только None, при этом соответствующие типы аргументов должны быть Optional.

Аннотации типов

В аннотациях типов в Python 3 должны использоваться типы из модуля yql.typing - примитивные типы и, в основном, одноименные функции-конструкторы типов из текстового описания типа за исключением того, что из-за ограничений Python вместо угловых скобок используются квадратные.

Для задания типов кортежа или структуры без элементов используются аннотации EmptyTuple и EmptyStruct соответственно.

Для описания типа вызываемого значения используется аннотация Callable, в которой в квадратных скобках передается число опциональных аргментов, тип выходного значения и описания аргументов. Описание аргумента это либо тип, либо slice в форме имя-аргумента:тип:флаги. Если не нужно задавать имя аргументу, то следует использовать пустую строку. Можно опускать последний компонент slice-а, но, если он задан, то должен быть set-ом флагов. В настоящее время возможным значением флага может быть только AutoMap. Если нужно задать флаги или имя для аргумента самой UDF, в аннотации указывается объект slice в такой же форме.

Пример использования аннотаций:

$script = @@#py
from yql.typing import *


def foo(
    x:Optional[Int32],
    y:Struct['a':Int32, 'b':Int32],
    z:slice("name", Int32, {AutoMap})) -> Optional[Int32]:
    """
       foo function description
    """
    return x + y.a + z
@@;

$udf = Python3::foo($script);
SELECT $udf(1, AsStruct(2 AS a, 3 AS b), 10 AS name); -- 13

Пример использования docstring:

$script = @@#py
def foo():
    """
       ()->String

       foo function description
    """
    return b"bar"
@@;

$udf = Python3::foo($script);
SELECT $udf(); -- bar

Вызов UDF в запросе

Полученную после объявления сигнатуры функцию можно вызывать так же, как и любую встроенную функцию YQL, передавая аргументы в круглых скобках. Например: $f() или $udf("test", 123).

Типы данных

Простые (Data type)

Простые типы данных

Типы, допускающие значение NULL

В YQL столбцы таблиц, да и просто любые значения, бывают как гарантированно имеющие значение, так и потенциально пустые (в терминах SQL — nullable, допускающие значение NULL). Функции в YQL, в том числе и на Python, в своей сигнатуре должны объявить, умеют ли они обрабатывать потенциально пустые входные значения, и нужна ли им возможность вернуть пустое значение (то есть NULL, он же «пустой Optional»).

При указании сигнатуры для UDF на Python тип, допускающий значение NULL, обозначается добавлением к имени типа суффикса ?, например String? или Double?. Для Python пустые значения (NULL) соответствуют None в аргументе или в возвращаемом значении функции.

Если аргумент помечен как допускающий значение NULL (Optional/Nullable), это не означает, что при вызове функции его можно не указывать.

Необязательные аргументы при описании сигнатуры функции указываются в квадратных скобках после обязательных. В качестве значений всех не указанных при вызове функции необязательных аргументов для Python будет передано None.

Например, если аргументы функции объявлены так: (String, [Int32?, Bool?]), это означает, что функция принимает три аргумента — обязательный типа String и два необязательных типа Int32 и Bool соответственно.

Контейнеры

Название Объявление сигнатуры Пример сигнатуры Как представлен в Python
Список List<Type> List<Int32> list-like object (подробнее)
Словарь Dict<KeyType,ValueType> Dict<String,Int32> dict-like object (подробнее)
Множество Set<KeyType> Set<String> dict-like object (подробнее), тип значения - yql.Void
Кортеж Tuple<Type1,...,TypeN> Tuple<Int32,Int32> tuple
Структура Struct<Name1:Type1,...,NameN:TypeN> Struct<Name:String,Age:Int32> Python2 : object / Python3 : StructSequence
Поток Stream<Type> Stream<Int32> generator
Вариант над кортежем Variant<Type1,Type2> Variant<Int32,String> tuple с индексом и объектом
Вариант над структурой Variant<Name1:Type1,Name2:Type2> Variant<value:Int32,error:String> tuple с именем поля и объектом
Перечисление Enum<Name1, Name2> Enum<Foo,Bar> tuple с именем поля и yql.Void

При необходимости, контейнеры можно вкладывать друг в друга, например List<Tuple<Int32,Int32>>.

Специальные типы

  • Callable — вызываемое значение, которое можно исполнить, передав аргументы через круглые скобки в SQL-синтаксисе или через Apply в s-expressions. Так как сигнатура вызываемых выражений должна быть статически известна, то все Python UDF, возвращающие вызываемые значения, должны указывать и их сигнатуру. Например, (Double)->(Bool)->Int64 — UDF принимает на вход Double, а возвращает вызываемое значение с сигнатурой (Bool)->Int64. При необходимости таким образом можно выстраивать и более длинные цепочки вызываемых значений.
  • Resource — непрозрачный указатель на некий ресурс, который можно передавать между пользовательскими функциями. Тип возвращаемого и принимаемого ресурса объявляется внутри функции строковой меткой, которая используется, чтобы сопоставив их заранее предотвратить передачу ресурсов между несовместимыми функциями. В C++ UDF имя ресурса можно задавать, а для Python UDF сейчас доступно только фиксированное имя ресурса — Python2.
  • Void — отсутствие какого-либо другого типа; является типом литерала NULL.

Автоматическое приведение типов при взаимодействии с Python UDF

На входе:

  • String,Yson в bytes (str для Python 2);
  • Utf8,Json в str (unicode для Python 2);
  • объект типа Void: в yql.Void (нужен import yql).

На выходе:

  • long/int в Int8, Int16, Int32, Int64, Uint8, Uint16, Uint32, Uint64, Float, Double;
  • float в Float, Double;
  • str (unicode в Python 2) в String, Utf8, Yson, Json;
  • bytes (str в Python 2) в String, Yson;
  • dict или объект в Struct<...>;
  • tuple в List<...>;
  • любой объект в Bool по правилам Python;
  • генераторы, итераторы и iterable-объекты в Stream<...>.
  • iterable-объекты в ленивые List<...>.

Основные представления контейнеров см. в таблице выше.

Существует возможность настроить автоматическую распаковку Yson на границах UDF с помощью свойства _yql_convert_yson на Python функции, в которое надо положить кортеж с функциями для десериализации и сериализации.

Пример:

$script = @@#py
import cyson

def f(s):
    abc = s.get(b"abc", 0)
    return (abc, s)

f._yql_convert_yson = (cyson.loads, cyson.dumps)
@@;

$udf = Python3::f(Callable<(Yson?)->Tuple<Int64,Yson?>>, $script);

SELECT $udf(CAST(@@{"abc"=1}@@ as Yson));

Строка в Python 3 соответствует типу Utf8 в YQL, а тип String соответствует bytes в Python 3.

Несоблюдение этого правила может приводить к непонятному и ошибочному, на первый взгляд, поведению во многих ситуациях, например:

$script = @@#py
def getDict():
    return {'Key': 123}
@@;
$utfDict = Python::getDict(Callable<()-> Dict<Utf8, Int32>>,  $script);
$strDict = Python::getDict(Callable<()-> Dict<String, Int32>>, $script);
SELECT $utfDict()['Key'] AS good, $strDict()['Key'] AS bad, $strDict() AS strange;

В Python2 различий между текстовой строкой и байтовой последовальностью по сути не было.

Получение списка полей структуры

В некоторых случаях удобно вместо обращения к полям структуры через точку написать цикл по всем полям, используя getattr для доступа к значению полей. Этого можно добиться через доступ к __class__.__match_args__ на объекте структуры.

Пример:

$script = @@#py
def f(s):
    return ",".join(str(getattr(s,name)) for name in s.__class__.__match_args__)
@@;

$f = ($s)->{
    return Python::f(CallableType(0,Utf8,TypeOf($s)),$script)($s);
};

select $f(<|a:"foo"u,b:"bar"u|>); -- "foo,bar"

Особенности передаваемых в функции объектов для списков и словарей

При передаче в Python UDF списков и словарей, в аргументы подаются не настоящие python-объекты типа list или dict, а специальные read-only объекты yql.TList и yql.TDict, которые позволяют избежать возможно не нужного полного копирования данных в памяти.

Особенности обоих объектов:

  • Read-only: в них нельзя что-то поменять не скопировав, так как тем же объектом может пользоваться какая-то другая часть текущего запроса, например соседняя функция.
  • Ценой потенциально медленного копирования можно получить из них настоящий объект типа list (set, frozenset и пр. тоже возможны при необходимости), проитерировавшись для частичного копирования или выполнения каких-либо преобразований при копировании. Для словарей нужно дополнительно вызвать iteritems(). Установив значение атрибута _yql_lazy_input в False на самой python функции можно включить автоматическое копирование списков и словарей в list и dict, соответственно. Данный механизм работает рекурсивно для вложенных контейнеров, а также поддерживает и другие их виды, в частности Struct и Tuple.
  • Для получения количества элементов можно вызвать len(my_arg).
  • Нижеизложенные задокументированные наборы методов могут немного отставать от действительности, текущий набор можно узнать с помощью dir(my_arg).

Особенности yql.TList:

  • Метод has_fast_len() — узнать, можно ли быстро получить длину. Если вернулось False, то список «ленивый».
  • Метод has_items() — проверка на пустоту.
  • Метод reversed() — получить перевернутую копию списка.
  • Методы skip(n) и take(n) — аналоги срезов [n:] и [:n], соответственно. При некорректном индексе (значении n) могут кидать исключение IndexError.
  • Метод to_index_dict() - получить словарь с номерами элементов в качестве ключей, что позволяет осуществлять случайный доступ. На самом объекте yql.TList он запрещён.

** Внимание! Данные особенности более не рекомендуются к использованию и будут удалены в будущих обновлениях.**
Для организации произвольного доступа к списку теперь можно использовать стандартные для python методы:

  • list[index] - доступ по индексу
  • list[start:stop:step] - срез списка
  • reversed(list) - разворот списка

Пример с _yql_lazy_input:

$u = Python3::list_func(Callable<(List<Int32>)->Int32>, @@#py
def list_func(lst):
  return lst.count(1)
list_func._yql_lazy_input = False
@@);
SELECT $u(AsList(1,2,3));

Стоит иметь в виду

  • При работе на YT стандартный вывод (stdout) используется для служебных целей, поэтому пользоваться им из UDF нельзя.
  • Всю тяжелую инициализацию стоит выполнять вне часто вызываемых функций, на уровне модуля или с использованием замыкания (см. пример ниже). В противном случае операция может не только занять неопределенно много времени, но и мешать соседям на MapReduce-кластере.
  • Из одного скрипта можно зарегистрировать несколько функций, повторив объявление имени и сигнатуры функции нужное количество раз.

Примеры

Hello World

$script = @@#py
def hello(name):
    return b'Hello, %s!' % name
@@;

$udf = Python3::hello(Callable<(String)->String>, $script);

SELECT $udf("world");

Генераторы для создания списков

$script = @@#py
def gen():
    for x in range(1, 10):
        yield x
@@;
$udf = Python::gen(Callable<()->Stream<Int64>>, $script);
SELECT Yql::Collect($udf());

Можно использовать в сочетании с PROCESS для потоковой обработки таблиц, например:

$udf = Python::process(
Callable<
    (List<Struct<
        age:Int64,
        name:String?
    >>)->Stream<Struct<result:Int64>>
>, @@
def process(rows):
    for row in rows:
        result = row.age ** len(row.name or '')
        yield locals()
@@);

$users = (
    SELECT age, name FROM <table_name>
);

PROCESS $users
USING $udf(TableRows());

Разделение инициализации и вызова функции

$script = @@#py
def multiply_by_filesize(path):
    with open(path, 'r') as f:
        size = len(f.read())

        def multiply(arg):
            return arg * size  # closure

        return multiply
@@;
$udf = Python3::multiply_by_filesize(
    Callable<(String)->Callable<(Int64)->Int64>>,
    $script
);
$callable = $udf(FilePath("foo.txt")); -- expensive initialization
SELECT $callable(123);                 -- cheap call of already initialized function

Использование типа данных входа при объявлении сигнатуры

$func = ($obj) -> {
    $udf = Python3::func(
        CallableType(0, ParseType("String?"), TypeOf($obj)),
        FileContent("my_func.py")
    );
    RETURN $udf($obj);
};

SELECT $func(TableRow()) FROM my_table;

SecureParam

Для использования SecureParam в Python нужно обратиться к атрибуту _yql_secure_param(token) функции, сигнатура которой объявлена в запросе, где token (строка) - имя токена, значение которого нужно получить. Имя токена обязательно стоит передавать через обертку SecureParam(), иначе значение не будет найдено. Если токен не будет найден по имени, то функция завершится с ошибкой.
Пример использования SecureParam

$secParLen = @@#py
def MyFunc(token):
    return len(MyFunc._yql_secure_param(token))
@@;
$udf = Python::MyFunc(Callable<(String)->Int>, $secParLen);
SELECT $udf(SecureParam("token:default_yt"));

См. функции для работы с типами данных.

Предыдущая
Следующая