17 дек. 2014 г.

Грязные технологии: миксины моделей SQLAlchemy

SQLAlchemy позволяет выносить часть определения модели в отдельный базовый класс, который будет потом «подмешиваться» к другим. Очень удобно, когда есть какой-то повторяющийся кусок в большом количестве классов моделей. Но есть один неприятный момент: все поля должны «знать», к какой модели они принадлежат, а для этого нужно копировать объект поля. С первым уровнем SQLAlchemy хорошо справляется, так что с простым Column всё путём, но ведь поля могут содержать ссылки на другие объекты (например, ForeignKey), к которым предъявляются такие же требования. И тут авторы SQLAlchemy пошли самым простым путём: если нельзя сделать автоматически на все случаи жизни, то не пусть делает пользователь вручную. В результате код миксина должен выглядеть примерно так:
class WithParent(object):
    @declared_attr
    def parent_id(cls):
        return Column(ForeignKey(Parent.id))
    @declared_attr
    def parent(cls):
        return relationship(Parent)
По сути обработанные declared_attr свойства решают ту же проблему, которую мы уже решали декоратором return_locals, а именно позволить выполнять код в определении класса несколько раз. Поэтому и решение напрашивается то же, только теперь нам нужно все свойства дополнительно завернуть в задекорированный метод. Понятно, что мы не хотим делать отдельный вызов нашей фабрики на каждый дескриптор, поэтому однажды полученный результат надо закешировать:
def declared_mixin(*args):

    def wrapper(func):
        attrs = weakref.WeakKeyDictionary()
        def create_descriptor(name):
            def get_attr(cls):
                if cls not in attrs:
                    # Call func only once per class
                    attrs[cls] = return_locals(func)()
                return attrs[cls][name]
            get_attr.__name__ = name
            return declared_attr(get_attr)
        dict_ = {name: create_descriptor(name)
                 for name in func.func_code.co_varnames}
        dict_['__doc__'] = func.__doc__
        return type(func.__name__, args, dict_)

    if len(args)==1 and not isinstance(args[0], type):
        # Short form (without args) is used
        func = args[0]
        args = ()
        return wrapper(func)
    else:
        return wrapper
Теперь наш пример миксина выглядит гораздо приятнее:
@declared_mixin
def WithParent():
    parent_id = Column(ForeignKey(Parent.id))
    parent = relationship(Parent)
В комментариях к прошлому посту Андрей Светлов резонно заметил, что хак слишком грязен для столь небольшого эффекта. В ситуации же с миксином полученный эффект уже больше: если в исходном варианте на одну смысловую строчку кода приходилось две строчки шума, то здесь мы от шума полностью избавились. И дело даже не в том, что строк стало меньше, зашумлённый код гораздо сложнее читать. Вопрос поиска менее грязных путей получения нужного результата остаётся открытым.

3 дек. 2014 г.

Грязные технологии: фабрика классов на основе функции

Так уж получилось, что нам часто требуется определять одинаковые (или почти одинаковые) классы моделей SQLAlchemy для разных MetaData ну и, соответственно, с разными базовыми классами. Декларативно. Повсеместный copy-paste быстро надоел. Были мысли создавать второй класс путём копирования, но уж больно сложно получается: не так просто определить, где на какой глубине остановиться, а где заменить ссылки на что-то уже отзеркалированное. Гораздо проще сделать фабрику и создавать столько классов, сколько нужно. В питоне ж это просто:
def create_C(Base):
    class C(Base):
        id = Column(Integer, primary_key=True)
        # …
    return C
Всё хорошо, только вот отступ лишний появляется. Кому-о, может, и мелочь, а нам он сильно не понравился. Неужели нельзя без него? Ну типа класс задекорировать чем-о так, чтобы он в фабрику превратился. Проблема в том, что тело класса выполняется сразу и только один раз, и никакими декораторами это правило не отменить. Ну да, можно отменить: всего-то с байткодом чуть поколдовать. Только уж очень гразным хак получается и переносимость между версиями под большим вопросом.
Зато вот у функции тело можно выполнять когда захочешь и сколько угодно раз. Почему бы этим не воспользоваться и не превратить декоратором функцию в класс?
@create_class(Base)
def C():
    id = Column(Integer, primary_key=True)
    # …
    return locals()
Опять что-то лишнее, теперь return locals(). А в реальных задачах у нас появятся аргументы у функции (пространство имён с другими моделями, например — нам же надо как-то внешние ссылки да связи определять), которыми не захочется пространство имён класса засорять, так что строчка ещё усложнится.
Вот бы здорово было бы вернуть локально определённые переменные автоматически. И это как раз можно сделать. Немного оптимизации (зачем нам держать трейсер весь вызов, это же приличные накладные расходы?), немного уважения к тем, кто это будет потом отлаживать дебаггером или профилировать, и получается такой декоратор:
import sys, functools, inspect


def return_locals(func):
    '''Modifies decorated function to return its locals'''

    @functools.wraps(func)
    def wrap(*args, **kwargs):
        frames = []

        def tracer(frame, event, arg):
            frames.append(frame)
            sys.settrace(old_tracer)
            if old_tracer is not None:
                return old_tracer(frame, event, arg)

        old_tracer = sys.gettrace()
        sys.settrace(tracer)
        try:
            func(*args, **kwargs)
        finally:
            sys.settrace(old_tracer)
        assert len(frames) == 1
        argspec = inspect.getargspec(func)
        argnames = list(argspec.args)
        if argspec.varargs is not None:
            argnames.append(argspec.varargs)
        if argspec.keywords is not None:
            argnames.append(argspec.keywords)
        return {name: value 
                for name, value in frames.pop(0).f_locals.items()
                if name not in argnames}

    return wrap
Осталась мелочь, создать декоратор самой фабрики:
def create_class(*bases):
    def wrapper(func):
        return type(func.__name__, bases, return_locals(func)())
    return wrapper