2025, Dec 07 18:03

Как встраивать CPython в C++‑DLL на Windows и не зависеть от vcpkg

Как упаковать CPython с C++‑DLL на Windows: PYTHONHOME, Lib/DLLs, обход ошибки ModuleNotFoundError: encodings, загрузка python311.dll и риски безопасности.

Встраивать CPython в C++‑DLL на Windows кажется делом простым — пока сборка работает только на машине разработчика и рассыпается на других. Характерный признак — ModuleNotFoundError: No module named 'encodings' после переноса бинарников из каталога решения. Первопричина не в вызовах API, а в устройстве рантайма и механизме, по которому CPython находит стандартную библиотеку. Ниже — практическое объяснение, почему так происходит, и как упаковать Python так, чтобы DLL запускалась где угодно и не зависела от vcpkg_installed.

Минимальное встраивание, которое «работает» в дереве сборки

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

void ensurePyBootstrapped() {
    if (!Py_IsInitialized()) {
        PyConfig cfg;
        PyConfig_InitPythonConfig(&cfg);

        wchar_t modPath[MAX_PATH];
        GetModuleFileNameW(NULL, modPath, MAX_PATH);

        std::wstring modDir = modPath;
        size_t offs = modDir.find_last_of(L"\\");
        if (offs != std::wstring::npos) {
            modDir = modDir.substr(0, offs);
        }

        std::wstring homeDir = modDir + L"\\..\\..\\vcpkg_installed\\x64-windows-static-md\\x64-windows-static-md\\tools\\python3";

        wchar_t absBuf[MAX_PATH];
        GetFullPathNameW(homeDir.c_str(), MAX_PATH, absBuf, NULL);
        homeDir = absBuf;

        std::wstring libDir = homeDir + L"\\Lib";
        std::wstring sitePkgs = libDir + L"\\site-packages";
        std::wstring dllDir = homeDir + L"\\DLLs";

        PyConfig_SetString(&cfg, &cfg.home, homeDir.c_str());

        std::wstring searchPath = libDir + L";" + sitePkgs + L";" + dllDir;
        PyConfig_SetString(&cfg, &cfg.pythonpath_env, searchPath.c_str());

        PyStatus st = Py_InitializeFromConfig(&cfg);
        PyConfig_Clear(&cfg);

        if (PyStatus_Exception(st)) {
            PyErr_Print();
            return;
        }

        PyRun_SimpleString("import sys");
        PyRun_SimpleString("sys.path.append(\".\")");
    }
}

void EXPORTED_API pyEcho(const char* msg) {
    ensurePyBootstrapped();

    PyObject* s = PyUnicode_FromString(msg);
    if (s) {
        PyObject* builtins = PyEval_GetBuiltins();
        PyObject* printFn = PyDict_GetItemString(builtins, "print");
        if (printFn && PyCallable_Check(printFn)) {
            PyObject* args = PyTuple_Pack(1, s);
            PyObject_CallObject(printFn, args);
            Py_DECREF(args);
        }
        Py_DECREF(s);
    }
}

Клиентское приложение:

#include <iostream>
#include "py_embed.h"

int main() {
    std::cout << "Hello";
    pyEcho("Hello from Python");
}

Что идет не так и почему

CPython не становится самодостаточным лишь от того, что вы кладете рядом python3.dll и python312.dll. Интерпретатору нужна стандартная библиотека в PYTHONHOME (его можно переопределить через конфигурационные API). Когда запуск идет рядом с vcpkg_installed, каталоги Lib, Lib\site-packages и DLLs доступны, поэтому импорты вроде encodings разрешаются. Перенесите бинарники в другое место — и эти директории исчезнут; интерпретатор стартует, но сразу падает на импорте encodings. Сообщение о sys.path и ошибке — прямое следствие того, что PYTHONHOME указывает на путь, существующий только внутри дерева сборки.

Есть еще одно практическое ограничение в Windows. Загрузчик процесса должен найти python311.dll или python312.dll в момент загрузки. Если положить Python‑DLL рядом с вашей DLL, загрузчик будет доволен. Если перенести Python‑DLL в подкаталог, придется заранее прописать этот путь в PATH либо загружать библиотеку явно через LoadLibrary вместо прямого линкования.

Сделайте DLL независимой от vcpkg_installed

Надежный вариант — поставлять интерпретатор вместе с приложением и инициализировать CPython на упакованном рантайме. Установите PYTHONHOME до вызова Py_InitializeFromConfig и направьте его на каталог python, который идет в комплекте с вашими бинарниками. Так делают многие настольные приложения.

.
└── app_root/
    ├── app.exe
    ├── python311.dll
    └── python/
        ├── DLLs/
        │   ├── _ctypes.pyd
        │   └── ...
        ├── Lib/
        │   ├── site_packages/
        │   │   └── numpy, etc...
        │   ├── os.py
        │   └── ... 
        └── bin (optional)/
            ├── python311.dll (see note below)
            └── python.exe (see note below)

Если python311.dll лежит не рядом с исполняемым файлом, а в подкаталоге, скорректируйте PATH или загрузите её через LoadLibrary, избегая прямого линкования. Если положить в комплект python.exe, пользователи смогут ставить дополнительные библиотеки через pip, запуская python.exe -m pip install ...

Исправленная инициализация, указывающая на ваш упакованный Python

Эта инициализация берет за якорь каталог исполняемого файла и задает PYTHONHOME равным app_root\python. Остальная логика прежняя, но жесткая привязка к дереву vcpkg_installed исчезает.

void initEmbeddedPython() {
    if (!Py_IsInitialized()) {
        PyConfig cfg;
        PyConfig_InitPythonConfig(&cfg);

        wchar_t exeBuf[MAX_PATH];
        GetModuleFileNameW(NULL, exeBuf, MAX_PATH);

        std::wstring baseDir = exeBuf;
        size_t cut = baseDir.find_last_of(L"\\");
        if (cut != std::wstring::npos) {
            baseDir = baseDir.substr(0, cut);
        }

        std::wstring pkgHome = baseDir + L"\\python";
        std::wstring libDir = pkgHome + L"\\Lib";
        std::wstring sitePkgs = libDir + L"\\site-packages";
        std::wstring dllDir = pkgHome + L"\\DLLs";

        PyConfig_SetString(&cfg, &cfg.home, pkgHome.c_str());

        std::wstring searchPath = libDir + L";" + sitePkgs + L";" + dllDir;
        PyConfig_SetString(&cfg, &cfg.pythonpath_env, searchPath.c_str());

        PyStatus st = Py_InitializeFromConfig(&cfg);
        PyConfig_Clear(&cfg);

        if (PyStatus_Exception(st)) {
            PyErr_Print();
            return;
        }

        PyRun_SimpleString("import sys");
        PyRun_SimpleString("sys.path.append(\".\")");
    }
}

void EXPORTED_API pyEcho(const char* msg) {
    initEmbeddedPython();

    PyObject* s = PyUnicode_FromString(msg);
    if (s) {
        PyObject* builtins = PyEval_GetBuiltins();
        PyObject* printFn = PyDict_GetItemString(builtins, "print");
        if (printFn && PyCallable_Check(printFn)) {
            PyObject* args = PyTuple_Pack(1, s);
            PyObject_CallObject(printFn, args);
            Py_DECREF(args);
        }
        Py_DECREF(s);
    }
}

С такой раскладкой интерпретатор находит encodings и остальную стандартную библиотеку в упакованной папке python, независимо от системы сборки. Python‑DLL должна лежать рядом с вашим EXE или DLL, чтобы удовлетворить загрузчик Windows, если только вы не используете обходной путь через PATH или LoadLibrary.

Когда полноценный CPython — избыточен

Если вам нужна лишь разборка или выполнение синтаксиса Python для легкого скриптинга и не требуются C/C++‑расширения вроде numpy, присмотритесь к другим реализациям: RustPython, IronPython, JYThon или pocketpy. Их можно встраивать без комплектования стандартной библиотеки CPython, но экосистему C‑расширений CPython они не поддерживают.

Компромиссы по безопасности, о которых стоит помнить

Упаковка Python означает, что стандартная библиотека и ваши скрипты лежат на диске рядом с приложением. Пользователи могут их читать и менять. Это исключает хранение секретов и создает риск внедрения кода через локальные правки. Снизить возможность подмены помогает кастомный импортер, который подгружает код из памяти, а не с диска, хотя нативные расширения усложняют этот подход. Даже в этом случае решительный атакующий сможет реверс‑инжинирить решение.

Почему это важно

Без стандартной библиотеки в PYTHONHOME интерпретатор срывается на первом импорте — как в случае с encodings. Понимая ожидания рантайма CPython, вы сможете собрать воспроизводимый пакет для развёртывания, а не эксперимент, зависящий от дерева сборки. Это также объясняет, почему раздача «одной DLL» нереалистична, если вы используете CPython и C‑расширения.

Итоги

Надежная схема встраивания Python в C++‑DLL на Windows — поставлять интерпретатор и стандартную библиотеку вместе с приложением, направлять PYTHONHOME на этот пакет и инициализировать через Py_InitializeFromConfig. Держите Python‑DLL там, где её найдет загрузчик, либо загружайте явно. Если экосистема CPython не нужна, альтернативные интерпретаторы упростят дистрибуцию ценой совместимости с расширениями. Учитывайте последствия для безопасности от поставки читаемого и изменяемого кода Python и выбирайте компромиссы под свою задачу.