From d32a7a2d48ac323d5503b6b87ba6b064614a0904 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 11 Jul 2026 23:17:11 -0500 Subject: [PATCH 1/3] feat(python): Update x-plat lib so that python bindings can be published to pypi and more easily built/installed locally --- .github/workflows/build_wheels.yml | 93 ++++++++++++++++++ README.md | 14 +++ lib/CMakeLists.txt | 72 ++++++++------ lib/README.md | 52 +++++++++- lib/README_PYPI.md | 65 +++++++++++++ lib/autogenerate_bindings.py | 92 +++++++++++++++++- lib/bind.cpp | 138 --------------------------- lib/espp.cmake | 48 +++++++--- lib/pyproject.toml | 7 -- lib/python_bindings/espp/__init__.py | 11 ++- lib/python_bindings/module.cpp | 6 +- lib/python_bindings/pybind_espp.cpp | 126 ++++++++++++++++-------- pyproject.toml | 87 +++++++++++++++++ python/README.md | 25 +++-- python/cobs_demo.py | 2 +- python/rtps_publisher.py | 2 +- python/rtps_pubsub.py | 2 +- python/rtps_subscriber.py | 2 +- python/rtsp_api_test.py | 2 +- python/rtsp_client_multitrack.py | 2 +- python/rtsp_server_multitrack.py | 2 +- python/support_loader.py | 12 --- python/task.py | 2 +- python/timer.py | 2 +- python/udp_client.py | 2 +- python/udp_server.py | 2 +- 26 files changed, 600 insertions(+), 270 deletions(-) create mode 100644 .github/workflows/build_wheels.yml create mode 100644 lib/README_PYPI.md delete mode 100644 lib/bind.cpp delete mode 100644 lib/pyproject.toml create mode 100644 pyproject.toml delete mode 100644 python/support_loader.py diff --git a/.github/workflows/build_wheels.yml b/.github/workflows/build_wheels.yml new file mode 100644 index 000000000..f2ae496d7 --- /dev/null +++ b/.github/workflows/build_wheels.yml @@ -0,0 +1,93 @@ +name: Build Python Wheels + +on: + pull_request: + branches: [main] + paths: + - 'lib/**' + - 'components/**' + - 'pyproject.toml' + - '.github/workflows/build_wheels.yml' + push: + branches: [main] + release: + types: [published] + workflow_dispatch: + +jobs: + build_wheels: + name: Build wheels on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest # linux x86_64 + - os: ubuntu-24.04-arm # linux aarch64 + - os: macos-13 # macos x86_64 + - os: macos-latest # macos arm64 + - os: windows-latest # windows amd64 + + steps: + - name: Checkout repo + uses: actions/checkout@v7 + with: + submodules: 'recursive' + # full history + tags so setuptools-scm can compute the version + fetch-depth: 0 + + - name: Build wheels + run: pipx run cibuildwheel + # configuration (python versions, test command, etc.) lives in + # [tool.cibuildwheel] in pyproject.toml + + - name: Upload wheels + uses: actions/upload-artifact@v7 + with: + name: wheels-${{ matrix.os }} + path: wheelhouse/*.whl + + build_sdist: + name: Build source distribution + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v7 + with: + submodules: 'recursive' + fetch-depth: 0 + + - name: Build sdist + run: pipx run build --sdist + + - name: Upload sdist + uses: actions/upload-artifact@v7 + with: + name: sdist + path: dist/*.tar.gz + + publish_pypi: + name: Publish to PyPI + needs: [build_wheels, build_sdist] + runs-on: ubuntu-latest + # only publish on published releases; PR / push builds just verify the + # wheels build and import correctly + if: github.event_name == 'release' && github.event.action == 'published' + environment: + name: pypi + url: https://pypi.org/p/espp + permissions: + # required for PyPI trusted publishing (OIDC) + id-token: write + + steps: + - name: Download all distributions + uses: actions/download-artifact@v5 + with: + pattern: '*' + merge-multiple: true + path: dist + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/README.md b/README.md index 1c00016af..4316ef9b0 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,20 @@ only need to do one of them; they are not steps to be run one after another.** update --init --recursive` to ensure that you have the latest versions of all the submodules which are required to build the components in this repository. +- **Use the cross-platform components from Python or desktop C++ (no ESP + hardware needed).** The portable subset of espp (tasks, timers, sockets, + RTSP, RTPS, CDR, COBS, serialization, math, and more) is also packaged as the + [`espp` python package](https://pypi.org/project/espp/): + + ```console + pip install espp + # or, straight from the repository: + pip install git+https://github.com/esp-cpp/espp.git + ``` + + See [lib/](./lib) for details, including how to build the same subset as a + C++ static library for PC, and [python/](./python) for example scripts. + ## Additional Information and Links * [Documentation](https://esp-cpp.github.io/espp/) - github hosted version of diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt index 36f269c42..cca77cf75 100644 --- a/lib/CMakeLists.txt +++ b/lib/CMakeLists.txt @@ -5,17 +5,20 @@ message(STATUS "Building for PC: C++ & Python") project(espp) -include(cmake/CPM.cmake) -CPMAddPackage( - NAME pybind11 - GIT_REPOSITORY https://github.com/pybind/pybind11.git - VERSION 3.0.4 - DOWNLOAD_ONLY YES -) -if(pybind11_ADDED) - add_subdirectory(${pybind11_SOURCE_DIR}) -else() - message(FATAL_ERROR "pybind11 not found. Please ensure it is available in the specified version.") +# Prefer an installed pybind11 (provided as a build requirement when building +# python wheels via pip / scikit-build-core); fall back to fetching it with CPM +# for standalone CMake builds (e.g. ./build.sh). +find_package(pybind11 CONFIG QUIET) +if(NOT pybind11_FOUND) + include(cmake/CPM.cmake) + CPMAddPackage( + NAME pybind11 + GIT_REPOSITORY https://github.com/pybind/pybind11.git + VERSION 3.0.4 + ) + if(NOT pybind11_ADDED) + message(FATAL_ERROR "pybind11 not found. Please ensure it is available in the specified version.") + endif() endif() set(CMAKE_COLOR_MAKEFILE ON) @@ -37,25 +40,36 @@ if(APPLE) set(LINK_ARG "-all_load") endif() -set(TARGET_NAME "espp_pc") +if(SKBUILD) + # Building a python wheel via scikit-build-core (pip install). Only build the + # espp._espp extension module and install it into the `espp` package; the + # pure-python package files come from lib/python_bindings/espp via the + # `wheel.packages` setting in pyproject.toml. + espp_add_python_module() + install(TARGETS _espp LIBRARY DESTINATION espp) +else() + # Standalone build (./build.sh / ./build.ps1): build the C++ static library + # and the python package, and install both into ./pc for local use. + set(TARGET_NAME "espp_pc") -# main library (which can be built for pc, android, and iOS) -add_library( # Specifies the name of the library. - ${TARGET_NAME} - # Sets the library as a static (.a) library. - STATIC - # Provides a relative path to your source file(s). - ${ESPP_SOURCES} ) -set_property(TARGET ${TARGET_NAME} PROPERTY POSITION_INDEPENDENT_CODE ON) -target_link_options(${TARGET_NAME} PRIVATE "${LINK_ARG}") -target_link_libraries(${TARGET_NAME} ${ESPP_EXTERNAL_LIBS}) -target_compile_features(${TARGET_NAME} PRIVATE cxx_std_20) + # main library (which can be built for pc, android, and iOS) + add_library( # Specifies the name of the library. + ${TARGET_NAME} + # Sets the library as a static (.a) library. + STATIC + # Provides a relative path to your source file(s). + ${ESPP_SOURCES} ) + set_property(TARGET ${TARGET_NAME} PROPERTY POSITION_INDEPENDENT_CODE ON) + target_link_options(${TARGET_NAME} PRIVATE "${LINK_ARG}") + target_link_libraries(${TARGET_NAME} ${ESPP_EXTERNAL_LIBS}) + target_compile_features(${TARGET_NAME} PRIVATE cxx_std_20) -# install build output and headers -install(TARGETS ${TARGET_NAME} - ARCHIVE DESTINATION ${PROJECT_SOURCE_DIR}/pc) + # install build output and headers + install(TARGETS ${TARGET_NAME} + ARCHIVE DESTINATION ${PROJECT_SOURCE_DIR}/pc) -espp_install_includes(${PROJECT_SOURCE_DIR}/pc) + espp_install_includes(${PROJECT_SOURCE_DIR}/pc) -# Build and install the python binding -espp_install_python_module(${PROJECT_SOURCE_DIR}/pc) + # Build and install the python package (espp/ with the _espp extension inside) + espp_install_python_module(${PROJECT_SOURCE_DIR}/pc) +endif() diff --git a/lib/README.md b/lib/README.md index 10983c918..4be1165b6 100644 --- a/lib/README.md +++ b/lib/README.md @@ -5,6 +5,7 @@ - [ESPP Library](#espp-library) - [Description](#description) + - [Installing the python package with pip](#installing-the-python-package-with-pip) - [Building for PC (C++ & Python)](#building-for-pc-c--python) - [Updating the python bindings](#updating-the-python-bindings) - [Setup](#setup) @@ -34,8 +35,43 @@ Some examples can be found in these folders: - [../python](../python): This folder contains python code which uses the espp library. -All the examples in these folders require that the espp library is built first, -as described below. +All the examples in these folders require that the espp library is either +installed via pip or built first, as described below. + +## Installing the python package with pip + +The python bindings are packaged as the [`espp` package on +PyPI](https://pypi.org/project/espp/) (see +[README_PYPI.md](./README_PYPI.md)), built via scikit-build-core from the +[pyproject.toml](../pyproject.toml) at the repository root: + +``` console +pip install espp +``` + +You can also install straight from git (requires CMake >= 3.20 and a C++20 +compiler; pip clones the needed submodules automatically): + +``` console +pip install git+https://github.com/esp-cpp/espp.git +``` + +When developing the bindings (or running the [../python](../python) example +scripts against local changes), use an editable install from the repository +root: + +``` console +pip install -e . +``` + +Re-run it after changing C++ code to rebuild the extension (the CMake build +dir is persistent, so rebuilds are incremental); pure-python changes are +picked up automatically. + +Wheels for Linux (x86_64 / aarch64), macOS (x86_64 / arm64), and Windows +(amd64) are built in CI by +[build_wheels.yml](../.github/workflows/build_wheels.yml) and published to +PyPI on each release. ## Building for PC (C++ & Python) @@ -56,7 +92,9 @@ This will build and install the following files: * `./pc/libespp_pc` - C++ static library for use with other C++ code. * `./pc/include` - All the header files need for using the library from C++ code. -* `./pc/espp.so` - C++ shared library for python binding for use with python code. +* `./pc/espp/` - The `espp` python package (pure-python files, type stubs, and + the compiled `espp._espp` pybind11 extension) - add `./pc` to your + `PYTHONPATH` / `sys.path` to `import espp` from python code. ## Updating the python bindings @@ -108,7 +146,13 @@ by hand: - `std::shared_ptr` holders + bases (the RTP packetizer hierarchy, `JpegFrame`), - unqualified RTSP nested types / nested-struct statics in named-ctor defaults (`espp::RtspClient::frame_callback_t`, `espp::RtspServer::Config::default_*`, ...), -- `def_readwrite` → `def_readonly` for non-copyable members (`RtspSession::Track` sockets). +- `def_readwrite` → `def_readonly` for non-copyable members (`RtspSession::Track` sockets), +- `py::call_guard()` on blocking methods (`Task::stop`, + `Timer::cancel`, socket send/receive/accept, the RTSP request methods, ...). + Without it, any method that joins the task thread or blocks on I/O deadlocks + when called from python, because the blocked-on thread needs the GIL to invoke + its python callback (see `_GIL_RELEASE_METHODS` in `autogenerate_bindings.py`; + the hand-written `rtps` bindings apply the same guard). The litgen dependency is unpinned and recent versions (0.20–0.22) regressed nested-scope/template generation, which is why this automation is needed. diff --git a/lib/README_PYPI.md b/lib/README_PYPI.md new file mode 100644 index 000000000..d4b7df812 --- /dev/null +++ b/lib/README_PYPI.md @@ -0,0 +1,65 @@ +# espp + +Python bindings for the cross-platform components of +[espp](https://github.com/esp-cpp/espp) — a collection of C++ components +originally built for ESP32 / ESP-IDF development, many of which are pure, +portable C++ and useful on the desktop as well. + +The bindings are generated with [pybind11](https://github.com/pybind/pybind11) +and expose real native threads, sockets, and protocol implementations — unlike +Python threading, an `espp.Task` or `espp.Timer` runs on a real OS thread. + +## Installation + +```console +pip install espp +``` + +Or straight from the repository: + +```console +pip install git+https://github.com/esp-cpp/espp.git +``` + +Building from source requires CMake (>= 3.20) and a C++20 compiler. + +## What's included + +- **Task / Timer**: native-threaded tasks and periodic timers with callbacks +- **Sockets**: UDP / TCP client & server helpers (unicast & multicast) +- **RTSP**: client & server with MJPEG / H.264 (de)packetizers +- **RTPS**: minimal RTPS participant for DDS interop (e.g. with embedded espp nodes) +- **CDR**: OMG Common Data Representation reader / writer +- **COBS**: consistent overhead byte stuffing encoder / decoder (incl. streaming) +- **Serialization**: alpaca-based binary serialization +- **Math**: vectors, Bézier curves, range mapping, fast approximations, filters (lowpass, Butterworth, ...), PID +- **Utilities**: logger, event manager, state machine, FTP, joystick, color, ... + +The package ships complete type stubs (`py.typed`), so you get full +autocompletion and type checking in your editor. + +## Quick example + +```python +import time +import espp + +def task_fn(): + print("hello from a native thread!") + time.sleep(0.5) + return False # don't stop the task + +task = espp.Task(task_fn, espp.Task.BaseConfig("example task")) +task.start() +time.sleep(2) +task.stop() +``` + +More example scripts live in the +[`python/`](https://github.com/esp-cpp/espp/tree/main/python) folder of the +repository. + +## Links + +- Documentation: https://esp-cpp.github.io/espp/ +- Source & issues: https://github.com/esp-cpp/espp diff --git a/lib/autogenerate_bindings.py b/lib/autogenerate_bindings.py index 5067ccb7f..b4d99fe23 100644 --- a/lib/autogenerate_bindings.py +++ b/lib/autogenerate_bindings.py @@ -1,5 +1,5 @@ -import litgen -from srcmlcpp import SrcmlcppOptions +# NOTE: litgen is imported lazily (inside the functions that need it) so the postprocess passes +# below can be imported and reapplied to the generated file without a litgen environment. import os import re @@ -226,16 +226,100 @@ def _fix_rtsp_qualifications(code: str) -> str: return code +# Methods that can block on network I/O or on joining the task thread. Calling them with the GIL +# held deadlocks whenever the blocked-on thread needs the GIL to invoke a python callback: e.g. +# Task.stop() joins the task thread while that thread is waiting for the GIL to call the python +# callback, so neither can proceed. pybind11 re-acquires the GIL automatically whenever a wrapped +# python callback is invoked from C++, so releasing it around these calls is safe. We append +# py::call_guard() as the last argument of each matching .def(...); the +# class is identified by the def's argument text (member pointer or lambda self-parameter), so all +# overloads (member pointer, py::overload_cast, lambda) are covered. +_GIL_RELEASE_METHODS = { + "espp::Task": ["start", "stop"], + "espp::Timer": ["start", "stop", "cancel"], + "espp::FtpServer": ["start", "stop"], + "espp::TcpSocket": ["connect", "transmit", "receive", "accept"], + "espp::UdpSocket": ["send", "receive", "start_receiving", "stop_receiving"], + "espp::RtspClient": [ + "send_request", "connect", "disconnect", "describe", + "setup", "play", "pause", "teardown", + ], + "espp::RtspServer": ["start", "stop", "send_frame"], + "espp::RtspSession": ["send_rtp_packet", "send_rtcp_packet"], +} + +_GIL_GUARD = "py::call_guard()" + + +def _find_call_end(code: str, open_paren: int) -> int: + """Return the index of the ')' closing the call whose '(' is at open_paren. + + Skips over string literals (including escaped quotes) so parentheses inside docstrings do not + unbalance the scan. + """ + depth = 0 + i = open_paren + while i < len(code): + c = code[i] + if c == '"': + i += 1 + while i < len(code) and code[i] != '"': + if code[i] == "\\": + i += 1 + i += 1 + elif c == "(": + depth += 1 + elif c == ")": + depth -= 1 + if depth == 0: + return i + i += 1 + raise ValueError("unbalanced parentheses while scanning a .def(...) call") + + +def _add_gil_release_guards(code: str) -> str: + for cls, methods in _GIL_RELEASE_METHODS.items(): + for name in methods: + pattern = re.compile(r"\.def\(\s*\"" + re.escape(name) + r"\",") + n = 0 + pos = 0 + while True: + m = pattern.search(code, pos) + if m is None: + break + open_paren = m.start() + len(".def") + close = _find_call_end(code, open_paren) + body = code[m.start():close] + # `cls::` matches member pointers / overload_casts; `cls ` and `cls&` match a + # lambda's `(espp::Timer &self, ...)` parameter. + if f"{cls}::" in body or f"{cls} " in body or f"{cls}&" in body: + if _GIL_GUARD not in body: + code = code[:close] + f", {_GIL_GUARD}" + code[close:] + n += 1 + pos = close + len(_GIL_GUARD) + 2 + else: + n += 1 # already guarded (pass is idempotent) + pos = close + else: + pos = m.end() + if n == 0: + print(f"WARNING: gil-release guard for {cls}::{name} matched 0 defs") + return code + + def _postprocess_generated(code: str) -> str: code = _fix_implicit_default_ctors(code) code = _fix_template_class_nested(code) code = _remove_static_instance_dups(code) code = _fix_class_holders(code) code = _fix_rtsp_qualifications(code) + code = _add_gil_release_guards(code) return code -def my_litgen_options() -> litgen.LitgenOptions: +def my_litgen_options() -> "litgen.LitgenOptions": + import litgen + # configure your options here options = litgen.LitgenOptions() @@ -344,6 +428,8 @@ def my_litgen_options() -> litgen.LitgenOptions: def autogenerate() -> None: + import litgen + repository_dir = os.path.realpath(os.path.dirname(__file__) + "/../") output_dir = repository_dir + "/lib/python_bindings" diff --git a/lib/bind.cpp b/lib/bind.cpp deleted file mode 100644 index 80d935445..000000000 --- a/lib/bind.cpp +++ /dev/null @@ -1,138 +0,0 @@ -#include -#include -#include -#include -#include - -#include "espp.hpp" - -namespace py = pybind11; -using namespace espp; - -PYBIND11_MODULE(espp, m) { - - // Logger Verbosity - py::enum_(m, "Verbosity") - .value("DEBUG", Logger::Verbosity::DEBUG) - .value("INFO", Logger::Verbosity::INFO) - .value("WARN", Logger::Verbosity::WARN) - .value("ERROR", Logger::Verbosity::ERROR) - .value("NONE", Logger::Verbosity::NONE); - - // Task - // NOTE: we cannot use the Task::Config since its callback function has a - // std::mutex and std::condition_variable which pybind does not support. - // Therefore we use the SimpleConfig, whose callback function has no - // arguments and return bool - py::class_(m, "TaskBaseConfig") - .def(py::init(), py::arg("name"), - py::arg("stack_size") = 4 * 1024, py::arg("priority") = 0, py::arg("core_id") = -1); - py::class_(m, "TaskConfig") - .def(py::init(), - py::arg("name"), py::arg("callback"), py::arg("stack_size") = 4 * 1024, - py::arg("priority") = 0, py::arg("core_id") = -1, - py::arg("verbosity") = Logger::Verbosity::WARN); - py::class_(m, "TaskSimpleConfig") - .def(py::init(), - py::arg("callback"), py::arg("task_config"), - py::arg("verbosity") = Logger::Verbosity::WARN); - py::class_(m, "TaskAdvancedConfig") - .def(py::init(), py::arg("callback"), - py::arg("task_config"), py::arg("verbosity") = Logger::Verbosity::WARN); - - py::class_(m, "Task") - .def(py::init()) - .def(py::init()) - .def(py::init()) - .def("start", &Task::start) - .def("stop", &Task::stop) - .def("is_started", &Task::is_started) - .def("is_running", &Task::is_running); - - // Timer - py::class_(m, "TimerConfig") - .def(py::init, std::chrono::duration, - Timer::callback_fn, bool, size_t, size_t, int, Logger::Verbosity>(), - py::arg("name"), py::arg("period"), py::arg("delay") = std::chrono::duration(0), - py::arg("callback"), py::arg("auto_start") = true, py::arg("stack_size") = 4 * 1024, - py::arg("priority") = 0, py::arg("core_id") = -1, - py::arg("verbosity") = Logger::Verbosity::WARN); - - py::class_(m, "Timer") - .def(py::init()) - .def("start", py::overload_cast<>(&Timer::start)) - .def("start", py::overload_cast>(&Timer::start)) - .def("cancel", &Timer::cancel) - .def("is_running", &Timer::is_running); - - // Socket Info - py::class_(m, "SocketInfo") - .def(py::init<>()) - .def_readwrite("address", &Socket::Info::address) - .def_readwrite("port", &Socket::Info::port); - - // UDP Socket Config - py::class_(m, "UdpSocketConfig") - .def(py::init(), py::arg("log_level") = Logger::Verbosity::WARN); - - // UDP Socket SendConfig - py::class_(m, "UdpSendConfig") - .def(py::init>(), - py::arg("ip_address"), py::arg("port"), py::arg("is_multicast_endpoint") = false, - py::arg("wait_for_response") = false, py::arg("response_size") = 0, - py::arg("on_response_callback") = nullptr, py::arg("response_timeout") = 0.5f); - - // UDP Socket ReceiveConfig - py::class_(m, "UdpReceiveConfig") - .def(py::init(), - py::arg("port"), py::arg("buffer_size"), py::arg("is_multicast_endpoint") = false, - py::arg("multicast_group") = "", py::arg("on_receive_callback") = nullptr); - - // UDP Socket - py::class_(m, "UdpSocket") - .def(py::init(), py::arg("config")) - .def("send", py::overload_cast &, const UdpSocket::SendConfig &>( - &UdpSocket::send)) - .def("send", - py::overload_cast(&UdpSocket::send)) - .def("receive", &UdpSocket::receive) - .def("start_receiving", &UdpSocket::start_receiving); - - // TCP Socket Config - py::class_(m, "TcpSocketConfig") - .def(py::init(), py::arg("log_level") = Logger::Verbosity::WARN); - - // TCP Socket ConnectConfig - py::class_(m, "TcpConnectConfig") - .def(py::init(), py::arg("ip_address"), py::arg("port")); - - // espp::detail TcpTransmitConfig - py::class_(m, "TcpTransmitConfig") - .def(py::init>(), - py::arg("wait_for_response") = false, py::arg("response_size") = 0, - py::arg("on_response_callback") = nullptr, py::arg("response_timeout") = 0.5f); - - // TCP Socket - py::class_(m, "TcpSocket") - .def(py::init(), py::arg("config")) - .def("reinit", &TcpSocket::reinit) - .def("close", &TcpSocket::close) - .def("is_connected", &TcpSocket::is_connected) - .def("connect", &TcpSocket::connect) - .def("bind", &TcpSocket::bind) - .def("listen", &TcpSocket::listen) - .def("accept", &TcpSocket::accept) - .def("get_remote_info", &TcpSocket::get_remote_info) - .def("transmit", - py::overload_cast &, const detail::TcpTransmitConfig &>( - &TcpSocket::transmit)) - .def("transmit", - py::overload_cast &, const detail::TcpTransmitConfig &>( - &TcpSocket::transmit)) - .def("transmit", py::overload_cast( - &TcpSocket::transmit)) - .def("receive", py::overload_cast &, size_t>(&TcpSocket::receive)) - .def("receive", py::overload_cast(&TcpSocket::receive)); -} diff --git a/lib/espp.cmake b/lib/espp.cmake index 12432b4fd..eff8edea2 100644 --- a/lib/espp.cmake +++ b/lib/espp.cmake @@ -94,11 +94,13 @@ else() set(ESPP_EXTERNAL_LIBS pthread) endif() +set(ESPP_PYTHON_BINDINGS_DIR ${CMAKE_CURRENT_LIST_DIR}/python_bindings) + set(ESPP_PYTHON_SOURCES - ${CMAKE_CURRENT_LIST_DIR}/python_bindings/module.cpp - ${CMAKE_CURRENT_LIST_DIR}/python_bindings/pybind_espp.cpp - ${CMAKE_CURRENT_LIST_DIR}/python_bindings/cdr_bindings.cpp - ${CMAKE_CURRENT_LIST_DIR}/python_bindings/rtps_bindings.cpp + ${ESPP_PYTHON_BINDINGS_DIR}/module.cpp + ${ESPP_PYTHON_BINDINGS_DIR}/pybind_espp.cpp + ${ESPP_PYTHON_BINDINGS_DIR}/cdr_bindings.cpp + ${ESPP_PYTHON_BINDINGS_DIR}/rtps_bindings.cpp ${ESPP_SOURCES} ) @@ -110,17 +112,37 @@ function(espp_install_includes FOLDER) install(DIRECTORY ${ESPP_EXTERNAL_INCLUDES_SEPARATE} DESTINATION ${FOLDER}/include/) endfunction() -# make an espp_install_python_module command that can be used by other scripts, where -# they just need to specify the folder they want to install into -function(espp_install_python_module FOLDER) - pybind11_add_module(espp ${ESPP_PYTHON_SOURCES}) - target_compile_features(espp PRIVATE cxx_std_20) +# make an espp_add_python_module command that defines the `_espp` pybind11 +# extension module target (the native part of the `espp` python package) +function(espp_add_python_module) + pybind11_add_module(_espp ${ESPP_PYTHON_SOURCES}) + target_compile_features(_espp PRIVATE cxx_std_20) # disable certain compiler warnings for this module, but only if we're not on # Windows if(NOT MSVC) - target_compile_options(espp PRIVATE -Wno-braced-scalar-init -Wno-unused-variable -Wno-unused-parameter) + target_compile_options(_espp PRIVATE -Wno-braced-scalar-init -Wno-unused-variable -Wno-unused-parameter) + endif() + target_link_libraries(_espp PRIVATE ${ESPP_EXTERNAL_LIBS}) + # embed the package version (set by scikit-build-core when building wheels); + # prefer the full PEP 440 version (includes .devN+local parts) over the + # CMake-compatible X.Y.Z truncation + if(DEFINED SKBUILD_PROJECT_VERSION_FULL) + target_compile_definitions(_espp PRIVATE VERSION_INFO=${SKBUILD_PROJECT_VERSION_FULL}) + elseif(DEFINED SKBUILD_PROJECT_VERSION) + target_compile_definitions(_espp PRIVATE VERSION_INFO=${SKBUILD_PROJECT_VERSION}) endif() - target_link_libraries(espp PRIVATE ${ESPP_EXTERNAL_LIBS}) - install(TARGETS espp - LIBRARY DESTINATION ${FOLDER}/) +endfunction() + +# make an espp_install_python_module command that can be used by other scripts, +# where they just need to specify the folder they want to install into. This +# installs the full `espp` python package (pure-python files + the compiled +# `_espp` extension) into FOLDER/espp so FOLDER can be put on sys.path. +function(espp_install_python_module FOLDER) + espp_add_python_module() + install(DIRECTORY ${ESPP_PYTHON_BINDINGS_DIR}/espp + DESTINATION ${FOLDER}/ + PATTERN "__pycache__" EXCLUDE + PATTERN ".mypy_cache" EXCLUDE) + install(TARGETS _espp + LIBRARY DESTINATION ${FOLDER}/espp/) endfunction() diff --git a/lib/pyproject.toml b/lib/pyproject.toml deleted file mode 100644 index 63f1e60f3..000000000 --- a/lib/pyproject.toml +++ /dev/null @@ -1,7 +0,0 @@ -[build-system] -requires = ["scikit-build-core", "pybind11"] -build-backend = "scikit_build_core.build" - -[project] -name = "espp" -version = "0.1.0" diff --git a/lib/python_bindings/espp/__init__.py b/lib/python_bindings/espp/__init__.py index aeafc623a..e7bbb144e 100644 --- a/lib/python_bindings/espp/__init__.py +++ b/lib/python_bindings/espp/__init__.py @@ -1,2 +1,9 @@ -from espp import * # type: ignore # noqa: F403 -from espp import __version__ # noqa: F401 +"""Python bindings for the cross-platform components of espp. + +The actual implementation lives in the compiled ``espp._espp`` extension +module (pybind11); this package re-exports everything from it and ships the +type stubs (``__init__.pyi``) that describe the full API. +""" + +from espp._espp import * # type: ignore # noqa: F403 +from espp._espp import __version__ # noqa: F401 diff --git a/lib/python_bindings/module.cpp b/lib/python_bindings/module.cpp index a1cf28636..a8140362b 100644 --- a/lib/python_bindings/module.cpp +++ b/lib/python_bindings/module.cpp @@ -12,9 +12,9 @@ void py_init_module_espp(py::module &m); void py_init_cdr(py::module &m); void py_init_rtps(py::module &m); -// This builds the native python module `espp` -// it will be wrapped in a standard python module `espp` -PYBIND11_MODULE(espp, m) { +// This builds the native python extension module `espp._espp`, which the +// `espp` python package (python_bindings/espp/__init__.py) re-exports. +PYBIND11_MODULE(_espp, m) { #ifdef VERSION_INFO m.attr("__version__") = MACRO_STRINGIFY(VERSION_INFO); #else diff --git a/lib/python_bindings/pybind_espp.cpp b/lib/python_bindings/pybind_espp.cpp index 7b465d8f5..ca9582c64 100644 --- a/lib/python_bindings/pybind_espp.cpp +++ b/lib/python_bindings/pybind_espp.cpp @@ -357,8 +357,10 @@ void py_init_module_espp(py::module &m) { "on.\n/ \\param root The root directory of the FTP server.") .def("start", &espp::FtpServer::start, "/ \\brief Start the FTP server.\n/ Bind to the port and start accepting " - "connections.\n/ \\return True if the server was started, False otherwise.") - .def("stop", &espp::FtpServer::stop, "/ \\brief Stop the FTP server."); + "connections.\n/ \\return True if the server was started, False otherwise.", + py::call_guard()) + .def("stop", &espp::FtpServer::stop, "/ \\brief Stop the FTP server.", + py::call_guard()); //////////////////// //////////////////// //////////////////// //////////////////// @@ -1798,7 +1800,8 @@ void py_init_module_espp(py::module &m) { .def("connect", &espp::TcpSocket::connect, py::arg("connect_config"), "*\n * @brief Open a connection to the remote TCP server.\n * @param connect_config " "ConnectConfig struct describing the server endpoint.\n * @return True if the client " - "successfully connected to the server.\n") + "successfully connected to the server.\n", + py::call_guard()) .def("get_remote_info", &espp::TcpSocket::get_remote_info, "*\n * @brief Get the remote endpoint info.\n * @return The remote endpoint info.\n") .def("transmit", @@ -1811,7 +1814,8 @@ void py_init_module_espp(py::module &m) { "send_config which will be provided the response data for\n * processing.\n " "* @param data vector of bytes to send to the remote endpoint.\n * @param " "transmit_config TransmitConfig struct indicating whether to wait for a\n * " - "response.\n * @return True if the data was sent, False otherwise.\n") + "response.\n * @return True if the data was sent, False otherwise.\n", + py::call_guard()) .def("transmit", py::overload_cast &, const espp::TcpSocket::TransmitConfig &>( &espp::TcpSocket::transmit), @@ -1822,7 +1826,8 @@ void py_init_module_espp(py::module &m) { "send_config which will be provided the response data for\n * processing.\n " "* @param data vector of bytes to send to the remote endpoint.\n * @param " "transmit_config TransmitConfig struct indicating whether to wait for a\n * " - "response.\n * @return True if the data was sent, False otherwise.\n") + "response.\n * @return True if the data was sent, False otherwise.\n", + py::call_guard()) .def("transmit", py::overload_cast( &espp::TcpSocket::transmit), @@ -1833,7 +1838,8 @@ void py_init_module_espp(py::module &m) { "send_config which will be provided the response data for\n * processing.\n " "* @param data string view of bytes to send to the remote endpoint.\n * @param " "transmit_config TransmitConfig struct indicating whether to wait for a\n * " - "response.\n * @return True if the data was sent, False otherwise.\n") + "response.\n * @return True if the data was sent, False otherwise.\n", + py::call_guard()) .def("transmit", py::overload_cast, const espp::TcpSocket::TransmitConfig &>( &espp::TcpSocket::transmit), @@ -1844,13 +1850,15 @@ void py_init_module_espp(py::module &m) { "send_config which will be provided the response data for\n * processing.\n " "* @param data span of bytes to send to the remote endpoint.\n * @param " "transmit_config TransmitConfig struct indicating whether to wait for a\n * " - "response.\n * @return True if the data was sent, False otherwise.\n") + "response.\n * @return True if the data was sent, False otherwise.\n", + py::call_guard()) .def("receive", py::overload_cast &, size_t>(&espp::TcpSocket::receive), py::arg("data"), py::arg("max_num_bytes"), "*\n * @brief Call read on the socket, assuming it has already been configured\n * " " appropriately.\n *\n * @param data Vector of bytes of received data.\n * " "@param max_num_bytes Maximum number of bytes to receive.\n * @return True if " - "successfully received, False otherwise.\n") + "successfully received, False otherwise.\n", + py::call_guard()) .def("receive", py::overload_cast(&espp::TcpSocket::receive), py::arg("data"), py::arg("max_num_bytes"), "*\n * @brief Call read on the socket, assuming it has already been configured\n * " @@ -1858,7 +1866,8 @@ void py_init_module_espp(py::module &m) { "received or the\n * receive timeout is reached.\n * @note The data pointed " "to by data must be at least max_num_bytes in size.\n * @param data Pointer to buffer " "to receive data.\n * @param max_num_bytes Maximum number of bytes to receive.\n * " - "@return Number of bytes received.\n") + "@return Number of bytes received.\n", + py::call_guard()) .def("bind", &espp::TcpSocket::bind, py::arg("port"), "*\n * @brief Bind the socket as a server on \\p port.\n * @param port The port to " "which to bind the socket.\n * @return True if the socket was bound.\n") @@ -1871,7 +1880,8 @@ void py_init_module_espp(py::module &m) { "*\n * @brief Accept an incoming connection.\n * @note Blocks until a connection is " "accepted.\n * @note Must be called after listen.\n * @note This function will " "block until a connection is accepted.\n * @return A unique pointer to a " - "TcpClientSession if a connection was\n * accepted, None otherwise.\n"); + "TcpClientSession if a connection was\n * accepted, None otherwise.\n", + py::call_guard()); //////////////////// //////////////////// //////////////////// //////////////////// @@ -1997,7 +2007,8 @@ void py_init_module_espp(py::module &m) { pyClassUdpSocket.def(py::init()) .def("stop_receiving", &espp::UdpSocket::stop_receiving, - "/ Stop the receive task, if one is running, and close the socket.") + "/ Stop the receive task, if one is running, and close the socket.", + py::call_guard()) .def("send", py::overload_cast &, const espp::UdpSocket::SendConfig &>( &espp::UdpSocket::send), @@ -2010,7 +2021,8 @@ void py_init_module_espp(py::module &m) { "send_config which will be provided the response data for\n * processing.\n " "* @param data vector of bytes to send to the remote endpoint.\n * @param send_config " "SendConfig struct indicating where to send and whether\n * to wait for a " - "response.\n * @return True if the data was sent, False otherwise.\n") + "response.\n * @return True if the data was sent, False otherwise.\n", + py::call_guard()) .def("send", py::overload_cast( &espp::UdpSocket::send), @@ -2023,7 +2035,8 @@ void py_init_module_espp(py::module &m) { "send_config which will be provided the response data for\n * processing.\n " "* @param data String view of bytes to send to the remote endpoint.\n * @param " "send_config SendConfig struct indicating where to send and whether\n * to " - "wait for a response.\n * @return True if the data was sent, False otherwise.\n") + "wait for a response.\n * @return True if the data was sent, False otherwise.\n", + py::call_guard()) .def("send", py::overload_cast, const espp::UdpSocket::SendConfig &>( &espp::UdpSocket::send), @@ -2036,7 +2049,8 @@ void py_init_module_espp(py::module &m) { "send_config which will be provided the response data for\n * processing.\n " "* @param data std::span of bytes to send to the remote endpoint.\n * @param " "send_config SendConfig struct indicating where to send and whether\n * to " - "wait for a response.\n * @return True if the data was sent, False otherwise.\n") + "wait for a response.\n * @return True if the data was sent, False otherwise.\n", + py::call_guard()) .def("receive", &espp::UdpSocket::receive, py::arg("max_num_bytes"), py::arg("data"), py::arg("remote_info"), "*\n * @brief Call recvfrom on the socket, assuming it has already been\n * " @@ -2044,14 +2058,16 @@ void py_init_module_espp(py::module &m) { "receive.\n * @param data Vector of bytes of received data.\n * @param remote_info " "Socket::Info containing the sender's information. This\n * will be populated " "with the information about the sender.\n * @return True if successfully received, " - "False otherwise.\n") + "False otherwise.\n", + py::call_guard()) .def("start_receiving", &espp::UdpSocket::start_receiving, py::arg("task_config"), py::arg("receive_config"), "*\n * @brief Configure a server socket and start a thread to continuously\n * " " receive and handle data coming in on that socket.\n *\n * @param task_config " "Task::BaseConfig struct for configuring the receive task.\n * @param receive_config " "ReceiveConfig struct with socket and callback info.\n * @return True if the socket " - "was created and task was started, False otherwise.\n"); + "was created and task was started, False otherwise.\n", + py::call_guard()); //////////////////// //////////////////// //////////////////// //////////////////// @@ -2134,12 +2150,14 @@ void py_init_module_espp(py::module &m) { espp::Logger::Verbosity>()) .def("start", &espp::Task::start, "*\n * @brief Start executing the task.\n *\n * @return True if the task started, " - "False if it was already started.\n") + "False if it was already started.\n", + py::call_guard()) .def("stop", &espp::Task::stop, "*\n * @brief Stop the task execution.\n * @details This will request the task to " "stop, notify the condition variable,\n * and (if this calling context is " "not the task context) join the\n * thread.\n * @return True if the task " - "stopped, False if it was not started / already\n * stopped.\n") + "stopped, False if it was not started / already\n * stopped.\n", + py::call_guard()) .def("is_started", &espp::Task::is_started, "*\n * @brief Has the task been started or not?\n *\n * @return True if the task " "is started / running, False otherwise.\n") @@ -2308,7 +2326,8 @@ void py_init_module_espp(py::module &m) { .def( "start", [](espp::Timer &self) { return self.start(); }, "/ @brief Start the timer.\n/ @details Starts the timer. Does nothing if the timer is " - "already running.") + "already running.", + py::call_guard()) .def("start", py::overload_cast &>(&espp::Timer::start), py::arg("delay"), "/ @brief Start the timer with a delay.\n/ @details Starts the timer with a delay. If " @@ -2316,12 +2335,15 @@ void py_init_module_espp(py::module &m) { "again with the new\n/ delay. If the timer is not running, this will start the " "timer\n/ with the delay. Overwrites any previous delay that might have\n/ " " been set.\n/ @param delay The delay before the first execution of the timer " - "callback.") + "callback.", + py::call_guard()) .def("stop", &espp::Timer::stop, "/ @brief Stop the timer, same as cancel().\n/ @details Stops the timer, same as " - "cancel().") + "cancel().", + py::call_guard()) .def("cancel", &espp::Timer::cancel, - "/ @brief Cancel the timer.\n/ @details Cancels the timer.") + "/ @brief Cancel the timer.\n/ @details Cancels the timer.", + py::call_guard()) .def("set_period", &espp::Timer::set_period, py::arg("period"), "/ @brief Set the period of the timer.\n/ @details Sets the period of the timer.\n/ " "@param period The period of the timer.\n/ @note If the period is 0, the timer will run " @@ -3251,21 +3273,26 @@ void py_init_module_espp(py::module &m) { "and \"Session\" headers will be added automatically.\n/ The \"Accept\" header " "will be added automatically. The \"Transport\"\n/ header will be added " "automatically for the \"SETUP\" method. Defaults to\n/ an empty map.\n/ \\param " - "ec The error code to set if an error occurs\n/ \\return The response from the server") + "ec The error code to set if an error occurs\n/ \\return The response from the server", + py::call_guard()) .def("connect", &espp::RtspClient::connect, py::arg("ec"), "/ Connect to the RTSP server\n/ Connects to the RTSP server and sends the OPTIONS " - "request.\n/ \\param ec The error code to set if an error occurs") + "request.\n/ \\param ec The error code to set if an error occurs", + py::call_guard()) .def("disconnect", &espp::RtspClient::disconnect, py::arg("ec"), "/ Disconnect from the RTSP server\n/ Disconnects from the RTSP server and sends the " - "TEARDOWN request.\n/ \\param ec The error code to set if an error occurs") + "TEARDOWN request.\n/ \\param ec The error code to set if an error occurs", + py::call_guard()) .def("describe", &espp::RtspClient::describe, py::arg("ec"), "/ Describe the RTSP stream\n/ Sends the DESCRIBE request to the RTSP server and parses " - "the response.\n/ \\param ec The error code to set if an error occurs") + "the response.\n/ \\param ec The error code to set if an error occurs", + py::call_guard()) .def("setup", py::overload_cast(&espp::RtspClient::setup), py::arg("ec"), "/ Setup the RTSP stream\n/ \note Starts the RTP and RTCP threads.\n/ Sends the SETUP " "request to the RTSP server and parses the response.\n/ \note The default ports are " "5000 and 5001 for RTP and RTCP respectively.\n/ \note The default receive timeout is 5 " - "seconds.\n/ \\param ec The error code to set if an error occurs") + "seconds.\n/ \\param ec The error code to set if an error occurs", + py::call_guard()) .def("setup", py::overload_cast &, std::error_code &>(&espp::RtspClient::setup), @@ -3274,7 +3301,8 @@ void py_init_module_espp(py::module &m) { "response.\n/ \note Starts the RTP and RTCP threads.\n/ \\param rtp_port The RTP client " "port\n/ \\param rtcp_port The RTCP client port\n/ \\param receive_timeout The timeout " "for receiving RTP and RTCP packets\n/ \\param ec The error code to set if an error " - "occurs") + "occurs", + py::call_guard()) .def("add_depacketizer", &espp::RtspClient::add_depacketizer, py::arg("payload_type"), py::arg("depacketizer"), "/ Register a depacketizer for a specific RTP payload type.\n/ When RTP packets with " @@ -3283,13 +3311,16 @@ void py_init_module_espp(py::module &m) { "H264)\n/ @param depacketizer The depacketizer to handle packets of this type") .def("play", &espp::RtspClient::play, py::arg("ec"), "/ Play the RTSP stream\n/ Sends the PLAY request to the RTSP server and parses the " - "response.\n/ \\param ec The error code to set if an error occurs") + "response.\n/ \\param ec The error code to set if an error occurs", + py::call_guard()) .def("pause", &espp::RtspClient::pause, py::arg("ec"), "/ Pause the RTSP stream\n/ Sends the PAUSE request to the RTSP server and parses the " - "response.\n/ \\param ec The error code to set if an error occurs") + "response.\n/ \\param ec The error code to set if an error occurs", + py::call_guard()) .def("teardown", &espp::RtspClient::teardown, py::arg("ec"), "/ Teardown the RTSP stream\n/ Sends the TEARDOWN request to the RTSP server and parses " - "the response.\n/ \\param ec The error code to set if an error occurs") + "the response.\n/ \\param ec The error code to set if an error occurs", + py::call_guard()) .def("tracks", &espp::RtspClient::tracks, "/ Get the parsed SDP track descriptions from the most recent DESCRIBE call.\n/ " "\\return The ordered set of discovered media tracks."); @@ -3385,10 +3416,12 @@ void py_init_module_espp(py::module &m) { .def("start", &espp::RtspServer::start, py::arg("accept_timeout") = std::chrono::seconds(5), "/ @brief Start the RTSP server\n/ Starts the accept task, session task, and binds the " "RTSP socket\n/ @param accept_timeout The timeout for accepting new connections\n/ " - "@return True if the server was started successfully, False otherwise") + "@return True if the server was started successfully, False otherwise", + py::call_guard()) .def("stop", &espp::RtspServer::stop, "/ @brief Stop the FTP server\n/ Stops the accept task, session task, and closes the " - "RTSP socket") + "RTSP socket", + py::call_guard()) .def("add_track", &espp::RtspServer::add_track, py::arg("config"), "/ @brief Register a media track with the server.\n/ Each track has its own packetizer, " "SSRC, and sequence number.\n/ @param config Track configuration including the " @@ -3408,20 +3441,23 @@ void py_init_module_espp(py::module &m) { "/ @brief Send a frame on a specific track.\n/ The track's packetizer splits the frame " "into RTP payload chunks,\n/ which are then wrapped with RTP headers and queued for " "delivery.\n/ @note Overwrites any existing pending packets for this track.\n/ @param " - "track_id The track to send on.\n/ @param frame_data Raw encoded frame data.") + "track_id The track to send on.\n/ @param frame_data Raw encoded frame data.", + py::call_guard()) .def("send_frame", py::overload_cast(&espp::RtspServer::send_frame), py::arg("frame"), "/ @brief Send a JPEG frame over the RTSP connection (backward compatible).\n/ If no " "tracks have been added, lazily creates a default MJPEG track on\n/ track 0. Uses the " "legacy RtpJpegPacket packetization to preserve the\n/ exact wire format for existing " "MJPEG users.\n/ @note Overwrites any existing frame that has not been sent.\n/ @param " - "frame The frame to send.") + "frame The frame to send.", + py::call_guard()) .def("send_frame", py::overload_cast>(&espp::RtspServer::send_frame), py::arg("frame_data"), "/ @brief Send raw JPEG bytes over the default MJPEG track.\n/ Uses the legacy MJPEG " "RTP packetization path without copying the frame\n/ into an intermediate JpegFrame " "object.\n/ @note Overwrites any existing frame that has not been sent.\n/ @param " - "frame_data Complete JPEG bytes, including header and EOI marker."); + "frame_data Complete JPEG bytes, including header and EOI marker.", + py::call_guard()); //////////////////// //////////////////// //////////////////// //////////////////// @@ -3514,37 +3550,43 @@ void py_init_module_espp(py::module &m) { py::arg("track_id"), py::arg("packet"), "/ Send an RTP packet on a specific track\n/ @param track_id The track to send on\n/ " "@param packet The RTP packet to send\n/ @return True if the packet was sent " - "successfully, False otherwise") + "successfully, False otherwise", + py::call_guard()) .def("send_rtp_packet", py::overload_cast>(&espp::RtspSession::send_rtp_packet), py::arg("track_id"), py::arg("packet_data"), "/ Send a serialized RTP packet on a specific track.\n/ @param track_id The track to " "send on\n/ @param packet_data Serialized RTP packet bytes\n/ @return True if the " - "packet was sent successfully, False otherwise") + "packet was sent successfully, False otherwise", + py::call_guard()) .def("send_rtp_packet", py::overload_cast(&espp::RtspSession::send_rtp_packet), py::arg("packet"), "/ Send an RTP packet to the client (backward compat — sends on default track 0)\n/ " "@param packet The RTP packet to send\n/ @return True if the packet was sent " - "successfully, False otherwise") + "successfully, False otherwise", + py::call_guard()) .def("send_rtp_packet", py::overload_cast>(&espp::RtspSession::send_rtp_packet), py::arg("packet_data"), "/ Send a serialized RTP packet to the client (default track 0).\n/ @param packet_data " "Serialized RTP packet bytes\n/ @return True if the packet was sent successfully, False " - "otherwise") + "otherwise", + py::call_guard()) .def("send_rtcp_packet", py::overload_cast(&espp::RtspSession::send_rtcp_packet), py::arg("track_id"), py::arg("packet"), "/ Send an RTCP packet on a specific track\n/ @param track_id The track to send on\n/ " "@param packet The RTCP packet to send\n/ @return True if the packet was sent " - "successfully, False otherwise") + "successfully, False otherwise", + py::call_guard()) .def("send_rtcp_packet", py::overload_cast(&espp::RtspSession::send_rtcp_packet), py::arg("packet"), "/ Send an RTCP packet to the client (backward compat — sends on default track 0)\n/ " "@param packet The RTCP packet to send\n/ @return True if the packet was sent " - "successfully, False otherwise"); + "successfully, False otherwise", + py::call_guard()); //////////////////// //////////////////// //////////////////// //////////////////// diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..1bf312f7d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,87 @@ +[build-system] +requires = ["scikit-build-core>=0.11", "pybind11>=3.0,<4", "setuptools-scm>=8"] +build-backend = "scikit_build_core.build" + +[project] +name = "espp" +dynamic = ["version"] +description = "Python bindings for the cross-platform components of espp (ESP-CPP): tasks, timers, sockets, RTSP, RTPS, CDR, COBS, serialization, math, and more" +readme = "lib/README_PYPI.md" +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "William Emfinger", email = "waemfinger@gmail.com" }] +requires-python = ">=3.10" +keywords = ["espp", "esp-cpp", "embedded", "rtsp", "rtps", "cdr", "cobs", "sockets"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Programming Language :: C++", + "Programming Language :: Python :: 3", + "Topic :: Software Development :: Embedded Systems", + "Topic :: Software Development :: Libraries", +] + +[project.urls] +Homepage = "https://github.com/esp-cpp/espp" +Documentation = "https://esp-cpp.github.io/espp/" +Repository = "https://github.com/esp-cpp/espp" +Issues = "https://github.com/esp-cpp/espp/issues" + +[tool.scikit-build] +# The CMake project for the host (PC) build lives in lib/; the repo root is the +# project root so the sdist can include the components/ sources it references. +cmake.source-dir = "lib" +# Pure-python part of the package (espp/__init__.py, __init__.pyi, py.typed); +# the compiled espp._espp extension is installed into it by CMake. +wheel.packages = ["lib/python_bindings/espp"] +wheel.exclude = ["**/.mypy_cache", "**/__pycache__"] +# Persistent CMake build dir (gitignored) so repeated local builds - notably +# re-running `pip install -e .` after a C++ change - are incremental. +build-dir = "build/{wheel_tag}" +sdist.exclude = [ + ".github/", + "doc/", + "docs/", + "pc/", + "python/", + # local build helpers / outputs (lib/pc, build/, _build/ already gitignored) + "*.sh", + "*.ps1", + # embedded-only submodules and components not referenced by lib/espp.cmake + "components/littlefs/", + "components/lvgl/", + "components/esp-dsp/", + "components/esp-nimble-cpp/", + "components/binary-log/", + "components/gfps_service/", + # examples are not needed to build the bindings + "components/*/example/", + # trim the bulk of the vendored fmt repo (only include/ is used) + "components/format/detail/fmt/doc/", + "components/format/detail/fmt/test/", +] + +# Derive the version from git tags of the form vX.Y.Z (e.g. v1.1.4) via +# setuptools-scm. +[[tool.dynamic-metadata]] +provider = "scikit_build_core.metadata.setuptools_scm" + +[tool.setuptools_scm] + +[tool.cibuildwheel] +build = ["cp310-*", "cp311-*", "cp312-*", "cp313-*", "cp314-*"] +skip = ["*-musllinux_*", "*_i686", "*-win32"] +test-command = "python -c \"import espp; print('espp', espp.__version__)\"" + +[tool.cibuildwheel.linux] +# The repo is mounted at /project inside the manylinux container; git must +# trust it for setuptools-scm to read the version from the repo. +before-all = "git config --global --add safe.directory /project" + +[tool.cibuildwheel.macos.environment] +# espp uses std::filesystem (and other C++20 features) which require a newer +# deployment target than cibuildwheel's default (10.13) +MACOSX_DEPLOYMENT_TARGET = "11.0" diff --git a/python/README.md b/python/README.md index d2c806ceb..7cbdd3b27 100644 --- a/python/README.md +++ b/python/README.md @@ -77,15 +77,28 @@ This section gives a brief overview of what the scripts in this folder do. ## Setup -For all scripts, you must have the `espp` shared objects built and the required Python dependencies installed: +All scripts need the `espp` python package installed. Released versions come +from PyPI: -```bash -pip install -r requirements.txt +```console +pip install espp ``` -**Note**: The COBS demo script (`cobs_demo.py`) requires the `cobs` library for cross-validation with the reference implementation. -accessible in the `espp/lib` folder. See the [espp/lib](../lib/README.md) -README for more details. +If you're working from this repository (e.g. developing the bindings or +running the scripts against unreleased changes), use an editable install from +the repository root instead - it builds the same package from the local +sources: + +```console +pip install -e .. # from this folder (or `pip install -e .` from the repo root) +``` + +Note: after changing C++ code (components or bindings), re-run the editable +install to rebuild the extension; the build is incremental. Pure-python +changes are picked up automatically. + +**Note**: The COBS demo script (`cobs_demo.py`) additionally requires the +`cobs` library for cross-validation with the reference implementation. ### Install Python Requirements diff --git a/python/cobs_demo.py b/python/cobs_demo.py index 9445af7f1..c1516a4ca 100644 --- a/python/cobs_demo.py +++ b/python/cobs_demo.py @@ -13,7 +13,7 @@ import sys from cobs import cobs -from support_loader import espp +import espp def simple_example(): """Simple practical example""" diff --git a/python/rtps_publisher.py b/python/rtps_publisher.py index 625de3328..f412899e5 100644 --- a/python/rtps_publisher.py +++ b/python/rtps_publisher.py @@ -12,7 +12,7 @@ import sys import time -from support_loader import espp +import espp def guess_local_ipv4() -> str: diff --git a/python/rtps_pubsub.py b/python/rtps_pubsub.py index 5e6080e49..f12ab4d80 100644 --- a/python/rtps_pubsub.py +++ b/python/rtps_pubsub.py @@ -13,7 +13,7 @@ import sys import time -from support_loader import espp +import espp def guess_local_ipv4() -> str: diff --git a/python/rtps_subscriber.py b/python/rtps_subscriber.py index cc7bc639d..bbfc1b655 100644 --- a/python/rtps_subscriber.py +++ b/python/rtps_subscriber.py @@ -12,7 +12,7 @@ import sys import time -from support_loader import espp +import espp def guess_local_ipv4() -> str: diff --git a/python/rtsp_api_test.py b/python/rtsp_api_test.py index d17c7a9a1..40dc5c286 100755 --- a/python/rtsp_api_test.py +++ b/python/rtsp_api_test.py @@ -11,7 +11,7 @@ import sys import traceback -from support_loader import espp +import espp passed = 0 failed = 0 diff --git a/python/rtsp_client_multitrack.py b/python/rtsp_client_multitrack.py index d06517cdf..129f59887 100755 --- a/python/rtsp_client_multitrack.py +++ b/python/rtsp_client_multitrack.py @@ -21,7 +21,7 @@ import numpy as np from zeroconf import ServiceBrowser, ServiceListener, Zeroconf -from support_loader import espp +import espp try: import sounddevice as sd diff --git a/python/rtsp_server_multitrack.py b/python/rtsp_server_multitrack.py index dc7dbcd6b..03c91e089 100755 --- a/python/rtsp_server_multitrack.py +++ b/python/rtsp_server_multitrack.py @@ -36,7 +36,7 @@ from zeroconf import IPVersion, ServiceInfo, Zeroconf from simplejpeg import encode_jpeg -from support_loader import espp +import espp try: import sounddevice as sd diff --git a/python/support_loader.py b/python/support_loader.py deleted file mode 100644 index 143c80e74..000000000 --- a/python/support_loader.py +++ /dev/null @@ -1,12 +0,0 @@ -try: - import espp -except ImportError: - import os - import sys - dirpath = os.path.dirname(os.path.realpath(__file__)) - sys.path.append(dirpath + "/../lib/pc") - import espp -else: - print("espp imported") - -print("Imported espp from: ", espp.__file__) diff --git a/python/task.py b/python/task.py index 99ed7c5d5..fb6212733 100644 --- a/python/task.py +++ b/python/task.py @@ -1,7 +1,7 @@ import sys import time -from support_loader import espp +import espp start = time.time() def task_func(): diff --git a/python/timer.py b/python/timer.py index 38f80eec9..3fa6596e9 100644 --- a/python/timer.py +++ b/python/timer.py @@ -1,7 +1,7 @@ import sys import time -from support_loader import espp +import espp start = time.time() def timer_func(): diff --git a/python/udp_client.py b/python/udp_client.py index 4c9db51ca..59934e608 100644 --- a/python/udp_client.py +++ b/python/udp_client.py @@ -1,7 +1,7 @@ import sys import time -from support_loader import espp +import espp udp_client = espp.UdpSocket(espp.UdpSocket.Config(espp.Logger.Verbosity.debug)) diff --git a/python/udp_server.py b/python/udp_server.py index 6784fc14b..8e59d8501 100644 --- a/python/udp_server.py +++ b/python/udp_server.py @@ -1,7 +1,7 @@ import sys import time -from support_loader import espp +import espp # defined out here so that it's only initialized once, not on each callback start = time.time() From 670c961da5a9bcaa57d1816abaecff6cbcf07631 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sun, 12 Jul 2026 11:10:26 -0500 Subject: [PATCH 2/3] address comments --- .github/workflows/build_wheels.yml | 5 +++++ lib/CMakeLists.txt | 4 +++- lib/espp.cmake | 3 ++- lib/python_bindings/espp/__init__.py | 4 ++-- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build_wheels.yml b/.github/workflows/build_wheels.yml index f2ae496d7..533f39222 100644 --- a/.github/workflows/build_wheels.yml +++ b/.github/workflows/build_wheels.yml @@ -14,6 +14,11 @@ on: types: [published] workflow_dispatch: +# default for all jobs; the publish job additionally grants itself the +# id-token permission required for PyPI trusted publishing +permissions: + contents: read + jobs: build_wheels: name: Build wheels on ${{ matrix.os }} diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt index cca77cf75..dc9e19cdd 100644 --- a/lib/CMakeLists.txt +++ b/lib/CMakeLists.txt @@ -46,7 +46,9 @@ if(SKBUILD) # pure-python package files come from lib/python_bindings/espp via the # `wheel.packages` setting in pyproject.toml. espp_add_python_module() - install(TARGETS _espp LIBRARY DESTINATION espp) + install(TARGETS _espp + LIBRARY DESTINATION espp + RUNTIME DESTINATION espp) else() # Standalone build (./build.sh / ./build.ps1): build the C++ static library # and the python package, and install both into ./pc for local use. diff --git a/lib/espp.cmake b/lib/espp.cmake index eff8edea2..4d0d302d4 100644 --- a/lib/espp.cmake +++ b/lib/espp.cmake @@ -144,5 +144,6 @@ function(espp_install_python_module FOLDER) PATTERN "__pycache__" EXCLUDE PATTERN ".mypy_cache" EXCLUDE) install(TARGETS _espp - LIBRARY DESTINATION ${FOLDER}/espp/) + LIBRARY DESTINATION ${FOLDER}/espp/ + RUNTIME DESTINATION ${FOLDER}/espp/) endfunction() diff --git a/lib/python_bindings/espp/__init__.py b/lib/python_bindings/espp/__init__.py index e7bbb144e..070ae79b5 100644 --- a/lib/python_bindings/espp/__init__.py +++ b/lib/python_bindings/espp/__init__.py @@ -5,5 +5,5 @@ type stubs (``__init__.pyi``) that describe the full API. """ -from espp._espp import * # type: ignore # noqa: F403 -from espp._espp import __version__ # noqa: F401 +from ._espp import * # type: ignore # noqa: F403 +from ._espp import __version__ # noqa: F401 From b5e920251686140df7ec4e4aa32177a8ec9c50cc Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sun, 12 Jul 2026 13:22:19 -0500 Subject: [PATCH 3/3] fix issue with macos13 intel arch in github ci --- .github/workflows/build_wheels.yml | 3 +-- lib/README.md | 2 +- pyproject.toml | 6 ++++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build_wheels.yml b/.github/workflows/build_wheels.yml index 533f39222..dce044e31 100644 --- a/.github/workflows/build_wheels.yml +++ b/.github/workflows/build_wheels.yml @@ -29,8 +29,7 @@ jobs: include: - os: ubuntu-latest # linux x86_64 - os: ubuntu-24.04-arm # linux aarch64 - - os: macos-13 # macos x86_64 - - os: macos-latest # macos arm64 + - os: macos-latest # macos universal2 (arm64 + x86_64) - os: windows-latest # windows amd64 steps: diff --git a/lib/README.md b/lib/README.md index 4be1165b6..ea57f83e6 100644 --- a/lib/README.md +++ b/lib/README.md @@ -68,7 +68,7 @@ Re-run it after changing C++ code to rebuild the extension (the CMake build dir is persistent, so rebuilds are incremental); pure-python changes are picked up automatically. -Wheels for Linux (x86_64 / aarch64), macOS (x86_64 / arm64), and Windows +Wheels for Linux (x86_64 / aarch64), macOS (universal2), and Windows (amd64) are built in CI by [build_wheels.yml](../.github/workflows/build_wheels.yml) and published to PyPI on each release. diff --git a/pyproject.toml b/pyproject.toml index 1bf312f7d..5edf53b7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,12 @@ test-command = "python -c \"import espp; print('espp', espp.__version__)\"" # trust it for setuptools-scm to read the version from the repo. before-all = "git config --global --add safe.directory /project" +[tool.cibuildwheel.macos] +# Build fat wheels covering both apple silicon and intel from the single +# arm64 runner (GitHub retired the intel macos-13 runners). The arm64 slice +# is tested natively; the x86_64 slice runs under Rosetta where available. +archs = ["universal2"] + [tool.cibuildwheel.macos.environment] # espp uses std::filesystem (and other C++20 features) which require a newer # deployment target than cibuildwheel's default (10.13)