python: skeleton pybind11 bindings (issue #6 task #197)

Каркас 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 12:59:04 +01:00
parent 655649f4d8
commit a7da4ea728
8 changed files with 406 additions and 0 deletions
+4
View File
@@ -39,3 +39,7 @@ endif()
if(BUILD_TOOLS)
add_subdirectory(tools/cuframes-rtsp-source)
endif()
if(BUILD_PYTHON_BINDINGS)
add_subdirectory(python)
endif()
+7
View File
@@ -0,0 +1,7 @@
build/
dist/
*.egg-info/
__pycache__/
*.pyc
*.so
.pytest_cache/
+52
View File
@@ -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()
+53
View File
@@ -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).
+56
View File
@@ -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",
]
+47
View File
@@ -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"]
+136
View File
@@ -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 <pybind11/pybind11.h>
#include <stdexcept>
#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<uint32_t>(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<std::runtime_error>(m, "CuframesError").attr("__class__");
auto make_subexc = [&m](const char* name) {
return py::exception<std::runtime_error>(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_<cuframes_format_t>(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_<cuframes_subscriber_mode_t>(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.
}
+51
View File
@@ -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)