вторник, 27 сентября 2022 г.

[prog.c++] Опыт встраивания интепретатора Python-а в C++ приложение посредством pybind11, vcpkg и CMake

Давеча потребовалось встроить интерпретатор Python-а в C++ приложение.

Поскольку в проекте уже использовался vcpkg, то часть проблем отпала сама собой: подтянуть Python3 в проект и слинковаться с ним особой проблемы не составило. Правда пришлось в CMakeLists.txt для приложения, в которое Python3 вставлялся, добавить несколько строчек, чтобы на Linux-е к приложению линковалось бы еще и библиотеки util и dl.

Под Linux-ом результирующий бинарник даже сразу запустился. А вот под Windows при старте приложение выдавало ошибку типа вот такой:

Fatal Python error: init_fs_encoding: failed to get the Python codec of the filesystem encoding
Python runtime state: core initialized

ModuleNotFoundError: No module named 'encodings'

и все, никакой нормальной работы.

Как я понял, проблема в том, что когда Python стартует, то он пытается разыскать свою стандартную библиотеку (т.е. туеву хучу *.py файлов). И под Windows он ее найти не может.

В процессе разбирательства выяснилось интересное. Когда мы работаем в Linux-е, то vcpkg при компиляции Python3 раскидывает компоненты Python следующим образом (cmake-build -- это каталог, в котором идет сборка проекта):


cmake-build/
`- vcpkg_installed/
   `- x64-linux/
     `- ...
     `- lib/
        `- ...
        `- python3.10/
           `- *.py       # Тут у нас stdlib для Python3.
        `- ...
      `- tools/
         `- python3/
            `- python    # А тут сам Python3.
            `- ...

При этом в результирующий бинарник каким-то образом хардкодятся абсолютные пути к стандартной библиотеке Python-а. Т.е. что-то вроде:

/home/eao197/prj/cmake-build/vcpkg_installed/lib/python3.10

Соответственно, если я удалю свой cmake-build или перенесу результат компиляции на другую машину с Linux-ом, где никакого vcpkg_installed нет, то приложение не сможет стартовать с той же самой ошибкой.

А вот в Windows результат компиляции Python3 размещается по-другому:


cmake-build/
`- vcpkg_installed/
   `- x64-windows-static/
     `- ...
     `- lib              # lib есть, но Python-а там нет.
     `- tools/
        `- python3/
           `- lib/
              `- *.py   # Тут у нас stdlib для Python3.
           `- python    # А тут сам Python3.
           `- ...

и результирующий бинарник про расположение стандартной библиотеки Python3 вообще ничего не знает.

Соответственно, выход виделся в том, чтобы расположить стандартную библиотеку Python3 прямо рядом с результирующим бинарником. Чтобы было что-то вроде:


`- bin/
   `- python3-lib/
      `- *.py         # Тут у нас stdlib для Python3.
   `- my_app          # А тут само приложение.

Чтобы разобраться с этой проблемой нужно было решить два вопроса.

Первый вопрос: как при компиляции приложения разместить рядом с ним стандартную библиотеку Python3?

Этот вопрос решился тем, что в проектный CMakeLists.txt для my_app добавилось еще одно правило по копированию стандартной библиотеки Python3 в нужное мне место. Что-то типа:


add_custom_command(
	TARGET ${MY_APP}
	POST_BUILD
	COMMAND ${CMAKE_COMMAND}
	-E copy_directory ${PYTHON3_LIBRARY_SOURCE_DIR} ${PYTHON3_LIBRARY_DEST_DIR}
)

Правда при этом пришлось вычислять расположение стандартной библиотеки в зависимости от платформы. Как-то так:


find_package(Python3 COMPONENTS Development REQUIRED)

get_filename_component(PYTHON3_EXECUTABLE_DIR
		${Python3_EXECUTABLE} DIRECTORY)

if(WIN32)
	set(PYTHON3_LIBRARY_SOURCE_DIR ${PYTHON3_EXECUTABLE_DIR}/lib)
else()
	set(PYTHON3_LIBRARY_SOURCE_DIR ${PYTHON3_EXECUTABLE_DIR}/../../lib/python3.10)
endif()

set(PYTHON3_LIBRARY_DEST_DIR ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/python3-lib)

Плюс к тому, я не в курсе как в CMake проверить, что python3-lib уже скопирован и не делать копию еще раз. В итоге этот самый custom-command заставляет копировать стандартную библиотеку каждый раз когда приложение собирается.

Upd. Под Linux-ом нужно озаботиться еще и передачей линкеру опций -Xlinker -export-dynamic, причины смотрим здесь. Так что в CMake для проекта нужно добавить еще что-то вроде:

if(NOT WIN32)
	target_link_options(${MY_APP} PRIVATE -Xlinker -export-dynamic)
endif()

Второй вопрос: как при запуске интерпретатора Python-а указать ему где искать стандартную библиотеку?

Этот вопрос решился как-то вот так (Upd. на самом деле нужно добавлять несколько путей к библиотекам):


Py_SetPythonHome( program_path );

PyStatus status;

PyConfig config;
PyConfig_InitIsolatedConfig( &config );
std::unique_ptr< PyConfig, PyConfig_cleaner > config_cleaner{ &config };

for( const auto & additional_path : {
		L"/python3-lib",
		L"/python3-lib/lib-dynload",
		L"/python3-lib/site-packages" } )
{
	status = PyWideStringList_Append(
			&config.module_search_paths,
			(std::wstring{ program_path } + additional_path).c_str() );
	if( PyStatus_Exception( status ) )
	{
		throw std::runtime_error{...};
	}
}
config.module_search_paths_set = 1;

status = Py_InitializeFromConfig( &config );
if( PyStatus_Exception( status ) )
{
	throw std::runtime_error{...};
}

Т.е. сперва вручную задается PYTHONHOME, а затем в конфигурации для Python-а добавляется подкаталог "python3-lib".

Уж не знаю, насколько все это корректно, но оно хотя бы работает.

При этом в проекте планируется использовать pybind11 и первоначально я хотел запускать Python через pybind11::scoped_interpreter. Но проблема в том, что конструктор scoped_interpreter сам запускает Python управляя его конфигурацией, а это не позволяет мне задать пути поиска стандартной библиотеки Python-а.

Поэтому пришлось делать свой аналог scoped_interpreter, который в конструкторе выполняет нужные мне действия, а вот в деструкторе вызывает pybind11::finalize_interpreter.

Но и это было еще не все :(

Отдельных хлопот добавило формирование значения, которое отдается в вызов Py_SetPythonHome. Дело в том, что туда нужно отдать wchar_t*, тогда как main в приложении получает argv как char ** и путь к программе вычисляется в виде объекта std::filesystem::path.

Под Windows все решилось так: у std::filesystem::path вызывается wstring() и получившийся std::wstring уже отдается в Py_SetPythonHome. По крайней мере в VC++ все это работает без проблем, в том числе когда в пути есть имена каталогов русскими буквами.

А вот под Linux-ом попытка вызвать std::filesystem::path::wstring() для пути, содержащего в себе русские буквы, приводит к выбросу исключения. Видимо, чего-то в реализации стандартной библиотеки от GCC не хватает.

При этом значение argv[0] в Linux-е задается в UTF-8 поэтому, в общем-то, ничего не мешает преобразовать UTF-8 в голый юникод и представить результат в виде std::wstring. Но...

Но, как оказалось, Python-у не нравится, когда ему в wchar_t* отдают голый Unicode. Такое ощущение, что он ждет значение в каком-то другом представлении, типа UTF-16 или UTF-32.

Поэтому под Linux значение из std::filesystem::path::c_str() нужно отдать в Py_DecodeLocale, а уже результат Py_DecodeLocale следует передавать в Py_SetPythonHome. Тогда работает как ожидается.

Странно, что под Windows использовать Py_DecodeLocale у меня не получилось: возвращенное им значение как-то неадекватно воспринимается Py_SetPythonHome. Такое ощущение, что под Windows нужно именно то представление, которое возвращается std::filesystem::path::wstring() (т.е. UCS-2).

Вот как-то так. Не без приключений.

Если кто-то знает как внедрить Python3 в C++ приложение более прямым способом, то поделитесь рецептом, плз.

Мне же в разбирательстве помогли следующие ссылки:


В очередной раз вынужден сказать, что CMake -- это редкостное говно. Мне обидно быть коллегой тех говнокодеров, которые высрали это на свет божий. Периодически хочется послать C++ куда подальше и уйти, пусть даже в Rust. Но сильнее всего это желание проявляется когда приходится погружаться в потроха CMake-овских скриптов.

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