From a7da4ea728e67cf28af94d7ae1b68348d9ee0cce Mon Sep 17 00:00:00 2001 From: Evgeny Demchenko Date: Sat, 13 Jun 2026 12:59:04 +0100 Subject: [PATCH] python: skeleton pybind11 bindings (issue #6 task #197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Каркас Python-пакета `cuframes`: - python/pyproject.toml — scikit-build-core конфиг - python/CMakeLists.txt — pybind11 module через FetchContent - python/src/_native.cpp — module entry, error таксономия, enum mirrors (PixelFormat, SubscriberMode), version - python/cuframes/__init__.py — re-export публичного API - python/tests/test_smoke.py — smoke tests без real subscribe - python/README.md — статус + build instructions - CMakeLists.txt — подключение python/ при BUILD_PYTHON_BINDINGS=ON Реальный subscriber/frame wrapper в следующих коммитах (tasks #198-#202). Co-Authored-By: Claude Opus 4.7 --- CMakeLists.txt | 4 ++ python/.gitignore | 7 ++ python/CMakeLists.txt | 52 ++++++++++++++ python/README.md | 53 ++++++++++++++ python/cuframes/__init__.py | 56 +++++++++++++++ python/pyproject.toml | 47 +++++++++++++ python/src/_native.cpp | 136 ++++++++++++++++++++++++++++++++++++ python/tests/test_smoke.py | 51 ++++++++++++++ 8 files changed, 406 insertions(+) create mode 100644 python/.gitignore create mode 100644 python/CMakeLists.txt create mode 100644 python/README.md create mode 100644 python/cuframes/__init__.py create mode 100644 python/pyproject.toml create mode 100644 python/src/_native.cpp create mode 100644 python/tests/test_smoke.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 6c7ed1c..1466702 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -39,3 +39,7 @@ endif() if(BUILD_TOOLS) add_subdirectory(tools/cuframes-rtsp-source) endif() + +if(BUILD_PYTHON_BINDINGS) + add_subdirectory(python) +endif() diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 0000000..130e886 --- /dev/null +++ b/python/.gitignore @@ -0,0 +1,7 @@ +build/ +dist/ +*.egg-info/ +__pycache__/ +*.pyc +*.so +.pytest_cache/ diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt new file mode 100644 index 0000000..9de45b2 --- /dev/null +++ b/python/CMakeLists.txt @@ -0,0 +1,52 @@ +# Python bindings for cuframes — pybind11 module. +# +# Buildup: используется как subdirectory из root CMakeLists.txt при +# BUILD_PYTHON_BINDINGS=ON, либо standalone через scikit-build-core +# (см. pyproject.toml). +# +# Output: единый shared module `_native.so` который импортируется из +# Python package `cuframes` (cuframes/__init__.py re-export'ит публичный API). + +include(FetchContent) + +# pybind11 — header-only + helper functions. FetchContent чтобы не требовать +# system install; pinned tag для воспроизводимых билдов. +FetchContent_Declare( + pybind11 + GIT_REPOSITORY https://github.com/pybind/pybind11.git + GIT_TAG v2.13.6 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(pybind11) + +pybind11_add_module(_native MODULE + src/_native.cpp +) + +target_include_directories(_native PRIVATE + ${PROJECT_SOURCE_DIR}/include +) + +target_link_libraries(_native PRIVATE + cuframes # imported target из libcuframes/CMakeLists.txt +) + +# Версия модуля соответствует libcuframes (см. cuframes.h) +target_compile_definitions(_native PRIVATE + CUFRAMES_PY_BINDING_VERSION="${PROJECT_VERSION}" +) + +set_target_properties(_native PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + CXX_VISIBILITY_PRESET hidden + INTERPROCEDURAL_OPTIMIZATION TRUE +) + +# При scikit-build-core билде модуль попадает в wheel рядом с Python-исходниками +# пакета. При standalone CMake — устанавливается в site-packages по умолчанию. +if(SKBUILD) + install(TARGETS _native DESTINATION cuframes) +else() + install(TARGETS _native LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}/cuframes) +endif() diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..1e2a558 --- /dev/null +++ b/python/README.md @@ -0,0 +1,53 @@ +# cuframes — Python bindings + +Status: **WIP** (Phase 0 skeleton — issue [gx/cuframes#6](http://server:3000/gx/cuframes/issues/6)) + +Это пакет Python-обёрток над `libcuframes` (C ABI). Цель — позволить +downstream ML/CV пайплайнам (yolo-world-detector, zone-motion, custom +скриптам) подписываться на cuframes без CPU round-trip: получать NV12 +frames прямо как CUDA pointer / `torch.Tensor` (DLPack export, zero-copy). + +## Текущий статус (что уже работает в этом skeleton) + +- Module import: `import cuframes` загружает `_native.so` +- Версия: `cuframes.version_string()`, `cuframes.protocol_version()` +- Enums: `PixelFormat`, `SubscriberMode` +- Иерархия исключений: `CuframesError` + 8 subclasses (publisher gone, + frame timeout, device lost, и т. д.) + +## Что в работе (см. tasks #198-#202) + +- [ ] `CuframesSubscriber` + `CuframesFrame` lifecycle +- [ ] DLPack export → `torch.from_dlpack`, `cupy.from_dlpack` +- [ ] Context manager (`with cuframes.subscribe(key) as sub:`) +- [ ] Per-subscriber CUDA stream +- [ ] Health/stats properties (`ring_occupancy`, `drop_count`) +- [ ] Thread-safety contract документация + +## Build (dev) + +Standalone wheel: + +```bash +cd python/ +pip install -e . --no-build-isolation +``` + +Через корневой CMake-проект (вместе с libcuframes): + +```bash +cmake -B build -DBUILD_PYTHON_BINDINGS=ON +cmake --build build -j +``` + +## Зависимости + +- `libcuframes` ≥ 0.4 (линкуется из соседнего CMake target) +- CUDA Toolkit 12+ +- `pybind11` 2.13+ (берётся через FetchContent при CMake-сборке) +- Python 3.10+ +- Опционально: `torch>=2.4` или `cupy-cuda12x>=13` для DLPack-потребителей + +## Лицензия + +LGPL-2.1+ (как у libcuframes). diff --git a/python/cuframes/__init__.py b/python/cuframes/__init__.py new file mode 100644 index 0000000..171d3ed --- /dev/null +++ b/python/cuframes/__init__.py @@ -0,0 +1,56 @@ +"""cuframes — zero-copy CUDA frame sharing. + +Python bindings to libcuframes. См. docs/python.md (т.б.д.) для +архитектуры, threading контракта и примеров интеграции с PyTorch/CuPy. + +Пример использования (skeleton — реальный subscriber API в работе, см. +issue gx/cuframes#6): + + import cuframes + + print(cuframes.version_string()) # "0.4.0" + print(cuframes.protocol_version()) # uint32 + + # TODO (task #198): + # with cuframes.subscribe("cam-parking") as sub: + # for frame in sub.frames(timeout_ms=1000): + # tensor = torch.from_dlpack(frame) + # ... +""" + +from ._native import ( + # Метаданные + version_string, + protocol_version, + # Enums + PixelFormat, + SubscriberMode, + # Error taxonomy + CuframesError, + CuframesPublisherGone, + CuframesFrameTimeout, + CuframesDeviceLost, + CuframesShmError, + CuframesProtocolMismatch, + CuframesInvalidArgument, + CuframesOutOfMemory, + CuframesInternal, +) + +__version__ = version_string() + +__all__ = [ + "version_string", + "protocol_version", + "PixelFormat", + "SubscriberMode", + "CuframesError", + "CuframesPublisherGone", + "CuframesFrameTimeout", + "CuframesDeviceLost", + "CuframesShmError", + "CuframesProtocolMismatch", + "CuframesInvalidArgument", + "CuframesOutOfMemory", + "CuframesInternal", +] diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..7dd4632 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,47 @@ +[build-system] +requires = [ + "scikit-build-core>=0.10", + "pybind11>=2.13", +] +build-backend = "scikit_build_core.build" + +[project] +name = "cuframes" +version = "0.4.0" +description = "Python bindings for cuframes — zero-copy CUDA frame sharing" +readme = "README.md" +license = { text = "LGPL-2.1+" } +requires-python = ">=3.10" +authors = [{ name = "Evgeny Demchenko", email = "demchenkoev@gmail.com" }] +keywords = ["cuda", "video", "ipc", "zero-copy"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU Lesser General Public License v2 or later (LGPLv2+)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Multimedia :: Video", +] + +[project.optional-dependencies] +torch = ["torch>=2.4"] +cupy = ["cupy-cuda12x>=13"] +dev = ["pytest>=8", "ruff>=0.6"] + +[tool.scikit-build] +cmake.version = ">=3.20" +cmake.build-type = "Release" +build-dir = "build/{wheel_tag}" +wheel.packages = ["cuframes"] +# Будем строить только Python модуль; libcuframes собирается отдельно +# в основном CMake-проекте и линкуется как imported target. +cmake.args = ["-DBUILD_PYTHON_BINDINGS=ON", "-DBUILD_EXAMPLES=OFF", "-DBUILD_TOOLS=OFF"] +cmake.source-dir = ".." + +[tool.scikit-build.cmake.define] +BUILD_PYTHON_BINDINGS = "ON" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/python/src/_native.cpp b/python/src/_native.cpp new file mode 100644 index 0000000..7090451 --- /dev/null +++ b/python/src/_native.cpp @@ -0,0 +1,136 @@ +// cuframes Python bindings — pybind11 entry point. +// +// Этот файл — skeleton модуля. Полноценные обёртки subscriber/frame появятся +// в следующих коммитах (см. issue gx/cuframes#6, tasks #198-#202). +// +// Контракт thread-safety: см. docs/python.md (т.б.д. в task #202). + +#include +#include + +#include "cuframes/cuframes.h" + +namespace py = pybind11; + +namespace { + +// ───────────────────────────────────────────────────────────────────────────── +// Error taxonomy — Python exceptions, соответствующие cuframes_error_t. +// +// Принцип: каждая категория ошибок которая требует разной обработки в +// downstream'е (reconnect vs retry vs fatal) → отдельный exception class. +// Это решает требование из architect review: «detector должен уметь +// reconnect-loop по publisher-gone, не падать». +// ───────────────────────────────────────────────────────────────────────────── + +struct CuframesExceptions { + py::object base; + py::object publisher_gone; // CUFRAMES_ERR_DISCONNECTED, _NOT_FOUND + py::object frame_timeout; // CUFRAMES_ERR_TIMEOUT, _WOULD_BLOCK + py::object device_lost; // CUFRAMES_ERR_CUDA + py::object shm_error; // CUFRAMES_ERR_IO + py::object protocol_mismatch; // CUFRAMES_ERR_PROTOCOL + py::object invalid_argument; // CUFRAMES_ERR_INVALID_ARG + py::object out_of_memory; // CUFRAMES_ERR_OUT_OF_MEMORY + py::object internal; // CUFRAMES_ERR_INTERNAL, прочее +}; + +CuframesExceptions g_exc; + +// Маппинг cuframes_error_t → подходящий Python exception class. +py::object exception_for(int err) { + switch (err) { + case CUFRAMES_ERR_NOT_FOUND: + case CUFRAMES_ERR_DISCONNECTED: + return g_exc.publisher_gone; + case CUFRAMES_ERR_TIMEOUT: + case CUFRAMES_ERR_WOULD_BLOCK: + return g_exc.frame_timeout; + case CUFRAMES_ERR_CUDA: + return g_exc.device_lost; + case CUFRAMES_ERR_IO: + return g_exc.shm_error; + case CUFRAMES_ERR_PROTOCOL: + return g_exc.protocol_mismatch; + case CUFRAMES_ERR_INVALID_ARG: + return g_exc.invalid_argument; + case CUFRAMES_ERR_OUT_OF_MEMORY: + return g_exc.out_of_memory; + default: + return g_exc.internal; + } +} + +// Бросает подходящий exception если err != CUFRAMES_OK. +void check(int err, const char* operation = nullptr) { + if (err == CUFRAMES_OK) return; + const char* msg = cuframes_strerror(err); + std::string what = operation + ? std::string(operation) + ": " + msg + " (code=" + std::to_string(err) + ")" + : std::string(msg) + " (code=" + std::to_string(err) + ")"; + PyErr_SetString(exception_for(err).ptr(), what.c_str()); + throw py::error_already_set(); +} + +} // namespace + +PYBIND11_MODULE(_native, m) { + m.doc() = "cuframes — zero-copy CUDA frame sharing (native bindings)"; + + // ── Версия ────────────────────────────────────────────────────────── + m.def("version_string", []() { + return std::string(cuframes_version_string()); + }, "Runtime version of libcuframes (MAJOR.MINOR.PATCH)."); + + m.def("protocol_version", []() { + return static_cast(cuframes_protocol_version()); + }, "Wire-protocol version. Subscribers с разной версией не подключатся."); + + m.attr("__binding_version__") = CUFRAMES_PY_BINDING_VERSION; + + // ── Error taxonomy ────────────────────────────────────────────────── + // Иерархия: + // CuframesError (base) + // ├── CuframesPublisherGone + // ├── CuframesFrameTimeout + // ├── CuframesDeviceLost + // ├── CuframesShmError + // ├── CuframesProtocolMismatch + // ├── CuframesInvalidArgument + // ├── CuframesOutOfMemory + // └── CuframesInternal + // + // Downstream code может ловить либо конкретный subtype, либо CuframesError + // как catch-all. + + g_exc.base = py::exception(m, "CuframesError").attr("__class__"); + auto make_subexc = [&m](const char* name) { + return py::exception(m, name, g_exc.base.ptr()).attr("__class__"); + }; + g_exc.publisher_gone = make_subexc("CuframesPublisherGone"); + g_exc.frame_timeout = make_subexc("CuframesFrameTimeout"); + g_exc.device_lost = make_subexc("CuframesDeviceLost"); + g_exc.shm_error = make_subexc("CuframesShmError"); + g_exc.protocol_mismatch = make_subexc("CuframesProtocolMismatch"); + g_exc.invalid_argument = make_subexc("CuframesInvalidArgument"); + g_exc.out_of_memory = make_subexc("CuframesOutOfMemory"); + g_exc.internal = make_subexc("CuframesInternal"); + + // ── Pixel formats (enum mirror) ───────────────────────────────────── + py::enum_(m, "PixelFormat") + .value("NV12", CUFRAMES_FORMAT_NV12) + .value("YUV420P", CUFRAMES_FORMAT_YUV420P) + .value("RGB", CUFRAMES_FORMAT_RGB) + .value("BGR", CUFRAMES_FORMAT_BGR) + .value("RGBA", CUFRAMES_FORMAT_RGBA) + .value("GRAYSCALE", CUFRAMES_FORMAT_GRAYSCALE); + + py::enum_(m, "SubscriberMode") + .value("NEWEST_ONLY", CUFRAMES_MODE_NEWEST_ONLY) + .value("STRICT_ORDER", CUFRAMES_MODE_STRICT_ORDER); + + // TODO(task #198): CuframesSubscriber + CuframesFrame classes. + // TODO(task #199): DLPack export. + // TODO(task #200): Health/stats properties (потребует расширения C API). + // TODO(task #201): Per-subscriber CUDA stream + thread-safety contract. +} diff --git a/python/tests/test_smoke.py b/python/tests/test_smoke.py new file mode 100644 index 0000000..000667f --- /dev/null +++ b/python/tests/test_smoke.py @@ -0,0 +1,51 @@ +"""Smoke tests для cuframes Python bindings. + +В Phase 0 (skeleton) проверяем что: + - модуль импортируется + - версия читается + - error классы существуют и являются нормальной иерархией + +Subscriber / DLPack тесты появятся в следующих фазах +(см. issue gx/cuframes#6, tasks #198+). +""" + +import cuframes + + +def test_version_format(): + v = cuframes.version_string() + assert isinstance(v, str) + parts = v.split(".") + assert len(parts) >= 3 + assert all(p.isdigit() for p in parts[:3]) + + +def test_protocol_version_is_uint(): + pv = cuframes.protocol_version() + assert isinstance(pv, int) + assert pv >= 0 + + +def test_pixel_format_enum_members(): + assert cuframes.PixelFormat.NV12.value == 0 + assert cuframes.PixelFormat.YUV420P.value == 1 + + +def test_subscriber_mode_enum_members(): + assert cuframes.SubscriberMode.NEWEST_ONLY.value == 0 + assert cuframes.SubscriberMode.STRICT_ORDER.value == 1 + + +def test_error_hierarchy(): + """Все subtype'ы наследуются от CuframesError.""" + for sub in [ + cuframes.CuframesPublisherGone, + cuframes.CuframesFrameTimeout, + cuframes.CuframesDeviceLost, + cuframes.CuframesShmError, + cuframes.CuframesProtocolMismatch, + cuframes.CuframesInvalidArgument, + cuframes.CuframesOutOfMemory, + cuframes.CuframesInternal, + ]: + assert issubclass(sub, cuframes.CuframesError)