...

пятница, 18 декабря 2015 г.

Python->Cython->C++, да и COM в придачу: написание фреймворка для автотестов

Я думаю все в курсе о пользе автотестов. Они помогают держать код в работоспособном состоянии даже при существенных изменениях. Так же это может избавить тестировщиков от нудной ручной работы и позволяет сосредоточиться на более интересных видах тестирования.

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

Как писать хорошие автотесты – тема отдельной статьи. И, вероятно, не одной. Я же расскажу вам как мы внедрили тестирование отдельных компонентов. Компоненты написаны на С++ и имеют интерфейсы очень похожие на СОМ. В качестве языка для тестов мы выбрали python и используем очень мощный тестовый фреймворк PyTest. В статье я расскажу про сложности связки С++/СОМ и питона, подводные камни, на которые мы наткнулись и как решали эти проблемы.

Disclaimer


  • Я не могу просто так копипастить код нашего проекта из-за NDA. Поэтому все примеры в статье написаны с нуля и никогда не компилировались. Поэтому могут быть мелкие неточности, синтаксические ошибки или несоблюдение правил оформления кода. Но основной смысл я постарался передать.
  • Я не являюсь экспертом в питоне. Сказать по правде, питон я начал учить примерно в середине работы над проектом. Поэтому некоторые утверждения касательно питона могут быть либо не совсем верными, либо не до конца проработаны.
  • Питоновский код в примерах может не соответствовать pep8, т.к. часто отражает СОМовский прототип и заимствует его стилистику.
  • Эта статья не мануал по cython’у, многие вещи остались за кадром

Предыстория


В проекте, над которым я работаю, мы разрабатываем большой и сложный модуль. Несколько миллионов строк кода на С++, десяток крупных компонентов и сотня dll под капотом. Этот модуль используется в нескольких огромных приложениях.

Исторически так сложилось, что тестируется это все только через UI, причем по большей части вручную. Часто так получается, что изменение в одной области вылазит багом где-нибудь в совершенно другом месте. И находят этот баг только через несколько дней, или даже недель. А иногда что-нибудь всплывает аж через несколько месяцев, когда другой продукт решит интегрировать новую версию нашего модуля.

У нас есть юнит тесты, которые гоняются на CI по коммитам. Но коду было больше 15 лет когда у нас заговорили про TDD. Код монолитный и просто так запустить его отдельно не выйдет. Нужен большой рефакторинг, на который никто не даcт ресурсов. Поэтому юнит тесты у нас есть только на простые функции или отдельные классы.

Но раз уж у модуля есть некоторый API, то и тестить этот модуль можно через этот API. Можно собрать юзкейсы всех приложений, которые нас используют, и написать на это автотесты. Тогда можно было бы эти тесты гонять прямо на CI по коммитам. Так родилась идея компонентного тестирования.

Но вот кто будет писать тесты? Тестировщики могли бы придумать хорошие тестовые сценарии и подготовить тестовые данные, но тестировщики не знают С++ (а те кто знают быстренько сваливают в девелоперы). Программисты могли бы закодить такие тесты, но обычно фантазии хватает только на пару позитивных сценариев. Покрыть все негативные кейсы, обычно, терпения не хватает.

Мы решили перенять опыт коллег из соседней команды. Они для своего компонента сделали врапперы с помощью cython и выставили в питон упрощенный интерфейс, который используют для тестов. Порог вхождения в питон гораздо ниже чем в С++. Тестировщики могут запросто осилить питон за пару дней и начинают писать хорошие автотесты.

При чем тут СОМ?


Прежде чем начать описывать наши мучения нужно сказать пару слов про наши интерфейсы. Мы используем технологию слизанную с СОМ и портированую на линукс, мак и фрю. Есть некоторые инфраструктурные различия, связанные с отсутствием реестра, но для статьи это не принципиально.

СОМ-оподобная технология дает нам кучу плюшек, как то готовая плагинно-компонентная инфраструктура. Мы можем легко стыковать модули, написанные разными командами в разных странах (в т.ч. и third party плагины). При этом мы не паримся вопросами совместимости разных компиляторов, рантаймов и стандартных библиотек. Так же стилистика интерфейсов всех модулей, соглашения о передаче параметров и возвращаемых значений, время жизни объектов – все это регламентируется соглашениями как с СОМ’е.

Есть и оборотная сторона. Внутри модулей мы можем использовать любые плюшки современных стандартов С++. Но в публичных интерфейсах мы должны придерживаться правил СОМ’а – только простые типы или интерфейсы-наследники IUnknown. Никакого STL. Никаких эксепшенов, только HRESULT. Из-за этого код на границах модулей получается весьма громоздкий и не сильно читабельный.

Первый опыт с cython


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

Но эти интерфейсы хоть и являются частью публичного API, на деле они достаточно низкоуровневые. Что бы сделать определенную операцию нельзя просто так взять и вызвать одиночную функцию или метод. Нужно создать пяток объектов, связать их друг с другом, запустить на выполнение и дожидаться результата через future. Эта вся сложность нужна, что бы организовать транзакционность, асинхронность, Undo/Redo, организовать доступ к потоконебезопасным внутренностям и еще кучи других вещей. Короче, 2 экрана кода на С++ в СОМ стиле.

Мы решили последовать рекомендации наших коллег и написать небольшую прослойку, которая бы скрывала низкоуровневые интерфейсы. В питон же предлагалось выставить несколько высокоуровневых функций.

Враппер на cython просто пробрасывал вызовы в с++:

cdef class MyModuleObject():
        cdef CMyModuleObject * thisptr  # wrapped C++ object

        def __init__(self):
                self.thisptr = new CMyModuleObject()

        def __dealloc__(self):
                del self.thisptr

        def DoSomething1(self):
                self.thisptr.DoSomething1()

        def DoSomething2(self):
                self.thisptr.DoSomething2()

        def GetResult(self):
                return self.thisptr.GetResult()

C++ реализация класса CMyModuleObject уже занималась полезными действиями: создавала объекты нашего модуля и вызывала у них какие-то полезные методы (те самые 2 экрана кода).

Cython это по сути транслятор. На основе исходного кода выше cython генерирует тонну сишного кода. Если его скомпилировать как dll/so (и переименовать в pyd), то получим питоновский модуль. С++ реализацию CMyModuleObject так же нужно поселить в эту длл. Теперь наш питоновский модуль можно импортировать из питона (продолбавшись сначала с путями импорта). Можно запускать с помощью обычного питоновского интерпретатора, главное что бы архитектура совпадала. Выполняя строку импорта, питон сам поднимет нашу dll, проинициализирует и проимпортирует все что нужно.

Скрипт на питоне выглядел как то так:

from my_module import *
obj1 = MyModuleObject()
obj1.DoSomething1()
obj1.DoSomething2()
print obj1.GetResult()

Круто! Гораздо проще, чем на С++!

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

Врапим COM интерфейсы


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

Сказано — сделано. Мы быстро пришли к такой схеме. Конструктор питоновского объекта создает СОМовский объект и владеет ссылкой на него. Ссылки, конечно же, считаются смартпоинтером-копией CComPtr.

cdef class PyComponent:
        cdef CComPtr[IComponent] thisptr


        def __cinit__(self):
                # Get the COM host
                cdef CComPtr[IComHost] com_host
                result = GetCOMHost(IID_IComHost, <IUnknown**>&(com_host))
                hresultcheck (result)

                # Create an instance of the component
                result = com_host.inArg().CoCreateInstance(
                                CLSID_Component,
                                NULL,
                                IID_IComponent, <void**>self.thisptr.outArg()  )
                hresultcheck( result )


        def SomeMethodWithParam(self, param):
                result = self.thisptr.inArg().SomeMethodWithParam(param)
                hresultcheck (result)


        def GetStringFromComponent(self):
                cdef char [1024] buf
                result = self.thisptr.inArg().GetStringFromComponent(buf, sizeof(buf)-1)
                hresultcheck(result)
                return string (buf)

Обычно HRESULT функций нам неинтересен. Если функция успешна – ну и славненько. Если зафейлилась, то скорее всего дальше идти не нужно. Поэтому мы просто проверяем код ошибки и кидаем питоновское исключение. Обработка кодов возврата не выносится на уровень клиентского питоновского кода, что делает клиентский код существенно компактней и читабельней.

class HRESULT_EXCEPTION(Exception):
        def __init__(self, result):
                super(HRESULT_EXCEPTION, self).__init__("Exception code: " + str(hex(result & 0xffffffff)))


cpdef hresultcheck(HRESULT result):
        if result != S_OK:
                raise HRESULT_EXCEPTION(result)

Обратите внимание, что функция hresultcheck объявлена как cpdef. Это означает, что она может быть вызвана как питоновская (иногда hresult у нас таки проверяется в питоне), так и нативная сишная. Второе свойство существенно сокращает сгенерированый ситоном код обработки ошибок и ускоряет выполнение. Вызов макроса SUCCEEDED мы так и не осилили, поэтому сравниваем с S_OK – пока хватает.

Иногда мы все же отходили от враппинга 1 к 1, когда было понятно, что определенные интерфейсы и их методы полагается использовать только одним определенным способом и никак иначе. Например, если подразумевается что СОМ объект будет создаваться пустым, а потом в него будут напихиваться параметры через Set*() методы или вызов какого нибудь Initialize(), в этом случае на уровне питона мы делали просто удобный конструктор с параметрами.

Или вот еще пример. Бывает, что запрос к одному объекту концептуально возвращает ссылку на другой объект (или просто новый объект). В СОМ приходится использовать output параметры, но в питоне можно по человечески возвращать объект.

cdef class Class2:
        cdef CComPtr[IClass2] thisptr
                
                
cdef class Class1:
        cdef CComPtr[IClass1] thisptr

        def GetClass2 (self):
                class2 = Class2()
                result = self.thisptr.inArg().GetClass2( class2.thisptr.outArg() )
                hresultcheck ( result )
                return class2

С точки зрения инкапсуляции код не очень хороший – один объект залазит в кишки другого. Но в питоне с инкапсуляцией (точнее с приватностью) и так не очень хорошо. Но более красивого способа мы пока не придумали. Есть риск того, что кто-нибудь попробует создать Class2 руками в клиентском коде, ничего хорошего, наверное, не выйдет. Буду рад, если кто подскажет вариант приватного конструктора в питоне.

Примеры кода выше располагаются в файлах с расширением pyx (их, кстати, можно делать много, а не пихать все в один). Это как cpp в плюсах – файл с реализацией. Но в ситоне еще нужен файл с объявлениями – pxd – место, где будут описаны все имена, которые считать сишными.

from libcpp cimport bool

from libcpp.vector cimport vector
from libcpp.string cimport string
from libc.stdlib cimport malloc, free

cdef extern from "mytypes.h":
        ctypedef unsigned short int myUInt16
        ctypedef unsigned long int  myUInt32
        ctypedef myUInt32 HRESULT
        ctypedef struct GUID:
                pass
        ctypedef enum myBool:
                kMyFalse 
                kMyTrue 
                kMyBool_Max 

cdef extern from "hresult.h":
        cdef HRESULT S_OK 
        cdef HRESULT S_FALSE

cdef extern from "Iunknown.h":
        cdef cppclass IUnknown:
                HRESULT QueryInterface (const IID & iid, void ** ppOut)
                HRESULT AddRef ()
                HRESULT Release ()

cdef extern from "CComPtr.h":
        cdef cppclass CComPtr [T]:
                # this is trick, to assign pointer into wrapper
                T& assign "operator="(T*) 
                T* inArg()
                T** outArg()

cdef extern from "comhost.h":
        cdef extern IID IID_IComHost
        cdef cppclass IComHost(IUnknown):
                HRESULT CoCreateInstance ( const GUID& classid,
        IUnknown* pUnkOuter, 
        const IID& iid, 
        void** x   )

Обратите внимание на CComPtr::operator=(). Если в ситоновском коде пытаться напрямую присваивать CComPtr’у – ничего не выйдет. Он просто не сможет толком разобрать эту синтаксическую конструкцию. Пришлось прибегнуть к трюку переименовывания символов. Так assign это то, как символ будет выглядет в ситоне, а в кавычках задается что именно нужно вызвать в сишном коде.

Трюк полезен если нужно назвать питоновский класс или функцию точно так же как и сишную.

pxd:

cdef extern from "MyFunc.h":
        int CMyFunc "MyFunc" ()

pyx:

def MyFunc():
        CMyFunc()

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

def do_operation(param1, param2):
        operation = DoSomethingOperation(param1, param2)
        engine = TransactionEngine()
        future = engine.Submit(operation)
        future.Wait()
        return future.GetResult()

Так наш код стал намного компактнее и понятнее, можно было использовать как высокоуровневые интерфейсы типа do_operation(), так и при необходимости спуститься до «сишных» интерфейсов.

Появилось ощущение гибкости, не нужно было каждый раз перекомпилировать С++ часть. Более того, для старта нам понадобилось заврапить всего 10 интерфейсов, а для каждой последующей фичи нужно было доврапить толко 1-2 – это реально добавляло сил и веры в выбранный подход.

Проблемы начинаются


В таком виде технология уже может подойти для большинства проектов, но мы уперлись в несколько фундаментальных ограничений.

Так, наш COM хост (объект, который обеспечивает инфраструктуру СОМ, всякие там CoCreateInstance и прочее) является обычным плюсовым объектом. А значит, его кто-то должен создать (аналог CoInitialize) и потом удалить (CoFinalize). Но вот в чем проблема, у питоновского модуля нет main(). Во всяком случае, в том виде как нам нужно было.

Поэтому мы создали плюсовый объект Application и положили инициализацию/финализацию хоста в этот объект. Написали врапер, который позволял нам создавать этот объект из питона (в начале каждого скрипта).

Но очень быстро мы начали ловить креши на выходе. Оказалось, что в отличие от С++ (первый созданный объект удалится последним) порядок разрушения объектов в питоне не определен. Ну, во всяком случае, нет возможности на него влиять. В зависимости от фазы луны питон прибивал объект Application первым, тот тушил COM инфраструктуру и принудительно выгружал все компоненты. Потом питон удалял какой-нибудь другой объект, у которого в наличии имелась ссылка на какой-нибудь СОМовский объект. Попытка вызвать Release() из длл, которая уже выгрузилась, приводила к крешу.

Вторая проблема – расположение исполняемого файла. Оказалось, что в нашем большом компоненте очень много мест, которые пытаются открывать файлики с данными по некоторому пути относительно исполняемого файла. Это нормально, если конечное приложение инсталлируется в систему некоторым известным способом. Это так же нормально, если мы работаем с приложением собранным в рабочем каталоге. Но такой способ перестает работать, если исполняемым файлом вдруг становится питон в системной директории.

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

Наконец третья проблема – event loop. Дело в том, что наш модуль весьма сложен и интерактивен. Это не просто библиотека в стиле «вызови функцию – получи результат». Это огромный комбайн. Внутри крутится пара сотен потоков, которые как то обмениваются сообщениями. Некоторые части кода были написаны во времена мезозоя и предназначены для исполнения только в главном потоке (иначе работать не будет). В других местах происходит отсылка сообщения хардкодом в главный поток, ожидая, что там знают, как обработать это сообщение. А еще у нас есть собственная подсистема потоков и сообщений, которая так же подразумевает, что в главном потоке обязательно будет крутиться цикл обработки сообщений и все это дирижировать. Без него никак.

Самым простым решением на старте было вставить в наш класс Application метод run_event_loop(), который крутил цикл сообщений. Процесс останавливался когда завершалась наша полезная работа (как я сейчас понимаю это происходило по чистому совпадению :) )

В общем, у нас нормально работали скрипты типа такого: запускаем некоторую работу с помощью неблокирующей функции (которая не дожидается окончания), после чего с головой уходим в ивент луп

app = Application()
start_some_processing_async()
app.run_event_loop()

Но вот со сценариями, которые требовали некоторой интерактивности получалась проблема. Мы не могли, например, запустить работу, а потом через пару секунд попробовать ее остановить. По факту работа не начиналась, пока не запускался цикл обработки сообщений в главном потоке. А уж если цикл запустился, то в питон мы уже не возвращаемся.

Конечно же, можно было бы городить что-нибудь асинхронное и на уровне питона, но это явно не то, что хотелось бы. Ведь подход предполагалось проталкивать людям, которые не искушены асинхронными системами. Они бы хотели писать просто вот так и не париться какими-то ивент лупами

start_some_processing_async()
time.sleep(3)
cancel_processing()

Недолго думая мы попробовали запустить процессинг в другом потоке, а в главном крутить цикл сообщений. Но мы тут же уперлись в следующую проблему – GIL (Global Interpreter Lock). Оказалось, что питоновские потоки на самом деле не выполняются параллельно. В каждый момент времени работает только один поток, а потоки переключаются каждые 100 команд. Все это регулирует этот самый GIL, останавливая все потоки кроме одного.

Получалось, что если главный поток ушел в функцию app.run_event_loop() и не вернулся (а по дизайну он должен там висеть), то другие питоновские команды в других потоках не выполняются. Просто в главном потоке не набралось еще 100 команд, и интерпретатор посчитал, что переключаться пока рано.

Решение нашлось в ключевом слове ситона nogil. Кусок кода помеченый nogil вначале освобождает GIL, потом выполняет сишный вызов. В конце GIL захватывается опять. Т.о. главный поток отпускал GIL и уходил в цикл сообщений. Второй поток получал управление и делал там все что нужно.

def Func(self):
        result = 0
        cdef IComponent * component = self.thisptr.inArg()
        with nogil:
                result = component.Func()
        hresultcheck(result)

Cython, кстати говоря, штука очень капризная. Он не всегда позволяет мешать питоновский и сишный код в одной строке. Так же он не позволяет вызывать некоторые питоновские конструкции и создавать новые переменные в nogil секциях (логично, для этого нужен доступ к кишкам питона, которые как раз и защищаются GIL'ом). Приходится вот так вот извращаться, что бы правильно объявить переменные и делать нужные вызовы.

Все вроде как начало работать, но очень нестабильно. Мы постоянно ловили какие то креши и подвисания, а так же постоянно натыкались на неработающий функционал (файлик по относительному пути не открылся, но ошибку никто не кинул).

Дизайн наоборот


Мы несколько недель пытались победить эти 3 проблемы, пробовали разные подходы. Но каждый раз возникала очередная неразрешимая проблема. Больше всего доставлял GIL, а как победить выгрузку COM хоста мы вообще не представляли.

Мы даже подумывали спрыгнуть на lua, но некоторые из ограничений все равно бы остались. Только при этом еще пришлось бы проходить весь путь с нуля.

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

  • Инициализировать СОМ хост
  • Стартовать второй поток
  • В главном потоке запускать event loop
  • Во втором потоке запускать интерпретатор питона (у него есть соответствующий API для встраивания в другие приложения)

Этот подход решает одним махом все 3 проблемы:

  1. Мы контролируем время жизни СОМ хоста и можем гарантировать его разрушение после того как питоновский поток закончит свою работу.
  2. Наше тестовое приложение будет жить рядом с основным продуктом, а значит, все относительные пути будут работать.
  3. Наконец, никаких проблем с GIL’ом. Питон выполняет однопоточный скрипт, а значит делить ресурсы ни с кем не нужно.

И знаете? Этот подход сработал! Были, правда, несколько мелких проблем, которые со временем удалось решить

  • некоторые вызовы должны, все-таки, выполняться в главном потоке. Ну ничего, мы запуляли сообщение в главный поток, с просьбой выполнить то, что нужно.
  • Пришлось изрядно повозиться с установкой PYTHON_HOME и PYTHON_PATH. Нетривиальный момент заключался в том, что ни питоновская функция Py_SetPythonHome(), ни стандартная setenv() не копируют переданную строку к себе, а просто запоминают указатель. В нашем случае это был указатель на временную переменную.
  • дабы не зависеть от версии питона, решили все потроха возить с собой. Включая стандартную библиотеку (которая, как оказалось, отлично читается прямо из zip’а) и несколько дополнительных библиотек

Еще одна проблемка, с которой пришлось повозиться – функция sys.exit(). Она нам нужна была, что бы словить код возврата от unittest и передать на выход, после чего потом обрабатывать на CI.

Работает это так. Если кто-то в скрипте вызывает sys.exit(), на самом деле генерируется SystemExit исключение. Это исключение ловится самим питоном и, как и любое другое исключение словленное глобально, должно быть распечатано в консоль вместе со стек трейсом. Но функция Py_PrintEx знает, что есть такой специальный случай, и если нам предлагают напечатать исключение SystemExit, значит нужно вызвать сишный exit()

Да да, вот так! Функция с названием Print делает вызов exit(). И этот exit честно отрабатывает – просто берет и срубает все приложение. И плевать он хотел на то, что в приложении есть неосвобожденные хендлы, незавершенные потоки, незакрытые файлы, нефинализированные модули, миллион активных потоков и все такое прочее.

Но питон (во всяком случае 2.7.6. Старье, знаю) не позволяет это обрулить на уровне API. Пришлось просто скопировать к себе в проект несколько функций из исходников питона (начиная с PyRun_SimpleFileExFlags() и нескольких приватных, которые она вызывает) и допилить под себя. Так, наша версия в случае SystemExit корректно выходит и возвращает код возврата. Т.о. тестовое приложение после завершения питоновской части может корректно себя почистить и потушить.

Поначалу у нас было 2 проектника – один билдил тестовое приложение со встроеным питоном, а второй, как и раньше, подгружаемый модуль для питона. Но позже мы объединили это все в один проектник. Тестовое приложение инициализировало питон, после чего вызывало функцию инициализации нашего питоновского модуля (генерируется ситоном). Т.о. питон уже на старте уже знал про наш модуль (хотя делать импорты все равно приходилось).

Коллбеки


В таком виде тестовое приложение очень хорошо себя показало. Мы прикрутили тестовый фреймворк (стандартный unittest) и тестировщики начали понемногу писать тесты. Мы сами тем временем продолжили врапить интерфейсы.

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

Плюсовый интерфейс выглядит так:

class ICallback : public IUnknown
{
        virtual HRESULT CallbackFunc() = 0;
};
Class IComponent : public IUnknown
{
        virtual HRESULT MethodWithCallback(ICallback * cb) = 0;
};

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

.h

//Forward declaration
struct _object;
typedef struct _object PyObject;


class CCallback : public ICallback
{
        //COM stuff
        ... 

        CCallback * Create();
        
        // ICallback
        virtual HRESULT CallbackFunc();
        
public:
        void SetPythonCallbackObject(PyObject * callback_handler);

private:
        PyObject * m_pPythonCallbackObject;
};

.cpp

const char PythonMethodName[] = "PythonCallbackMethod";

void CCallback::SetPythonCallbackObject(PyObject * callback_handler)
{
        // Do not addref to avoid cyclic dependency
        m_pPythonCallbackObject = callback_handler;
}

HRESULT CCallback::CallbackFunc()
{
        if(!m_pPythonCallbackObject)
                return S_OK;

        // Acquire GIL
        PyGILState_STATE gstate = PyGILState_Ensure();
        if ( gstate == PyGILState_UNLOCKED )
        {
                // Call the python method
                char * methodName = const_cast<char *>(PythonMethodName); //Py_Api doesn't work with constant char *
                PyObject * ret = PyObject_CallMethod(m_pPythonCallbackObject, methodName, NULL);
                if (!ret)
                {
                        if (PyErr_Occurred())
                        {
                                PyErr_Print();
                        }
                        std::cout<<"cannot call"<<PythonMethodName<<std::endl;
                }
                else
                        Py_DecRef(ret);
        }

        // Release the GIL
        PyGILState_Release(gstate);
        return S_OK;
}

Особый момент в этом коде – захват GIL. Иначе в лучшем случае питон сломается на проверке, что GIL захвачен, но скорее всего либо повиснет, либо скрешится.

У нас консольное приложение, поэтому вывод ошибок в консоль самое оно. Даже если питоновский код выкинет исключение наш обработчик его поймает и распечатает трейсбек.

Со стороны ситона это выглядит так:

cdef class PyCallback (object):
        cdef CComPtr[ICallback] callback

        def __cinit__(self):
                self.callback.assign( CCallback.Create() )
                self.callback.inArg().SetPythonCallbackObject(<PyObject *> self)


        def PythonCallbackMethod(self):
                print "PythonCallbackMethod called"

                

cdef class Component:
        cdef CComPtr[IComponent] thisptr

        def __cinit__(self):
                // Create IComponent instance
                ...
        
                
        def CallMethodWithCallback(self, PyCallback callback):
                cdef IComponent * component = self.thisptr.inArg()
                cdef ICallback * cb = callback.callback.inArg()
                hresult = 0
                
                with nogil:
                        hresult = component.MethodWithCallback(cb)
                hresultcheck(hresult)

При вызове метода MethodWithCallback() обязательно нужно отпустить GIL, иначе коллбек не сможет его захватить.

С клиентским питоновским кодом уже все должно быть просто и понятно

component = Component()
callback = PyCallback()
component.CallMethodWithCallback(callback)

Параметры в коллбеках тоже не сложно организовать, но нам пока это еще делать не приходилось. Думаю, там нужно будет немного поколдовать над питоновским API, или подсмотреть какой код генерит cython.

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

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

Мы поступили так. В сишном коде, который вызывает питоновский объект, добавили глобальный счетчик активных коллбеков. Пришлось в нашей версии PyRun_FileExFlags(), которую мы к тому времени уже слямзили из исходников питона, перед вызовом PyArena_Free() впилить такой кусочек

 PyThreadState *_save = PyEval_SaveThread();
        while (GetCurrentlyActiveCallbacks() > 0)
                ; // semicolon here is correct
        PyEval_RestoreThread(_save);

Заключение


Бутерброд python->cython->C++ оказалась очень удачным как фреймворк для API автотестов. Порог вхождения в питон очень мал, по сравнению с другими языками программирования. Любой грамотный тестировщик за пару дней осилит питон на уровне достаточном для написания автотестов. Главное в этом деле придумать как можно протестировать тот или иной функционал, а уж выразить это в коде – дело техники.

Нам удалось построить удобную для использования прослойку, которая в питон выставляет простой и понятный интерфейс. Вызовы низкоуровневого С++/СОМ кода скрыты за враперами и понимание этого кода для большинства задач не нужно. Но в случае необходимости туда легко спуститься.

Недавно мы прикрутили PyTest в качестве фреймворка для написания тестов. Очень рулит! Тесты стали еще проще, понятнее и быстрее.

Сейчас не видно каких либо серьезных архитектурных недостатков, но кое какие баги еще есть. Так, например, сейчас есть пара циклических зависимостей, из-за чего несколько ключевых объектов никак не хотят разрушаться. Но где правильно разорвать циклическую зависимость мы пока не придумали.

Что касается самого ситона. Разработчики cython’а идут на контакт и уже починили для нас парочку багов. Релизы у них выходят регулярно.

Cython штука достаточно капризная. Не всегда вразумительно объясняет, что же именно не верно в исходном файле, а порой еще и указывает не туда. Некоторые вещи мы так и не осилили.

Мы так же прикрутили интерактивный режим питона. Иногда удобнее отлаживать питоновский код прямо в консоли питона, чем править скрипт и запускать его снова и снова. Оказалось все просто – достаточно вызвать это:

PyRun_InteractiveLoop(stdin, "<stdin>");

Девелоперы С++ модуля активно запускают тесты прямо из IDE. Можно просто взять тест, который завалился на CI и отдебажить. Все бряки в С++ коде работают как нужно. Но вот как дебажить питоновскую часть мы пока еще не придумали. Впрочем, небыло особой необходимости — с PyTest тесты получаются очень простые и дебажить там практически нечего.

Теперь уже пришло осознание того, что технология состоялась. Расширять функционал легко и удобно. Народу понравилось писать автотесты, хотя пока относятся с подозрением.

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

This entry passed through the Full-Text RSS service - if this is your content and you're reading it on someone else's site, please read the FAQ at http://ift.tt/jcXqJW.

Комментариев нет:

Отправить комментарий