diff --git a/.gitignore b/.gitignore index 9294b1bb5..68de354da 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ libmessaging.* libmessaging_shared.* .sconsign.dblite .mypy_cache/ + +/msgq/ipc_pyx.pyi +/msgq/visionipc/visionipc_pyx.pyi diff --git a/MANIFEST.in b/MANIFEST.in index b3bc16ab9..5f694e2a0 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,3 @@ include README.md pyproject.toml setup.py SConstruct SConscript -recursive-include site_scons *.py -recursive-include msgq *.py *.pyx *.pxd *.h *.cc -global-exclude *.so *.o *.os *.a *.pyc +recursive-include msgq *.py py.typed *.h *.cc +global-exclude *.pyi *.so *.o *.os *.a *.pyc diff --git a/README.md b/README.md index 1609f32a8..015caf7a8 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,8 @@ msgq.MultiplePublishersError # Publisher conflict; subclass Issues and pull requests are welcome on [GitHub](https://github.com/commaai/msgq). Run `./test.sh` to build, lint, and test the package. +The Python bindings use nanobind. SCons generates type stubs from the compiled modules for type checking and includes them in the Python package. + ## License MSGQ is available under the [MIT License](https://github.com/commaai/msgq/blob/master/LICENSE). diff --git a/SConscript b/SConscript index 5a8d52041..a9e1af170 100644 --- a/SConscript +++ b/SConscript @@ -1,7 +1,11 @@ -Import('env', 'envCython', 'common') +import sys +import sysconfig + +Import('env', 'envNanobind', 'nanobind_lib', 'common') visionipc_dir = Dir('msgq/visionipc') +python_suffix = sysconfig.get_config_var('EXT_SUFFIX') # Build msgq msgq_objects = env.SharedObject([ @@ -10,7 +14,8 @@ msgq_objects = env.SharedObject([ 'msgq/msgq.cc', ]) msgq = env.Library('msgq', msgq_objects) -msgq_python = envCython.Program('msgq/ipc_pyx.so', 'msgq/ipc_pyx.pyx', LIBS=envCython["LIBS"]+[msgq]+common) +# Keep the extension module names compatible with existing Python imports. +msgq_python = envNanobind.Program(f'msgq/ipc_pyx{python_suffix}', ['msgq/ipc_py.cc', nanobind_lib], LIBS=envNanobind["LIBS"]+[msgq]+common) # Build Vision IPC vipc_files = ['visionipc.cc', 'visionipc_server.cc', 'visionipc_client.cc'] @@ -24,10 +29,16 @@ vipc_objects = env.SharedObject(vipc_sources) visionipc = env.Library('visionipc', vipc_objects) -vipc_libs = envCython["LIBS"] + [visionipc, msgq] + common -envCython.Program(f'{visionipc_dir.abspath}/visionipc_pyx.so', f'{visionipc_dir.abspath}/visionipc_pyx.pyx', +vipc_libs = envNanobind["LIBS"] + [visionipc, msgq] + common +visionipc_python = envNanobind.Program(f'{visionipc_dir.abspath}/visionipc_pyx{python_suffix}', [f'{visionipc_dir.abspath}/visionipc_py.cc', nanobind_lib], LIBS=vipc_libs) +# ASan modules require a preloaded runtime before Python can import them. +if not GetOption('asan'): + for module in ('msgq.ipc_pyx', 'msgq.visionipc.visionipc_pyx'): + env.Command(module.replace('.', '/') + '.pyi', [msgq_python, visionipc_python], + f'"{sys.executable}" -m nanobind.stubgen -i "{Dir("#").abspath}" -m {module} -o "$TARGET"') + if GetOption('extras'): env.Program('msgq/test_runner', ['msgq/msgq_tests.cc'], LIBS=[msgq]+common) diff --git a/SConstruct b/SConstruct index b7e1a4c76..d3f383401 100644 --- a/SConstruct +++ b/SConstruct @@ -1,8 +1,11 @@ import os import platform +from pathlib import Path import subprocess import sysconfig +import nanobind + arch = subprocess.check_output(["uname", "-m"], encoding='utf8').rstrip() if platform.system() == "Darwin": arch = "Darwin" @@ -62,21 +65,24 @@ env = Environment( CFLAGS="-std=gnu11", CXXFLAGS="-std=c++1z", CPPPATH=cpppath, - CYTHONCFILESUFFIX=".cpp", - tools=["default", "cython"] + tools=["default"] ) Export('env', 'arch', 'common') -envCython = env.Clone(LIBS=[]) -envCython["CCFLAGS"] += ["-Wno-#warnings", "-Wno-cpp", "-Wno-shadow", "-Wno-deprecated-declarations"] -envCython["CCFLAGS"].remove('-Werror') +envNanobind = env.Clone(LIBS=[]) +nb_dir = Path(nanobind.source_dir()).parent +envNanobind.Append(CPPPATH=[nanobind.include_dir(), str(nb_dir / "ext/robin_map/include")], + CCFLAGS=["-fvisibility=hidden", "-fno-strict-aliasing"]) if arch == "Darwin": - envCython["LINKFLAGS"] = ["-bundle", "-undefined", "dynamic_lookup"] + envNanobind["LINKFLAGS"] = ldflags + ["-bundle", "-undefined", "dynamic_lookup"] else: - envCython["LINKFLAGS"] = ["-pthread", "-shared"] - -Export('envCython') + envNanobind["LINKFLAGS"] = ldflags + ["-pthread", "-shared"] +# Third-party runtime sources do not follow our shadowing warning policy. +envNanobindRuntime = envNanobind.Clone() +envNanobindRuntime['CCFLAGS'] = [flag for flag in envNanobindRuntime['CCFLAGS'] if not flag.startswith('-Wshadow')] +nanobind_lib = envNanobindRuntime.SharedObject('#/build/nb_combined.os', str(nb_dir / "src/nb_combined.cpp")) +Export('envNanobind', 'nanobind_lib') SConscript(['SConscript']) diff --git a/lefthook.yml b/lefthook.yml index 91b18aa52..43d664a3b 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -18,12 +18,12 @@ test: ty: run: ty check . codespell: - run: codespell {files} -L ned,stdio,master --builtin clear,rare,informal,usage,code,names,en-GB_to_en-US -S uv.lock,*_pyx.cpp + run: codespell {files} -L ned,stdio,master --builtin clear,rare,informal,usage,code,names,en-GB_to_en-US -S uv.lock files: git ls-tree -r HEAD --name-only cppcheck: run: cppcheck --error-exitcode=1 --inline-suppr --language=c++ --force --quiet -j4 --check-level=exhaustive $(git ls-files '*.cc' | grep -v -E '(msgq_tests|test_runner)\.cc') cpplint: - run: cpplint --exclude=msgq/ipc_pyx.cpp --exclude=msgq/visionipc/visionipc_pyx.cpp --recursive --quiet --counting=detailed --linelength=240 --filter=-build,-legal,-readability,-runtime,-whitespace,+build/include_subdir,+build/forward_decl,+build/include_what_you_use,+build/deprecated,+whitespace/comma,+whitespace/line_length,+whitespace/empty_if_body,+whitespace/empty_loop_body,+whitespace/empty_conditional_body,+whitespace/forcolon,+whitespace/parens,+whitespace/semicolon,+whitespace/tab,+readability/braces msgq/ + run: cpplint --recursive --quiet --counting=detailed --linelength=240 --filter=-build,-legal,-readability,-runtime,-whitespace,+build/include_subdir,+build/forward_decl,+build/include_what_you_use,+build/deprecated,+whitespace/comma,+whitespace/line_length,+whitespace/empty_if_body,+whitespace/empty_loop_body,+whitespace/empty_conditional_body,+whitespace/forcolon,+whitespace/parens,+whitespace/semicolon,+whitespace/tab,+readability/braces msgq/ # *** tests *** test_runner: diff --git a/msgq/ipc.pxd b/msgq/ipc.pxd deleted file mode 100644 index 3d35af0ae..000000000 --- a/msgq/ipc.pxd +++ /dev/null @@ -1,61 +0,0 @@ -# distutils: language = c++ -#cython: language_level=3 - -from libcpp.string cimport string -from libcpp.vector cimport vector -from libcpp cimport bool - - -cdef extern from "msgq/event.h": - cdef cppclass Event: - @staticmethod - int wait_for_one(vector[Event], int) except + - - Event() - Event(int) - void set() - int clear() - void wait(int) except + - bool peek() - int fd() - - cdef cppclass SocketEventHandle: - @staticmethod - void toggle_fake_events(bool) - @staticmethod - void set_fake_prefix(string) - @staticmethod - string fake_prefix() - - SocketEventHandle(string, string, bool) - bool is_enabled() - void set_enabled(bool) - Event recv_called() - Event recv_ready() - - -cdef extern from "msgq/ipc.h": - cdef cppclass Message: - void init(size_t) - void init(char *, size_t) - void close() - size_t getSize() - char *getData() - - cdef cppclass SubSocket: - SubSocket() nogil - int connect(string, bool, size_t) nogil - Message * receive(bool) nogil - void setTimeout(int) nogil - - cdef cppclass PubSocket: - PubSocket() - int connect(string, size_t) - int sendMessage(Message *) - int send(char *, size_t) - bool all_readers_updated() - - cdef cppclass Poller: - Poller() - void registerSocket(SubSocket *) - vector[SubSocket*] poll(int) nogil diff --git a/msgq/ipc_py.cc b/msgq/ipc_py.cc new file mode 100644 index 000000000..f0b785891 --- /dev/null +++ b/msgq/ipc_py.cc @@ -0,0 +1,117 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "msgq/event.h" +#include "msgq/ipc.h" +#include "msgq/python_helpers.h" + +namespace nb = nanobind; + +namespace { + +struct IpcError : std::runtime_error { + using std::runtime_error::runtime_error; +}; + +struct MultiplePublishersError : IpcError { + using IpcError::IpcError; +}; + +void check_error(bool failed, int error, const std::string &endpoint = "") { + if (!failed) return; + std::string message = "Messaging failure " + (endpoint.empty() ? "" : "with " + endpoint) + ": " + std::strerror(error); + if (error == EADDRINUSE) throw MultiplePublishersError(message); + throw IpcError(message); +} + +} // namespace + +NB_MODULE(ipc_pyx, m) { + nb::exception ipc_error(m, "IpcError"); + nb::exception(m, "MultiplePublishersError", ipc_error); + + m.def("toggle_fake_events", &SocketEventHandle::toggle_fake_events, nb::arg("enabled")); + m.def("set_fake_prefix", [](const PythonString &prefix) { SocketEventHandle::set_fake_prefix(python_string(prefix)); }, nb::arg("prefix")); + m.def("get_fake_prefix", [] { + auto prefix = SocketEventHandle::fake_prefix(); + return nb::bytes(prefix.data(), prefix.size()); + }); + m.def("delete_fake_prefix", [] { SocketEventHandle::set_fake_prefix(""); }); + m.def("wait_for_one_event", &Event::wait_for_one, nb::arg("events"), nb::arg("timeout") = -1, nb::call_guard()); + + nb::class_(m, "Event") + .def(nb::init<>()) + .def("set", &Event::set) + .def("clear", &Event::clear) + .def("wait", &Event::wait, nb::arg("timeout") = -1, nb::call_guard()) + .def("peek", &Event::peek) + .def_prop_ro("fd", &Event::fd) + .def_prop_ro("ptr", [](Event &event) { return reinterpret_cast(&event); }); + + nb::class_(m, "SocketEventHandle") + .def("__init__", [](SocketEventHandle *self, const PythonString &endpoint, const PythonString &identifier, bool override) { + new (self) SocketEventHandle(python_string(endpoint), python_string(identifier), override); + }, nb::arg("endpoint"), nb::arg("identifier"), nb::arg("override")) + .def_prop_rw("enabled", &SocketEventHandle::is_enabled, &SocketEventHandle::set_enabled) + .def_prop_ro("recv_called_event", &SocketEventHandle::recv_called, nb::keep_alive<0, 1>()) + .def_prop_ro("recv_ready_event", &SocketEventHandle::recv_ready, nb::keep_alive<0, 1>()); + + nb::class_(m, "SubSocket") + .def(nb::init<>()) + .def("connect", [](SubSocket &socket, const PythonString &endpoint, bool conflate, size_t segment_size) { + auto name = python_string(endpoint); + nb::gil_scoped_release release; + int result = socket.connect(name, conflate, segment_size); + check_error(result != 0, errno, name); + }, nb::arg("endpoint"), nb::arg("conflate") = false, nb::arg("segment_size") = 0) + .def("setTimeout", &SubSocket::setTimeout, nb::arg("timeout"), nb::call_guard()) + .def("receive", [](SubSocket &socket, bool non_blocking) -> std::optional { + std::unique_ptr message; + { + nb::gil_scoped_release release; + message.reset(socket.receive(non_blocking)); + } + if (!message) return std::nullopt; + return nb::bytes(message->getData(), message->getSize()); + }, nb::arg("non_blocking") = false); + + nb::class_(m, "Poller") + .def(nb::init<>()) + .def("registerSocket", &Poller::registerSocket, nb::arg("socket"), nb::keep_alive<1, 2>()) + .def("poll", &Poller::poll, nb::arg("timeout"), nb::rv_policy::reference, nb::call_guard()); + + nb::class_(m, "PubSocket") + .def(nb::init<>()) + .def("connect", [](PubSocket &socket, const PythonString &endpoint, size_t segment_size) { + auto name = python_string(endpoint); + int result = socket.connect(name, segment_size); + check_error(result != 0, errno, name); + }, nb::arg("endpoint"), nb::arg("segment_size") = 0) + .def("send", [](PubSocket &socket, nb::bytes data) { + int result = socket.send(const_cast(data.c_str()), data.size()); + check_error(result != static_cast(data.size()), errno); + }, nb::arg("data")) + .def("all_readers_updated", &PubSocket::all_readers_updated) + .def("wait_for_readers", [](PubSocket &socket, double timeout, double interval) { + // Python's sleep releases the GIL and handles signals while waiting. + nb::object sleep = nb::module_::import_("time").attr("sleep"); + auto deadline = std::chrono::steady_clock::now() + std::chrono::duration(timeout); + while (std::chrono::steady_clock::now() < deadline) { + if (socket.all_readers_updated()) return; + sleep(interval); + } + PyErr_SetString(PyExc_TimeoutError, "subscriber did not connect"); + throw nb::python_error(); + }, nb::arg("timeout") = 1.0, nb::arg("interval") = 0.001); +} diff --git a/msgq/ipc_pyx.pyx b/msgq/ipc_pyx.pyx deleted file mode 100644 index 446f303f6..000000000 --- a/msgq/ipc_pyx.pyx +++ /dev/null @@ -1,234 +0,0 @@ -# distutils: language = c++ -# cython: c_string_encoding=ascii, language_level=3 - -import time -from libcpp.string cimport string -from libcpp.vector cimport vector -from libcpp cimport bool -from libc cimport errno -from libc.string cimport strerror -from cython.operator import dereference - - -from .ipc cimport SubSocket as cppSubSocket -from .ipc cimport PubSocket as cppPubSocket -from .ipc cimport Poller as cppPoller -from .ipc cimport Message as cppMessage -from .ipc cimport Event as cppEvent, SocketEventHandle as cppSocketEventHandle - - -class IpcError(Exception): - def __init__(self, endpoint=None): - suffix = f"with {endpoint.decode('utf-8')}" if endpoint else "" - message = f"Messaging failure {suffix}: {strerror(errno.errno).decode('utf-8')}" - super().__init__(message) - - -class MultiplePublishersError(IpcError): - pass - - -def toggle_fake_events(bool enabled): - cppSocketEventHandle.toggle_fake_events(enabled) - - -def set_fake_prefix(string prefix): - cppSocketEventHandle.set_fake_prefix(prefix) - - -def get_fake_prefix(): - return cppSocketEventHandle.fake_prefix() - - -def delete_fake_prefix(): - cppSocketEventHandle.set_fake_prefix(b"") - - -def wait_for_one_event(list events, int timeout=-1): - cdef vector[cppEvent] items - for event in events: - items.push_back(dereference(event.ptr)) - return cppEvent.wait_for_one(items, timeout) - - -cdef class Event: - cdef cppEvent event; - - cdef setEvent(self, cppEvent event): - self.event = event - - def set(self): - self.event.set() - - def clear(self): - return self.event.clear() - - def wait(self, int timeout=-1): - self.event.wait(timeout) - - def peek(self): - return self.event.peek() - - @property - def fd(self): - return self.event.fd() - - @property - def ptr(self): - return &self.event - - -cdef class SocketEventHandle: - cdef cppSocketEventHandle * handle; - - def __cinit__(self, string endpoint, string identifier, bool override): - self.handle = new cppSocketEventHandle(endpoint, identifier, override) - - def __dealloc__(self): - del self.handle - - @property - def enabled(self): - return self.handle.is_enabled() - - @enabled.setter - def enabled(self, bool value): - self.handle.set_enabled(value) - - @property - def recv_called_event(self): - e = Event() - e.setEvent(self.handle.recv_called()) - - return e - - @property - def recv_ready_event(self): - e = Event() - e.setEvent(self.handle.recv_ready()) - - return e - - -cdef class Poller: - cdef cppPoller * poller - cdef list sub_sockets - - def __cinit__(self): - self.sub_sockets = [] - self.poller = new cppPoller() - - def __dealloc__(self): - del self.poller - - def registerSocket(self, SubSocket socket): - self.sub_sockets.append(socket) - self.poller.registerSocket(socket.socket) - - def poll(self, timeout): - sockets = [] - cdef int t = timeout - - with nogil: - result = self.poller.poll(t) - - for s in result: - socket = SubSocket() - socket.setPtr(s) - sockets.append(socket) - - return sockets - - -cdef class SubSocket: - cdef cppSubSocket * socket - cdef bool is_owner - - def __cinit__(self): - with nogil: - self.socket = new cppSubSocket() - - self.is_owner = True - - def __dealloc__(self): - if self.is_owner: - with nogil: - del self.socket - - cdef setPtr(self, cppSubSocket * ptr): - if self.is_owner: - with nogil: - del self.socket - - self.is_owner = False - self.socket = ptr - - def connect(self, string endpoint, bool conflate=False, size_t segment_size=0): - cdef int r - with nogil: - r = self.socket.connect(endpoint, conflate, segment_size) - - if r != 0: - if errno.errno == errno.EADDRINUSE: - raise MultiplePublishersError(endpoint) - else: - raise IpcError(endpoint) - - def setTimeout(self, int timeout): - with nogil: - self.socket.setTimeout(timeout) - - def receive(self, bool non_blocking=False): - cdef cppMessage *msg - with nogil: - msg = self.socket.receive(non_blocking) - - if msg == NULL: - return None - else: - sz = msg.getSize() - m = msg.getData()[:sz] - with nogil: - del msg - - return m - - -cdef class PubSocket: - cdef cppPubSocket * socket - - def __cinit__(self): - self.socket = new cppPubSocket() - - def __dealloc__(self): - del self.socket - - def connect(self, string endpoint, size_t segment_size=0): - r = self.socket.connect(endpoint, segment_size) - - if r != 0: - if errno.errno == errno.EADDRINUSE: - raise MultiplePublishersError(endpoint) - else: - raise IpcError(endpoint) - - def send(self, bytes data): - length = len(data) - r = self.socket.send(data, length) - - if r != length: - if errno.errno == errno.EADDRINUSE: - raise MultiplePublishersError - else: - raise IpcError - - def all_readers_updated(self): - return self.socket.all_readers_updated() - - def wait_for_readers(self, double timeout=1.0, double interval=0.001): - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if self.all_readers_updated(): - return - time.sleep(interval) - raise TimeoutError("subscriber did not connect") diff --git a/msgq/py.typed b/msgq/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/msgq/python_helpers.h b/msgq/python_helpers.h new file mode 100644 index 000000000..291230111 --- /dev/null +++ b/msgq/python_helpers.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +#include +#include +#include + +// Cython accepted both text and bytes for C++ strings. Keep that API while +// exposing the full input type to nanobind's stub generator. +using PythonString = std::variant; + +inline std::string python_string(const PythonString &value) { + if (const auto *text = std::get_if(&value)) return *text; + const auto &bytes = std::get(value); + return std::string(bytes.c_str(), bytes.size()); +} diff --git a/msgq/visionipc/tests/test_visionipc.py b/msgq/visionipc/tests/test_visionipc.py index 4b1ad7cce..1a78b1cf9 100644 --- a/msgq/visionipc/tests/test_visionipc.py +++ b/msgq/visionipc/tests/test_visionipc.py @@ -93,7 +93,6 @@ def test_conflate(self): recv_buf = client.recv(timeout_ms=5) assert recv_buf is None - @unittest.expectedFailure # Cython copies strided input as contiguous bytes. def test_send_buffer_types(self): server, client, buffer_len = self.setup_vipc("camerad", 0) data = bytes(i % 256 for i in range(buffer_len)) @@ -115,7 +114,6 @@ def test_send_wrong_size(self): with self.assertRaises(AssertionError): server.send(0, b"short") - @unittest.skip("Cython buffers do not retain their client; accessing freed buffers segfaults.") def test_buffer_keeps_client_alive(self): server, client, buffer_len = self.setup_vipc("camerad", 0) data = b"a" * buffer_len diff --git a/msgq/visionipc/visionipc.pxd b/msgq/visionipc/visionipc.pxd deleted file mode 100644 index 2552fe0e4..000000000 --- a/msgq/visionipc/visionipc.pxd +++ /dev/null @@ -1,52 +0,0 @@ -# distutils: language = c++ -#cython: language_level=3 - -from libcpp.string cimport string -from libcpp.vector cimport vector -from libcpp.set cimport set -from libc.stdint cimport uint32_t, uint64_t -from libcpp cimport bool, int - -cdef extern from "msgq/visionipc/visionbuf.h": - ctypedef uint32_t VisionStreamType "VisionStreamType" - - cdef cppclass VisionBuf: - void * addr - int fd - size_t len - size_t width - size_t height - size_t stride - size_t uv_offset - size_t idx - void set_frame_id(uint64_t id) - uint64_t get_frame_id() - -cdef extern from "msgq/visionipc/visionipc.h": - struct VisionIpcBufExtra: - uint32_t frame_id - uint64_t timestamp_sof - uint64_t timestamp_eof - bool valid - -cdef extern from "msgq/visionipc/visionipc_server.h": - string get_endpoint_name(string, VisionStreamType) - - cdef cppclass VisionIpcServer: - VisionIpcServer(string) - void create_buffers(VisionStreamType, size_t, size_t, size_t) - void create_buffers_with_sizes(VisionStreamType, size_t, size_t, size_t, size_t, size_t, size_t) - VisionBuf * get_buffer(VisionStreamType) - void send(VisionBuf *, VisionIpcBufExtra *, bool) - void start_listener() - -cdef extern from "msgq/visionipc/visionipc_client.h": - cdef cppclass VisionIpcClient: - int num_buffers - VisionBuf buffers[1] - VisionIpcClient(string, VisionStreamType, bool) - VisionBuf * recv(VisionIpcBufExtra *, int) nogil - bool connect(bool) - bool is_connected() - @staticmethod - set[VisionStreamType] getAvailableStreams(string, bool) diff --git a/msgq/visionipc/visionipc_py.cc b/msgq/visionipc/visionipc_py.cc new file mode 100644 index 000000000..c6b7a6c65 --- /dev/null +++ b/msgq/visionipc/visionipc_py.cc @@ -0,0 +1,114 @@ +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "msgq/python_helpers.h" +#include "msgq/visionipc/visionbuf.h" +#include "msgq/visionipc/visionipc.h" +#include "msgq/visionipc/visionipc_client.h" +#include "msgq/visionipc/visionipc_server.h" + +namespace nb = nanobind; + +namespace { + +struct PythonVisionIpcClient : VisionIpcClient { + using VisionIpcClient::VisionIpcClient; + VisionIpcBufExtra extra = {}; +}; + +using Buffer = nb::ndarray, nb::device::cpu>; +using InputBuffer = nb::ndarray, nb::device::cpu>; + +} // namespace + +NB_MODULE(visionipc_pyx, m) { + m.def("get_endpoint_name", [](const PythonString &name, VisionStreamType stream) { + return get_endpoint_name(python_string(name), stream); + }, nb::arg("name"), nb::arg("stream")); + + nb::class_(m, "VisionBuf") + .def_prop_ro("data", [](VisionBuf &buf) { return Buffer(buf.addr, {buf.len}); }, nb::rv_policy::reference_internal, + nb::sig("def data(self) -> memoryview")) + .def_ro("width", &VisionBuf::width) + .def_ro("height", &VisionBuf::height) + .def_ro("stride", &VisionBuf::stride) + .def_ro("uv_offset", &VisionBuf::uv_offset) + .def_ro("idx", &VisionBuf::idx) + .def_ro("fd", &VisionBuf::fd) + .def_prop_ro("frame_id", &VisionBuf::get_frame_id); + + nb::class_(m, "VisionIpcServer") + .def("__init__", [](VisionIpcServer *self, const PythonString &name) { + new (self) VisionIpcServer(python_string(name)); + }, nb::arg("name")) + .def("create_buffers", &VisionIpcServer::create_buffers, nb::arg("tp"), nb::arg("num_buffers"), nb::arg("width"), nb::arg("height")) + .def("create_buffers_with_sizes", &VisionIpcServer::create_buffers_with_sizes, + nb::arg("tp"), nb::arg("num_buffers"), nb::arg("width"), nb::arg("height"), nb::arg("size"), nb::arg("stride"), nb::arg("uv_offset")) + .def("send", [](VisionIpcServer &server, VisionStreamType tp, InputBuffer data, + uint32_t frame_id, uint64_t timestamp_sof, uint64_t timestamp_eof) { + VisionBuf *buf = server.get_buffer(tp); + if (buf->len != data.size()) { + PyErr_SetString(PyExc_AssertionError, "buffer size does not match frame size"); + throw nb::python_error(); + } + // The old binding accepted strided byte views as well as contiguous data. + auto *source = data.data(); + if (data.stride(0) == 1) { + std::memcpy(buf->addr, source, data.size()); + } else { + auto *target = static_cast(buf->addr); + for (size_t i = 0; i < data.size(); ++i) target[i] = source[static_cast(i) * data.stride(0)]; + } + buf->set_frame_id(frame_id); + VisionIpcBufExtra extra = {frame_id, timestamp_sof, timestamp_eof, false}; + server.send(buf, &extra, false); + }, nb::arg("tp"), nb::arg("data").noconvert(), nb::arg("frame_id") = 0, nb::arg("timestamp_sof") = 0, nb::arg("timestamp_eof") = 0, + nb::sig("def send(self, tp: int, data: bytes | bytearray | memoryview, frame_id: int = 0, timestamp_sof: int = 0, timestamp_eof: int = 0) -> None")) + .def("start_listener", &VisionIpcServer::start_listener); + + nb::class_(m, "VisionIpcClient") + .def("__init__", [](PythonVisionIpcClient *self, const PythonString &name, VisionStreamType stream, bool conflate) { + new (self) PythonVisionIpcClient(python_string(name), stream, conflate); + }, nb::arg("name"), nb::arg("stream"), nb::arg("conflate")) + .def_prop_ro("width", [](PythonVisionIpcClient &client) -> std::optional { + return client.num_buffers ? std::make_optional(client.buffers[0].width) : std::nullopt; + }) + .def_prop_ro("height", [](PythonVisionIpcClient &client) -> std::optional { + return client.num_buffers ? std::make_optional(client.buffers[0].height) : std::nullopt; + }) + .def_prop_ro("stride", [](PythonVisionIpcClient &client) -> std::optional { + return client.num_buffers ? std::make_optional(client.buffers[0].stride) : std::nullopt; + }) + .def_prop_ro("uv_offset", [](PythonVisionIpcClient &client) -> std::optional { + return client.num_buffers ? std::make_optional(client.buffers[0].uv_offset) : std::nullopt; + }) + .def_prop_ro("buffer_len", [](PythonVisionIpcClient &client) -> std::optional { + return client.num_buffers ? std::make_optional(client.buffers[0].len) : std::nullopt; + }) + .def_prop_ro("num_buffers", [](PythonVisionIpcClient &client) { return client.num_buffers; }) + .def_prop_ro("frame_id", [](PythonVisionIpcClient &client) { return client.extra.frame_id; }) + .def_prop_ro("timestamp_sof", [](PythonVisionIpcClient &client) { return client.extra.timestamp_sof; }) + .def_prop_ro("timestamp_eof", [](PythonVisionIpcClient &client) { return client.extra.timestamp_eof; }) + .def_prop_ro("valid", [](PythonVisionIpcClient &client) { return client.extra.valid; }) + .def("recv", [](PythonVisionIpcClient &client, int timeout_ms) { + nb::gil_scoped_release release; + return client.recv(&client.extra, timeout_ms); + }, nb::arg("timeout_ms") = 100, nb::rv_policy::reference_internal, + nb::sig("def recv(self, timeout_ms: int = 100) -> VisionBuf | None")) + .def("connect", [](PythonVisionIpcClient &client, bool blocking) { + return client.connect(blocking); + }, nb::arg("blocking"), nb::call_guard()) + .def("is_connected", [](PythonVisionIpcClient &client) { return client.is_connected(); }) + .def_static("available_streams", [](const PythonString &name, bool block) { + auto text = python_string(name); + nb::gil_scoped_release release; + return VisionIpcClient::getAvailableStreams(text, block); + }, nb::arg("name"), nb::arg("block")); +} diff --git a/msgq/visionipc/visionipc_pyx.pxd b/msgq/visionipc/visionipc_pyx.pxd deleted file mode 100644 index 6e2d5ed05..000000000 --- a/msgq/visionipc/visionipc_pyx.pxd +++ /dev/null @@ -1,10 +0,0 @@ -# distutils: language = c++ -#cython: language_level=3 - -from .visionipc cimport VisionBuf as cppVisionBuf - -cdef class VisionBuf: - cdef cppVisionBuf * buf - - @staticmethod - cdef create(cppVisionBuf*) diff --git a/msgq/visionipc/visionipc_pyx.pyx b/msgq/visionipc/visionipc_pyx.pyx deleted file mode 100644 index a274d4f7a..000000000 --- a/msgq/visionipc/visionipc_pyx.pyx +++ /dev/null @@ -1,163 +0,0 @@ -# distutils: language = c++ -# cython: c_string_encoding=ascii, language_level=3 - -from libc.string cimport memcpy -from libc.stdint cimport uint32_t, uint64_t -from libcpp cimport bool -from libcpp.string cimport string - -from .visionipc cimport VisionIpcServer as cppVisionIpcServer -from .visionipc cimport VisionIpcClient as cppVisionIpcClient -from .visionipc cimport VisionBuf as cppVisionBuf -from .visionipc cimport VisionIpcBufExtra, VisionStreamType -from .visionipc cimport get_endpoint_name as cpp_get_endpoint_name - - -def get_endpoint_name(string name, VisionStreamType stream): - return cpp_get_endpoint_name(name, stream).decode('utf-8') - - -cdef class VisionBuf: - @staticmethod - cdef create(cppVisionBuf * cbuf): - buf = VisionBuf() - buf.buf = cbuf - return buf - - @property - def data(self): - cdef unsigned char[:] data = self.buf.addr - return memoryview(data) - - @property - def width(self): - return self.buf.width - - @property - def height(self): - return self.buf.height - - @property - def stride(self): - return self.buf.stride - - @property - def uv_offset(self): - return self.buf.uv_offset - - @property - def idx(self): - return self.buf.idx - - @property - def fd(self): - return self.buf.fd - - @property - def frame_id(self): - return self.buf.get_frame_id() - - -cdef class VisionIpcServer: - cdef cppVisionIpcServer * server - - def __init__(self, string name): - self.server = new cppVisionIpcServer(name) - - def create_buffers(self, VisionStreamType tp, size_t num_buffers, size_t width, size_t height): - self.server.create_buffers(tp, num_buffers, width, height) - - def create_buffers_with_sizes(self, VisionStreamType tp, size_t num_buffers, size_t width, size_t height, size_t size, size_t stride, size_t uv_offset): - self.server.create_buffers_with_sizes(tp, num_buffers, width, height, size, stride, uv_offset) - - def send(self, VisionStreamType tp, const unsigned char[:] data, uint32_t frame_id=0, uint64_t timestamp_sof=0, uint64_t timestamp_eof=0): - cdef cppVisionBuf * buf = self.server.get_buffer(tp) - - # Populate buffer - assert buf.len == len(data) - memcpy(buf.addr, &data[0], len(data)) - buf.set_frame_id(frame_id) - - cdef VisionIpcBufExtra extra - extra.frame_id = frame_id - extra.timestamp_sof = timestamp_sof - extra.timestamp_eof = timestamp_eof - extra.valid = False - - self.server.send(buf, &extra, False) - - def start_listener(self): - self.server.start_listener() - - def __dealloc__(self): - del self.server - - -cdef class VisionIpcClient: - cdef cppVisionIpcClient * client - cdef VisionIpcBufExtra extra - - def __cinit__(self, string name, VisionStreamType stream, bool conflate): - self.client = new cppVisionIpcClient(name, stream, conflate) - - def __dealloc__(self): - del self.client - - @property - def width(self): - return self.client.buffers[0].width if self.client.num_buffers else None - - @property - def height(self): - return self.client.buffers[0].height if self.client.num_buffers else None - - @property - def stride(self): - return self.client.buffers[0].stride if self.client.num_buffers else None - - @property - def uv_offset(self): - return self.client.buffers[0].uv_offset if self.client.num_buffers else None - - @property - def buffer_len(self): - return self.client.buffers[0].len if self.client.num_buffers else None - - @property - def num_buffers(self): - return self.client.num_buffers - - @property - def frame_id(self): - return self.extra.frame_id - - @property - def timestamp_sof(self): - return self.extra.timestamp_sof - - @property - def timestamp_eof(self): - return self.extra.timestamp_eof - - @property - def valid(self): - return self.extra.valid - - def recv(self, int timeout_ms=100): - cdef cppVisionBuf * buf - # release the GIL, this can block for timeout_ms - with nogil: - buf = self.client.recv(&self.extra, timeout_ms) - if not buf: - return None - return VisionBuf.create(buf) - - def connect(self, bool blocking): - return self.client.connect(blocking) - - def is_connected(self): - return self.client.is_connected() - - @staticmethod - def available_streams(string name, bool block): - return cppVisionIpcClient.getAvailableStreams(name, block) diff --git a/pyproject.toml b/pyproject.toml index e2f4d16ce..115237922 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=77", "Cython>=3.1", "scons"] +requires = ["setuptools>=77", "nanobind>=2.12", "scons"] build-backend = "setuptools.build_meta" [project] @@ -23,7 +23,7 @@ Repository = "https://github.com/commaai/msgq" [project.optional-dependencies] dev = [ "setuptools", # for distutils - "Cython", + "nanobind>=2.12", "scons", "ruff", "cppcheck", @@ -41,13 +41,16 @@ include = ["msgq", "msgq.*"] namespaces = false [tool.setuptools.package-data] -"msgq" = ["*.pxd", "*.h"] -"msgq.visionipc" = ["*.pxd", "*.h"] +"msgq" = ["*.pyi", "py.typed", "*.h"] +"msgq.visionipc" = ["*.pyi", "*.h"] [tool.cibuildwheel] build = "cp311-* cp312-* cp313-* cp314-*" test-command = "python -m unittest discover -s msgq" +[tool.cibuildwheel.macos.environment] +MACOSX_DEPLOYMENT_TARGET = "10.13" + # https://beta.ruff.rs/docs/configuration/#using-pyprojecttoml [tool.ruff] lint.select = ["E", "F", "W", "PIE", "C4", "ISC", "RUF100", "A"] @@ -56,10 +59,3 @@ lint.flake8-implicit-str-concat.allow-multiline=false line-length = 160 target-version="py311" - -[tool.ty.src] -exclude = ["site_scons/"] - -[tool.ty.rules] -# Cython modules are compiled at build time, not available for static analysis -unresolved-import = "ignore" diff --git a/setup.py b/setup.py index 45e2c172d..4fa17fc0e 100644 --- a/setup.py +++ b/setup.py @@ -2,6 +2,7 @@ from pathlib import Path import subprocess import sys +import sysconfig from setuptools import Extension, setup from setuptools.command.build_ext import build_ext @@ -11,10 +12,13 @@ class SConsBuildExt(build_ext): def build_extensions(self): subprocess.check_call([sys.executable, "-m", "SCons", "--minimal", "-j", str(self.parallel or os.cpu_count() or 1)]) for ext in self.extensions: - source = Path(*ext.name.split(".")).with_suffix(".so") + module = Path(*ext.name.split(".")) + source = module.with_suffix(sysconfig.get_config_var("EXT_SUFFIX")) target = Path(self.get_ext_fullpath(ext.name)) target.parent.mkdir(parents=True, exist_ok=True) self.copy_file(str(source), str(target)) + stub = module.with_suffix(".pyi") + self.copy_file(str(stub), str(target.parent / stub.name)) setup( diff --git a/site_scons/site_tools/cython.py b/site_scons/site_tools/cython.py deleted file mode 100644 index c29147553..000000000 --- a/site_scons/site_tools/cython.py +++ /dev/null @@ -1,72 +0,0 @@ -import re -import SCons -from SCons.Action import Action -from SCons.Scanner import Scanner - -pyx_from_import_re = re.compile(r'^from\s+(\S+)\s+cimport', re.M) -pyx_import_re = re.compile(r'^cimport\s+(\S+)', re.M) -cdef_import_re = re.compile(r'^cdef extern from\s+.(\S+).:', re.M) - - -def pyx_scan(node, env, path, arg=None): - contents = node.get_text_contents() - - # from cimport ... - matches = pyx_from_import_re.findall(contents) - # cimport - matches += pyx_import_re.findall(contents) - - # Modules can be either .pxd or .pyx files - files = [m.replace('.', '/') + '.pxd' for m in matches] - files += [m.replace('.', '/') + '.pyx' for m in matches] - - # cdef extern from - files += cdef_import_re.findall(contents) - - # Handle relative imports - cur_dir = str(node.get_dir()) - files = [cur_dir + f if f.startswith('/') else f for f in files] - - # Filter out non-existing files (probably system imports) - files = [f for f in files if env.File(f).exists()] - return env.File(files) - - -pyxscanner = Scanner(function=pyx_scan, skeys=['.pyx', '.pxd'], recursive=True) -cythonAction = Action("$CYTHONCOM") - - -def create_builder(env): - try: - cython = env['BUILDERS']['Cython'] - except KeyError: - cython = SCons.Builder.Builder( - action=cythonAction, - emitter={}, - suffix=cython_suffix_emitter, - single_source=1 - ) - env.Append(SCANNERS=pyxscanner) - env['BUILDERS']['Cython'] = cython - return cython - -def cython_suffix_emitter(env, source): - return "$CYTHONCFILESUFFIX" - -def generate(env): - env["CYTHON"] = "cythonize" - env["CYTHONCOM"] = "$CYTHON $CYTHONFLAGS $SOURCE" - env["CYTHONCFILESUFFIX"] = ".cpp" - - c_file, _ = SCons.Tool.createCFileBuilders(env) - - c_file.suffix['.pyx'] = cython_suffix_emitter - c_file.add_action('.pyx', cythonAction) - - c_file.suffix['.py'] = cython_suffix_emitter - c_file.add_action('.py', cythonAction) - - create_builder(env) - -def exists(env): - return True diff --git a/test.sh b/test.sh index bf3a0e0ef..dfe98c1ff 100755 --- a/test.sh +++ b/test.sh @@ -19,7 +19,7 @@ lefthook run test uv venv --python "$DIR/.venv/bin/python" "$TEST_DIR/.venv" uv pip install --python "$TEST_DIR/.venv/bin/python" "$DIR" cd "$TEST_DIR" - "$TEST_DIR/.venv/bin/python" -m unittest msgq.tests.test_messaging + "$TEST_DIR/.venv/bin/python" -m unittest msgq.tests.test_messaging msgq.visionipc.tests.test_visionipc ) # *** all done ***