diff --git a/.gitignore b/.gitignore index 50fec285af..5026268983 100755 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,11 @@ venv/ *.vscode_debug_path/ lib/* +# Headers staged by sdk/tools/sync_headers.py (duplicates of src/) +sdk/src/bsk_sdk/include/Basilisk/architecture/_GeneralModuleFiles/ +sdk/src/bsk_sdk/include/Basilisk/architecture/messaging/ +sdk/src/bsk_sdk/include/Basilisk/architecture/utilities/ + # Python packaging *.egg-info build/ diff --git a/docs/source/Support/Developer.rst b/docs/source/Support/Developer.rst index b1720eee3b..b5667aeea3 100644 --- a/docs/source/Support/Developer.rst +++ b/docs/source/Support/Developer.rst @@ -16,5 +16,6 @@ The following support files help with writing Basilisk modules. Developer/createHtmlDocumentation Developer/bskModuleCheckoutList Developer/UnderstandingBasilisk + Developer/bskSdkV1 Developer/migratingBskModuleToBsk2 Developer/MigratingToPython3 diff --git a/docs/source/Support/Developer/bskSdkV1.rst b/docs/source/Support/Developer/bskSdkV1.rst new file mode 100644 index 0000000000..785aa6e308 --- /dev/null +++ b/docs/source/Support/Developer/bskSdkV1.rst @@ -0,0 +1,162 @@ +Basilisk SDK Version 1 +====================== + +.. contents:: Outline + :local: + +Purpose +------- + +The Basilisk SDK (``bsk-sdk``) defines the public surface that external plugin +authors can rely on when integrating new simulation capabilities with the +core runtime. Version 1 focuses on establishing a stable contract for Python +and C++ plugin authors and capturing the minimal tooling that ships inside the +Basilisk source tree. + +Scope and Deliverables +---------------------- + +Version 1 guarantees the following artifacts: + +- ``bsk_core.plugins``: the runtime registry responsible for discovering + entry-point advertised plugins and exposing them under ``Basilisk.modules``. +- ``bsk-sdk``: a small Python package that publishes the SDK headers, declares a + dependency on the ``pybind11`` headers required by the helper macros, and + provides :func:`bsk_sdk.include_dir` / :func:`bsk_sdk.include_dirs` helpers for + build scripts. +- A companion ``sync_headers.py`` utility (``sdk/tools``) keeps the vendored + Basilisk ``architecture`` headers in sync with the main source tree. +- ``sdk/include/bsk/plugin_sdk.hpp``: a single header that wraps the pybind11 + boilerplate required for C++ factories and enforces the default constructible + + ``Reset``/``UpdateState`` interface contract. The same header is shipped by + :mod:`bsk-sdk`. +- A consolidated ``plugins`` example package containing both Python and C++ + implementations that demonstrate the expected packaging and registration + patterns. + +Any other files in the repository are explicitly *not* part of the SDK +agreement for this release. + +Plugin Registry API +------------------- + +The ``bsk_core.plugins.PluginRegistry`` class is the primary integration +point for third-party plugins. The registry is responsible for staging plugin +definitions until the runtime exports them under ``Basilisk.modules``. + +The public methods guaranteed in v1 are: + +.. code-block:: python + + class PluginRegistry: + def register_python_module(self, name: str, cls: type[sysModel.SysModel]) -> None: ... + def register_factory(self, name: str, factory: Any) -> None: ... + +``register_python_module`` accepts any subclass of +``Basilisk.architecture.sysModel.SysModel`` and exposes it as a class on +``Basilisk.modules`` using the provided name. ``register_factory`` stores an +opaque object under the supplied name. Factories are expected to be callables +returning Basilisk-compatible module instances, but v1 defers any runtime shape +validation to keep the surface area small. + +Plugins must advertise a ``register(registry)`` callable through the +``basilisk.plugins`` entry-point group. During startup Basilisk resolves the +entry-point, imports the containing module, and invokes the callable with the +shared registry instance. + +Python Plugin Pattern +--------------------- + +Pure-Python plugins should follow the pattern demonstrated in +``plugins/src/python/Basilisk/ExternalModules/customPythonModule.py``: + +.. code-block:: python + + from Basilisk.architecture import sysModel + + class ExamplePluginModule(sysModel.SysModel): + def Reset(self, current_sim_nanos): + ... + + def UpdateState(self, current_sim_nanos, call_time): + ... + + def register(registry): + registry.register_python_module("ExamplePluginModule", ExamplePluginModule) + +The distribution's ``pyproject.toml`` must expose the ``register`` function via + +.. code-block:: toml + + [project.entry-points."basilisk.plugins"] + example = "bsk_example_plugin.simple:register" + +At runtime users import the module from ``Basilisk.modules``: + +.. code-block:: python + + from Basilisk import modules + + plugin_cls = modules.ExamplePluginModule + instance = plugin_cls() + instance.Reset(0) + instance.UpdateState(0, 0) + +C++ Plugin Pattern +------------------ + +Native extensions should include ``sdk/include/bsk/plugin_sdk.hpp`` to inherit +the pybind11 binding helpers. When building outside the Basilisk source tree +the :mod:`bsk-sdk` package exposes the headers via +``import bsk_sdk; bsk_sdk.include_dir()`` (or ``include_dirs()`` to also capture +the ``Basilisk`` subdirectory and ``pybind11`` include path). Version 1 +guarantees the availability of: + +- ``bsk::plugin::register_basic_plugin`` +- ``BSK_PLUGIN_PYBIND_MODULE`` + +The ``BSK_PLUGIN_PYBIND_MODULE`` macro defines both the pybind11 module and the +``create_factory`` callable consumed by the Basilisk runtime. The expected class +contract mirrors the Python case: default constructible with ``Reset`` and +``UpdateState`` methods. + +.. code-block:: cpp + + #include + + class ExampleCppModule { + public: + void Reset(double current_sim_nanos); + void UpdateState(double current_sim_nanos, double call_time); + }; + + BSK_PLUGIN_PYBIND_MODULE(_example_cpp, ExampleCppModule, "ExampleCppModule"); + +The companion Python package should lazily import the extension, extract the +factory, and register it: + +.. code-block:: python + + from importlib import import_module + + def register(registry): + ext = import_module("bsk_example_plugin_cpp._example_cpp") + factory = ext.create_factory() + registry.register_factory("ExampleCppFactory", factory) + +Limitations and Future Work +--------------------------- + +Version 1 intentionally leaves several items out of scope so they can be +designed with real-world feedback: + +- The SDK header is distributed from the Basilisk source tree and is not + published as a standalone artifact. +- Factories registered via ``register_factory`` are treated as opaque callables; + Basilisk does not verify their type or interface beyond name collisions. +- The helper header requires C++17 and a compatible pybind11 toolchain. +- Plugin lifecycle hooks beyond ``Reset``/``UpdateState`` will be designed as + future Basilisk modules adopt richer interfaces. + +Feedback on these gaps is welcome and will inform the roadmap for subsequent +SDK revisions. diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt new file mode 100644 index 0000000000..8369fcd838 --- /dev/null +++ b/plugins/CMakeLists.txt @@ -0,0 +1,65 @@ +cmake_minimum_required(VERSION 3.18) +project(bsk_external LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +find_package(Python3 COMPONENTS Interpreter Development.Module REQUIRED) + +execute_process( + COMMAND "${Python3_EXECUTABLE}" -c "import bsk_sdk; print('\\n'.join(bsk_sdk.include_dirs()), end='')" + OUTPUT_VARIABLE BSK_SDK_INCLUDE_OUTPUT + RESULT_VARIABLE BSK_SDK_RESULT +) +if(NOT BSK_SDK_RESULT EQUAL 0) + message(FATAL_ERROR "Failed to locate bsk-sdk include directories. Is the package installed?") +endif() +string(REPLACE "\r" "" BSK_SDK_INCLUDE_OUTPUT "${BSK_SDK_INCLUDE_OUTPUT}") +string(STRIP "${BSK_SDK_INCLUDE_OUTPUT}" BSK_SDK_INCLUDE_OUTPUT) +string(REPLACE "\n" ";" BSK_SDK_INCLUDE_DIRS "${BSK_SDK_INCLUDE_OUTPUT}") +list(GET BSK_SDK_INCLUDE_DIRS 1 BSK_SDK_BASILISK_INCLUDE) + +Python3_add_library(_custom_cpp MODULE ExternalModules/CustomCppModule/custom_cpp_module.cpp WITH_SOABI) +target_compile_features(_custom_cpp PRIVATE cxx_std_17) +target_include_directories( + _custom_cpp + PRIVATE + ${BSK_SDK_INCLUDE_DIRS} +) +target_link_libraries(_custom_cpp PRIVATE Python3::Module) + +set(BSK_ARCHITECTURE_SOURCES + "${BSK_SDK_BASILISK_INCLUDE}/architecture/_GeneralModuleFiles/sys_model.cpp" + "${BSK_SDK_BASILISK_INCLUDE}/architecture/utilities/bskLogging.cpp" + "${BSK_SDK_BASILISK_INCLUDE}/architecture/utilities/moduleIdGenerator/moduleIdGenerator.cpp" +) + +target_sources(_custom_cpp PRIVATE ${BSK_ARCHITECTURE_SOURCES}) + +install( + TARGETS _custom_cpp + DESTINATION Basilisk/ExternalModules + COMPONENT extensions +) + +install( + DIRECTORY src/python/ + DESTINATION . + COMPONENT python +) + +install( + DIRECTORY msgPayloadDefC msgPayloadDefCpp + DESTINATION Basilisk + COMPONENT python +) + +if(APPLE) + # 1) Don't hide symbols for this target (avoid losing PyInit export) + set_target_properties(_custom_cpp PROPERTIES + C_VISIBILITY_PRESET default + CXX_VISIBILITY_PRESET default + VISIBILITY_INLINES_HIDDEN OFF + ) + +endif() diff --git a/plugins/ExternalModules/CustomCppModule/custom_cpp_module.cpp b/plugins/ExternalModules/CustomCppModule/custom_cpp_module.cpp new file mode 100644 index 0000000000..5546cef86c --- /dev/null +++ b/plugins/ExternalModules/CustomCppModule/custom_cpp_module.cpp @@ -0,0 +1,116 @@ +#include +#include +#include +#include + +#include +#include + +#include +#include +#include + +namespace { + +struct CustomPluginMsgPayload +{ + std::array dataVector{}; +}; + +class CustomCppModule : public SysModel +{ + public: + CustomCppModule() + : output_writer_(dataOutMsg.addAuthor()) + , input_reader_(input_channel_.addSubscriber()) + , input_writer_(input_channel_.addAuthor()) + { + this->ModelTag = "CustomCppModule"; + } + + void Reset(uint64_t /*current_sim_nanos*/) override + { + reset_called_ = true; + update_called_ = false; + steps_ = 0; + last_input_ = {}; + last_output_ = {}; + last_update_nanos_ = 0; + } + + void UpdateState(uint64_t current_sim_nanos) override + { + update_called_ = true; + ++steps_; + + if (input_reader_.isLinked() && input_reader_.isWritten()) { + last_input_ = input_reader_(); + } + + last_output_ = last_input_; + last_output_.dataVector[0] += static_cast(steps_); + last_output_.dataVector[2] = static_cast(current_sim_nanos) * 1e-9; + + output_writer_(&last_output_, this->moduleID, current_sim_nanos); + last_update_nanos_ = current_sim_nanos; + } + + void set_input_payload(CustomPluginMsgPayload payload) + { + // WriteFunctor wants a non-const pointer; payload is local, so OK. + input_writer_(&payload, this->moduleID, last_update_nanos_); + } + + CustomPluginMsgPayload last_input() const { return last_input_; } + CustomPluginMsgPayload last_output() const { return last_output_; } + uint64_t last_update_nanos() const { return last_update_nanos_; } + bool reset_called() const { return reset_called_; } + bool update_called() const { return update_called_; } + + private: + Message dataOutMsg; + Message input_channel_; + + WriteFunctor output_writer_; + ReadFunctor input_reader_; + WriteFunctor input_writer_; + + CustomPluginMsgPayload last_input_{}; + CustomPluginMsgPayload last_output_{}; + + uint64_t last_update_nanos_ = 0; + bool reset_called_ = false; + bool update_called_ = false; + int steps_ = 0; +}; + +} // namespace + +PYBIND11_MODULE(_custom_cpp, m) +{ + namespace py = pybind11; + + py::class_(m, "CustomPluginMsgPayload") + .def(py::init<>()) + .def(py::init([](const std::vector& values) { + CustomPluginMsgPayload payload; + for (std::size_t i = 0; i < std::min(values.size(), payload.dataVector.size()); ++i) { + payload.dataVector[i] = values[i]; + } + return payload; + })) + .def_readwrite("dataVector", &CustomPluginMsgPayload::dataVector); + + py::class_(m, "CustomCppModule") + .def(py::init<>()) + .def("Reset", &CustomCppModule::Reset, py::arg("current_sim_nanos")) + .def("UpdateState", &CustomCppModule::UpdateState, py::arg("current_sim_nanos")) + .def("set_input_payload", &CustomCppModule::set_input_payload, py::arg("payload")) + .def_property_readonly("last_input", &CustomCppModule::last_input) + .def_property_readonly("last_output", &CustomCppModule::last_output) + .def_property_readonly("last_update_nanos", &CustomCppModule::last_update_nanos) + .def_property_readonly("reset_called", &CustomCppModule::reset_called) + .def_property_readonly("update_called", &CustomCppModule::update_called); + + m.def("create_factory", []() { return bsk::plugin::make_factory(); }); +} diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 0000000000..81d552a7ea --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,63 @@ +# Basilisk Plugin Examples + +This package demonstrates how to distribute custom Basilisk modules as a +standalone plugin powered by the :mod:`bsk-sdk`. It contains both Python and +C++ examples and registers them with the Basilisk runtime through the plugin +entry-point system. + +## Building + +```bash +pip install -e ./sdk # install the published SDK locally +pip install scikit-build-core +pip install -e ./plugins --no-build-isolation +``` + +Requirements (beyond the Basilisk runtime): + +- Python 3.8+ +- A C++17 compiler +- ``bsk-sdk`` (published via ``./sdk`` in this repo for local development) +- ``pybind11`` (installed automatically via the ``bsk-sdk`` dependency) +- ``scikit-build-core`` (build tooling) + +## Usage + +After installation the plugin is discovered automatically: + +```python +from Basilisk import modules + +cpp_factory = modules.CustomCppModule +instance = cpp_factory() +instance.Reset(0) +instance.UpdateState(0, 0) +``` + +The plugin also exposes a pure Python module: + +```python +from Basilisk import modules + +python_cls = modules.CustomPythonModule +module = python_cls() +module.Reset(0) +module.UpdateState(0, 0) +``` + +The C++ factory mirrors Basilisk's ``SysModel`` concept and exposes a plugin +specific message payload: + +```python +from Basilisk import modules +from Basilisk.ExternalModules import customCppModule + +factory = modules.CustomCppModule +instance = factory() +payload = customCppModule.CustomPluginMsgPayload([1.0, 0.0, 0.0]) +instance.set_input_payload(payload) +instance.Reset(0) +instance.UpdateState(1_000_000_000) +print(instance.last_output().dataVector) +``` +``` diff --git a/plugins/msgPayloadDefC/CustomModuleMsgPayload.h b/plugins/msgPayloadDefC/CustomModuleMsgPayload.h new file mode 100644 index 0000000000..152f91f6a7 --- /dev/null +++ b/plugins/msgPayloadDefC/CustomModuleMsgPayload.h @@ -0,0 +1,31 @@ +/* + ISC License + + Copyright (c) 2016, Autonomous Vehicle Systems Lab, University of Colorado at Boulder + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + */ + +#ifndef _CUSTOM_MODULE_OUT_H_ +#define _CUSTOM_MODULE_OUT_H_ + + +/*! @brief Structure used to define the output of the sub-module. This is the same + output message that is used by all sub-modules in the module folder. */ +typedef struct { + double dataVector[3]; //!< [units] sample output vector +}CustomModuleMsgPayload; + + +#endif diff --git a/plugins/msgPayloadDefCpp/CustomModuleCppMsgPayload.h b/plugins/msgPayloadDefCpp/CustomModuleCppMsgPayload.h new file mode 100644 index 0000000000..7f11ea7560 --- /dev/null +++ b/plugins/msgPayloadDefCpp/CustomModuleCppMsgPayload.h @@ -0,0 +1,31 @@ +/* + ISC License + + Copyright (c) 2016, Autonomous Vehicle Systems Lab, University of Colorado at Boulder + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + */ + +#ifndef _CUSTOM_MODULE_CPP_OUT_H_ +#define _CUSTOM_MODULE_CPP_OUT_H_ + + +/*! @brief Structure used to define the output of the sub-module. This is the same + output message that is used by all sub-modules in the module folder. */ +typedef struct { + double dataVector[3]; //!< [units] sample output vector +}CustomModuleCppMsgPayload; + + +#endif diff --git a/plugins/pyproject.toml b/plugins/pyproject.toml new file mode 100644 index 0000000000..53d9cb7a94 --- /dev/null +++ b/plugins/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["scikit-build-core>=0.9.3", "bsk-sdk>=1.0.0"] +build-backend = "scikit_build_core.build" + +[project] +name = "bsk-external" +version = "0.1.0" +description = "Standalone Basilisk plugin housing example external modules." +readme = "README.md" +requires-python = ">=3.8" +license = { text = "ISC" } +authors = [{ name = "Basilisk Developers" }] +dependencies = ["bsk"] + +[project.entry-points."basilisk.plugins"] +external = "bsk_external_plugin.register:register" + +[tool.scikit-build] +wheel.py-api = "cp38" + +[tool.scikit-build.cmake] +version = ">=3.18" + +[tool.scikit-build.install] +components = ["python", "extensions"] diff --git a/plugins/src/python/Basilisk/ExternalModules/__init__.py b/plugins/src/python/Basilisk/ExternalModules/__init__.py new file mode 100644 index 0000000000..aa2986288d --- /dev/null +++ b/plugins/src/python/Basilisk/ExternalModules/__init__.py @@ -0,0 +1,13 @@ +""" +External Basilisk modules distributed as a standalone plugin. + +The package mirrors Basilisk's legacy layout by exposing each module as a child +module so imports such as ``from Basilisk.ExternalModules import customCppModule`` +continue to work. +""" + +from __future__ import annotations + +from . import customCppModule, customPythonModule + +__all__ = ["customCppModule", "customPythonModule"] diff --git a/plugins/src/python/Basilisk/ExternalModules/customCppModule.py b/plugins/src/python/Basilisk/ExternalModules/customCppModule.py new file mode 100644 index 0000000000..74c3d294b4 --- /dev/null +++ b/plugins/src/python/Basilisk/ExternalModules/customCppModule.py @@ -0,0 +1,23 @@ +""" +Python helpers exposing the C++ CustomCppModule to Basilisk users. +""" + +from __future__ import annotations + +from importlib import import_module + +_extension = import_module("Basilisk.ExternalModules._custom_cpp") + +CustomCppModule = _extension.CustomCppModule +CustomPluginMsgPayload = _extension.CustomPluginMsgPayload + + +def customCppModule(): + """ + Backwards compatible factory returning a new :class:`CustomCppModule`. + """ + + return CustomCppModule() + + +__all__ = ["CustomCppModule", "CustomPluginMsgPayload", "customCppModule"] diff --git a/plugins/src/python/Basilisk/ExternalModules/customPythonModule.py b/plugins/src/python/Basilisk/ExternalModules/customPythonModule.py new file mode 100644 index 0000000000..19aead1369 --- /dev/null +++ b/plugins/src/python/Basilisk/ExternalModules/customPythonModule.py @@ -0,0 +1,46 @@ +""" +Pure Python example module that mimics the basic Basilisk SysModel contract. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List + + +@dataclass +class CustomPythonModule: + """ + Simple stateful example of a Basilisk-like module implemented in Python. + """ + + dummy: float = 0.0 + input_vector: List[float] = field(default_factory=lambda: [0.0, 0.0, 0.0]) + data_vector: List[float] = field(default_factory=lambda: [0.0, 0.0, 0.0]) + reset_called: bool = False + update_called: bool = False + + def Reset(self, current_sim_nanos: float) -> None: # noqa: N802 - Basilisk naming convention + del current_sim_nanos + self.reset_called = True + self.dummy = 0.0 + self.data_vector = [0.0, 0.0, 0.0] + + def UpdateState(self, current_sim_nanos: float, call_time: float) -> None: # noqa: N802 + del current_sim_nanos + self.update_called = True + self.dummy += 1.0 + self.data_vector = [ + self.dummy + self.input_vector[0], + self.input_vector[1], + call_time, + ] + + +def customPythonModule() -> CustomPythonModule: + """Backwards compatible constructor for the module.""" + + return CustomPythonModule() + + +__all__ = ["CustomPythonModule", "customPythonModule"] diff --git a/plugins/src/python/bsk_external_plugin/__init__.py b/plugins/src/python/bsk_external_plugin/__init__.py new file mode 100644 index 0000000000..41727dae3a --- /dev/null +++ b/plugins/src/python/bsk_external_plugin/__init__.py @@ -0,0 +1,3 @@ +"""Namespace package for the Basilisk External plugin.""" + +__all__ = ["register"] diff --git a/plugins/src/python/bsk_external_plugin/register.py b/plugins/src/python/bsk_external_plugin/register.py new file mode 100644 index 0000000000..b8eb73dd23 --- /dev/null +++ b/plugins/src/python/bsk_external_plugin/register.py @@ -0,0 +1,55 @@ +""" +Entry point for the Basilisk External plugin. + +This module hooks the packaged custom modules into the Basilisk runtime through +``bsk_core.plugins``. +""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any, Callable + +if TYPE_CHECKING: # pragma: no cover - typing only + from bsk_core.plugins import PluginRegistry + + +def _load_cpp_extension() -> Any: + try: + return import_module("Basilisk.ExternalModules._custom_cpp") + except ModuleNotFoundError as exc: # pragma: no cover - build/runtime issue + raise ImportError( + "Unable to import the Basilisk External C++ extension. " + "Ensure the package was built with scikit-build-core." + ) from exc + + +def _register_cpp_factory(registry: "PluginRegistry") -> None: + extension = _load_cpp_extension() + if hasattr(extension, "register_payloads"): + extension.register_payloads() + + factory = extension.create_factory() + + registry.register_factory("CustomCppModule", factory) + registry.register_factory("customCppModule", factory) + + +def _register_python_module(registry: "PluginRegistry") -> None: + module = import_module("Basilisk.ExternalModules.customPythonModule") + + def factory(): + return module.CustomPythonModule() + + registry.register_factory("CustomPythonModule", factory) + registry.register_factory("customPythonModule", factory) + + +def register(registry: "PluginRegistry") -> None: + """Register all external modules with the Basilisk runtime.""" + + _register_cpp_factory(registry) + _register_python_module(registry) + + +__all__ = ["register"] diff --git a/plugins/tests/test_external_plugin.py b/plugins/tests/test_external_plugin.py new file mode 100644 index 0000000000..f0d03df37e --- /dev/null +++ b/plugins/tests/test_external_plugin.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import sys +from pathlib import Path +import importlib +import types + +import pytest + +# Ensure the Basilisk sources under development are importable when running the +# tests directly from the repository. +REPO_ROOT = Path(__file__).resolve().parents[2] +SRC_ROOT = REPO_ROOT / "src" +if SRC_ROOT.exists(): + sys.path.insert(0, str(SRC_ROOT)) + +from bsk_core.plugins import PluginRegistry + +from bsk_external_plugin.register import register + + +@pytest.fixture() +def registry(): + return PluginRegistry() + + +@pytest.fixture(autouse=True) +def plugin_sys_path(monkeypatch): + test_root = Path(__file__).resolve().parents[2] + sdk_python = test_root / "src" + plugin_src = test_root / "plugins" / "src" / "python" + monkeypatch.syspath_prepend(str(sdk_python)) + monkeypatch.syspath_prepend(str(plugin_src)) + + +@pytest.fixture(autouse=True) +def stub_cpp_extension(monkeypatch): + class StubPayload: + def __init__(self, values=None): + self.dataVector = list(values or [0.0, 0.0, 0.0]) + + class StubCppModule: + def __init__(self): + self.reset_called = False + self.update_called = False + self._steps = 0 + self._input = StubPayload() + self._output = StubPayload() + self._last_update = 0 + + def Reset(self, current_sim_nanos): + del current_sim_nanos + self.reset_called = True + self.update_called = False + self._steps = 0 + self._output = StubPayload() + self._last_update = 0 + + def UpdateState(self, current_sim_nanos): + self.update_called = True + self._steps += 1 + vec = self._input.dataVector + self._output = StubPayload( + [vec[0] + float(self._steps), vec[1], float(current_sim_nanos) * 1e-9] + ) + self._last_update = current_sim_nanos + + def set_input_payload(self, payload): + self._input = payload + + def last_input(self): + return self._input + + def last_output(self): + return self._output + + def last_update_nanos(self): + return self._last_update + + class StubModule: + CustomCppModule = StubCppModule + CustomPluginMsgPayload = StubPayload + + @staticmethod + def create_factory(): + def factory(): + return StubCppModule() + + return factory + + monkeypatch.setitem(sys.modules, "Basilisk.ExternalModules._custom_cpp", StubModule) + + +def test_registers_python_and_cpp_modules(registry): + register(registry) + + assert "CustomCppModule" in registry.factories + assert "customCppModule" in registry.factories + assert "CustomPythonModule" in registry.factories + assert "customPythonModule" in registry.factories + + +def test_cpp_factory_behaves_like_module(registry): + register(registry) + + factory = registry.factories["CustomCppModule"] + instance = factory() + + assert instance.reset_called is False + assert instance.update_called is False + + instance.Reset(0) + assert instance.reset_called is True + + from Basilisk.ExternalModules import customCppModule as cpp_mod + + payload = cpp_mod.CustomPluginMsgPayload([1.0, -0.5, 0.7]) + instance.set_input_payload(payload) + instance.UpdateState(1_500_000_000) + assert instance.update_called is True + assert instance.last_input().dataVector == [1.0, -0.5, 0.7] + assert instance.last_output().dataVector == pytest.approx([2.0, -0.5, 1.5]) + + +def test_python_module_behaves_like_module(registry): + register(registry) + + factory = registry.factories["CustomPythonModule"] + instance = factory() + + instance.input_vector = [0.1, 0.2, 0.3] + instance.Reset(0.0) + assert instance.reset_called is True + + instance.UpdateState(0.0, 2.0) + assert instance.update_called is True + assert instance.dummy == pytest.approx(1.0) + assert instance.data_vector == pytest.approx([1.1, 0.2, 2.0]) + + +def test_legacy_wrappers_expose_modules(): + # Ensure the wrappers can be imported even before the plugin is registered. + from Basilisk.ExternalModules import customCppModule, customPythonModule + + cpp_instance = customCppModule.customCppModule() + assert isinstance(cpp_instance, customCppModule.CustomCppModule) + payload = customCppModule.CustomPluginMsgPayload() + assert payload.dataVector == [0.0, 0.0, 0.0] + + py_instance = customPythonModule.customPythonModule() + assert isinstance(py_instance, customPythonModule.CustomPythonModule) + + +def test_documented_quickstart(monkeypatch, stub_cpp_extension): + """Demonstrate the user-facing workflow for loading the plugin.""" + + from bsk_core import plugins + + entry_point = types.SimpleNamespace(load=lambda: register) + monkeypatch.setattr(plugins, "_iter_plugin_entry_points", lambda: [entry_point]) + monkeypatch.setattr(plugins, "GLOBAL_REGISTRY", plugins.PluginRegistry()) + monkeypatch.setattr(plugins, "_PLUGINS_LOADED", False) + + modules_pkg = importlib.import_module("Basilisk.modules") + modules_pkg = importlib.reload(modules_pkg) + + from Basilisk.ExternalModules import customCppModule, customPythonModule + + cpp_factory = getattr(modules_pkg, "CustomCppModule") + cpp_instance = cpp_factory() + assert cpp_instance.reset_called is False + + payload = customCppModule.CustomPluginMsgPayload([1.0, -0.5, 0.0]) + cpp_instance.set_input_payload(payload) + cpp_instance.Reset(0) + cpp_instance.UpdateState(500_000_000) + assert cpp_instance.last_input().dataVector == [1.0, -0.5, 0.0] + assert cpp_instance.last_output().dataVector == pytest.approx([2.0, -0.5, 0.5]) + + py_factory = getattr(modules_pkg, "CustomPythonModule") + py_instance = py_factory() + py_instance.input_vector = [0.25, 1.0, 0.0] + py_instance.Reset(0.0) + py_instance.UpdateState(0.0, 3.0) + assert py_instance.data_vector == pytest.approx([1.25, 1.0, 3.0]) diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 0000000000..30cb6671e6 --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,12 @@ +# Basilisk SDK + +This package publishes the header-only Basilisk plugin SDK so that external +projects can build Basilisk-compatible plugins without vendoring the full +simulation codebase. It currently ships the ``bsk/plugin_sdk.hpp`` helper and a +curated subset of Basilisk's ``architecture`` headers. The package depends on +``pybind11`` and offers convenience accessors, +:func:`bsk_sdk.include_dir` and :func:`bsk_sdk.include_dirs`, for build systems. + +To refresh the vendored Basilisk headers from the source tree run:: + + python sdk/tools/sync_headers.py diff --git a/sdk/__init__.py b/sdk/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sdk/include/bsk/plugin_sdk.hpp b/sdk/include/bsk/plugin_sdk.hpp new file mode 100644 index 0000000000..cfc73b1cf1 --- /dev/null +++ b/sdk/include/bsk/plugin_sdk.hpp @@ -0,0 +1,193 @@ +#pragma once + +/** + * Minimal helper utilities for writing Basilisk C++ plugins. + * + * This header wraps a small amount of pybind11 boilerplate so that + * plugin authors can focus on module logic, not binding details. + * + * Design goals: + * - No Basilisk internals required in plugins + * - No manual message registration by plugin authors (just use macro) + * - Safe default behavior + * - Forward-compatible with future SDK expansion + * + * Usage: + * + * #include + * + * struct MyPayload { ... }; + * + * class MyCppModule { + * public: + * void Reset(uint64_t t); + * void UpdateState(uint64_t t); + * }; + * + * PYBIND11_MODULE(_my_module, m) { + * BSK_PLUGIN_REGISTER_PAYLOAD(MyPayload); + * auto cls = bsk::plugin::bind_module(m, "MyCppModule"); + * m.def("create_factory", [](){ return bsk::plugin::make_factory(); }); + * } + */ + +#include +#include +#include +#include +#include + +#include +#include + +namespace bsk::plugin { + +namespace detail { + +// ---------- compile-time detection helpers ---------- + +template +struct has_reset : std::false_type +{}; + +template +struct has_reset().Reset(std::declval()))>> : std::true_type +{}; + +template +struct has_update_state : std::false_type +{}; + +template +struct has_update_state().UpdateState(std::declval()))>> + : std::true_type +{}; + +template +struct has_reset_flag : std::false_type +{}; + +template +struct has_reset_flag().reset_called())>> : std::true_type +{}; + +template +struct has_update_flag : std::false_type +{}; + +template +struct has_update_flag().update_called())>> : std::true_type +{}; + +} // namespace detail + +// ---------- factory helpers ---------- + +template +pybind11::cpp_function +make_factory() +{ + static_assert(std::is_default_constructible_v, "Basilisk plugin modules must be default constructible"); + return pybind11::cpp_function([]() { return Module(); }); +} + +// ---------- class binding ---------- + +template +pybind11::class_ +bind_module(pybind11::module_& m, const char* python_name) +{ + auto cls = pybind11::class_(m, python_name); + + if constexpr (std::is_default_constructible_v) { + cls.def(pybind11::init<>()); + } + + if constexpr (detail::has_reset::value) { + cls.def("Reset", &Module::Reset); + } + + if constexpr (detail::has_update_state::value) { + cls.def("UpdateState", &Module::UpdateState); + } + + if constexpr (detail::has_reset_flag::value) { + cls.def_property_readonly("reset_called", &Module::reset_called); + } + + if constexpr (detail::has_update_flag::value) { + cls.def_property_readonly("update_called", &Module::update_called); + } + + return cls; +} + +// ---------- high-level registration ---------- + +template +inline void +register_basic_plugin(pybind11::module_& m, const char* class_name) +{ + bind_module(m, class_name); + + // Required entry point consumed by Basilisk runtime + m.def("create_factory", []() { return make_factory(); }); +} + +} // namespace bsk::plugin + +// ---------- stable C ABI from Basilisk core (plugins link to this) ---------- +// +// IMPORTANT: +// - This symbol must exist in the running Basilisk Python package / dylibs. +// - Plugins just declare it and call it. +// - It must be exported from Basilisk core on macOS. +// +extern "C" int +bsk_msg_register_payload_type_ex(const char* type_name, + unsigned long payload_size_bytes, + unsigned long payload_align_bytes, + unsigned int version, + unsigned long long schema_hash); + +namespace bsk::plugin { + +// Simple payload registrar used by macros below. +inline void +register_payload_or_throw(const char* name, + unsigned long size, + unsigned long align, + unsigned int version = 1u, + unsigned long long schema_hash = 0ull) +{ + const int rc = bsk_msg_register_payload_type_ex(name, size, align, version, schema_hash); + if (rc != 0) { + throw std::runtime_error(std::string("Failed to register payload type: ") + name); + } +} + +} // namespace bsk::plugin + +// ---------- payload registration macros ---------- +// +// IMPORTANT: +// Use the TYPE form in plugins: +// +// BSK_PLUGIN_REGISTER_PAYLOAD(MyPayload); +// +// This is robust and produces the desired registered name "MyPayload". +// +// If you *really* want to pass an expression, you must provide the name explicitly: +// +// BSK_PLUGIN_REGISTER_PAYLOAD_NAMED("MyPayload", MyPayload{}); +// +#define BSK_PLUGIN_REGISTER_PAYLOAD(PayloadType) \ + ::bsk::plugin::register_payload_or_throw( \ + #PayloadType, (unsigned long)sizeof(PayloadType), (unsigned long)alignof(PayloadType), 1u, 0ull) + +#define BSK_PLUGIN_REGISTER_PAYLOAD_NAMED(type_name_cstr, payload_expr) \ + ::bsk::plugin::register_payload_or_throw((type_name_cstr), \ + (unsigned long)sizeof(decltype(payload_expr)), \ + (unsigned long)alignof(decltype(payload_expr)), \ + 1u, \ + 0ull) diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml new file mode 100644 index 0000000000..386f7bb876 --- /dev/null +++ b/sdk/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["setuptools>=70"] +build-backend = "setuptools.build_meta" + +[project] +name = "bsk-sdk" +version = "1.0.0" +description = "Header-only SDK for developing Basilisk plugins." +readme = "README.md" +requires-python = ">=3.8" +license = { text = "ISC" } +authors = [{ name = "Basilisk Developers" }] +dependencies = ["pybind11>=2.12"] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: ISC License (ISCL)", + "Operating System :: OS Independent", +] + +[project.urls] +homepage = "https://avslab.github.io/basilisk/" +source = "https://github.com/AVSLab/basilisk" +tracker = "https://github.com/AVSLab/basilisk/issues" + +[tool.setuptools] +package-dir = {"" = "src"} +packages = ["bsk_sdk"] +include-package-data = true + +[tool.setuptools.package-data] +"bsk_sdk" = ["include/**/*"] diff --git a/sdk/src/bsk_sdk/__init__.py b/sdk/src/bsk_sdk/__init__.py new file mode 100644 index 0000000000..f87d35ab78 --- /dev/null +++ b/sdk/src/bsk_sdk/__init__.py @@ -0,0 +1,31 @@ +""" +Utilities for locating the Basilisk SDK headers within a Python environment. + +The package installs the header-only SDK under ``include/bsk``. Plugin build +systems can use :func:`include_dir` to configure their compiler include paths. +""" + +from __future__ import annotations + +from importlib import resources +from pathlib import Path + +try: + import pybind11 +except ImportError as exc: # pragma: no cover - defensive guard for misconfigured envs + raise ImportError( + "pybind11 is required to build Basilisk plugins; install it alongside bsk-sdk." + ) from exc + +def include_dir() -> str: + """Return the absolute path to the installed SDK include directory.""" + return str(resources.files(__package__) / "include") + + +def include_dirs() -> list[str]: + """Return include directories required to build against the SDK.""" + root = Path(include_dir()) + return [str(root), str(root / "Basilisk"), pybind11.get_include()] + + +__all__ = ["include_dir", "include_dirs"] diff --git a/sdk/src/bsk_sdk/include/bsk/plugin_sdk.hpp b/sdk/src/bsk_sdk/include/bsk/plugin_sdk.hpp new file mode 100644 index 0000000000..6aeaad1249 --- /dev/null +++ b/sdk/src/bsk_sdk/include/bsk/plugin_sdk.hpp @@ -0,0 +1,114 @@ +#pragma once + +/** + * Minimal helper utilities for writing Basilisk C++ plugins. + * + * This header intentionally wraps a tiny amount of pybind11 boilerplate so that + * plugin authors can focus on the C++ implementation while Basilisk grows a + * richer native SDK. The helpers assume that the plugin exposes a default + * constructible module type with ``Reset`` and ``UpdateState`` methods. + * + * Usage: + * + * .. code-block:: cpp + * + * #include + * + * class MyCppModule { ... }; + * + * BSK_PLUGIN_PYBIND_MODULE(_my_module, MyCppModule, "MyCppModule"); + * + * This defines the required ``create_factory`` function and binds the class to + * Python so the plugin can be consumed via Basilisk's runtime registry. + */ + +#include +#include + +#include +#include + +namespace bsk::plugin { + +namespace detail { + +template +struct has_reset : std::false_type {}; + +template +struct has_reset().Reset(std::declval()))>> + : std::true_type {}; + +template +struct has_update_state : std::false_type {}; + +template +struct has_update_state< + T, std::void_t().UpdateState(std::declval(), std::declval()))>> + : std::true_type {}; + +template +struct has_reset_flag : std::false_type {}; + +template +struct has_reset_flag().reset_called())>> : std::true_type {}; + +template +struct has_update_flag : std::false_type {}; + +template +struct has_update_flag().update_called())>> : std::true_type {}; + +} // namespace detail + +template +pybind11::cpp_function make_factory() { + static_assert(std::is_default_constructible_v, + "Basilisk plugin modules must be default constructible"); + return pybind11::cpp_function([]() { return Module(); }); +} + +template +auto bind_module(pybind11::module_& m, const char* python_name) { + auto cls = pybind11::class_(m, python_name); + + if constexpr (std::is_default_constructible_v) { + cls.def(pybind11::init<>()); + } + + if constexpr (detail::has_reset::value) { + cls.def("Reset", &Module::Reset); + } + + if constexpr (detail::has_update_state::value) { + cls.def("UpdateState", &Module::UpdateState); + } + + if constexpr (detail::has_reset_flag::value) { + cls.def_property_readonly("reset_called", &Module::reset_called); + } + + if constexpr (detail::has_update_flag::value) { + cls.def_property_readonly("update_called", &Module::update_called); + } + + return cls; +} + +template +void register_basic_plugin(pybind11::module_& m, const char* class_name) { + (void)bind_module(m, class_name); + m.def("create_factory", []() { return make_factory(); }); +} + +} // namespace bsk::plugin + +/** + * Convenience macro that defines the required pybind11 module exporting the + * provided ``ModuleType`` as ``class_name``. It also generates the + * ``create_factory`` function consumed by the Basilisk runtime. + */ +#define BSK_PLUGIN_PYBIND_MODULE(module_name, ModuleType, class_name) \ + PYBIND11_MODULE(module_name, module) { \ + ::bsk::plugin::register_basic_plugin(module, class_name); \ + } diff --git a/sdk/tools/sync_headers.py b/sdk/tools/sync_headers.py new file mode 100755 index 0000000000..e7306d8380 --- /dev/null +++ b/sdk/tools/sync_headers.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +""" +Synchronize a curated subset of Basilisk source headers into the SDK. + +This script copies hand-picked directories from the main ``src`` tree into the +SDK include path so that external plugin builds can depend solely on the +``bsk-sdk`` package. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +SRC_ROOT = REPO_ROOT / "src" +SDK_INCLUDE_ROOT = REPO_ROOT / "sdk" / "src" / "bsk_sdk" / "include" / "Basilisk" + +DIRECTORIES = [ + "architecture/_GeneralModuleFiles", + "architecture/messaging", + "architecture/utilities", +] +IGNORE_PATTERNS = ["_UnitTest", "_Documentation", "*.swg", "*.i"] + + +def copy_tree(src: Path, dest: Path) -> None: + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(src, dest, ignore=shutil.ignore_patterns(*IGNORE_PATTERNS)) + + +def main() -> None: + SDK_INCLUDE_ROOT.mkdir(parents=True, exist_ok=True) + + for relative in DIRECTORIES: + src_dir = SRC_ROOT / relative + dest_dir = SDK_INCLUDE_ROOT / relative + if not src_dir.exists(): + raise FileNotFoundError(f"Missing source directory: {src_dir}") + print(f"Copying {src_dir} -> {dest_dir}") + copy_tree(src_dir, dest_dir) + + +if __name__ == "__main__": + main() diff --git a/src/Basilisk/architecture/messaging/anyMessage.h b/src/Basilisk/architecture/messaging/anyMessage.h new file mode 100644 index 0000000000..8c8191990f --- /dev/null +++ b/src/Basilisk/architecture/messaging/anyMessage.h @@ -0,0 +1,141 @@ +#pragma once + +#include "Basilisk/architecture/messaging/msgTypeRegistry.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace Basilisk { +namespace messaging { + +class AnyMessageError : public std::runtime_error +{ + public: + explicit AnyMessageError(const std::string& what_arg) + : std::runtime_error(what_arg) + { + } +}; + +class AnyMessage +{ + public: + AnyMessage() = default; + + // Construct with a registered type handle (allocates storage). + explicit AnyMessage(MsgTypeHandle handle) { set_type(handle); } + + // --------------------------------------------------------------------- + // Type management + // --------------------------------------------------------------------- + + void set_type(std::string_view type_name) + { + auto handle = MsgTypeRegistry::global().lookup(type_name); + if (handle == 0) { + throw AnyMessageError("AnyMessage: unknown message type name"); + } + set_type(handle); + } + + void set_type(MsgTypeHandle handle) + { + const auto& info = MsgTypeRegistry::global().info(handle); + handle_ = handle; + storage_.resize(info.size); + } + + // --------------------------------------------------------------------- + // Accessors + // --------------------------------------------------------------------- + + MsgTypeHandle type() const { return handle_; } + MsgTypeHandle type_handle() const { return handle_; } + + const MsgTypeInfo& type_info() const + { + if (handle_ == 0) { + throw AnyMessageError("AnyMessage: type not set"); + } + return MsgTypeRegistry::global().info(handle_); + } + + std::size_t size() const { return storage_.size(); } + std::size_t size_bytes() const { return storage_.size(); } + + const void* data() const { return storage_.empty() ? nullptr : storage_.data(); } + void* data() { return storage_.empty() ? nullptr : storage_.data(); } + + uint64_t time_written_nanos() const { return time_written_nanos_; } + + // --------------------------------------------------------------------- + // Write / read API + // --------------------------------------------------------------------- + + // Primary API (timestamped) + void write_bytes(const void* src, std::size_t nbytes, uint64_t time_nanos) + { + if (handle_ == 0) { + throw AnyMessageError("AnyMessage: type not set before write"); + } + if (nbytes != storage_.size()) { + throw AnyMessageError("AnyMessage: payload size mismatch on write"); + } + std::memcpy(storage_.data(), src, nbytes); + time_written_nanos_ = time_nanos; + } + + // Compatibility overload (used by SWIG + legacy code) + void write_bytes(const void* src, std::size_t nbytes) { write_bytes(src, nbytes, 0); } + + void read_bytes(void* dst, std::size_t nbytes) const + { + if (handle_ == 0) { + throw AnyMessageError("AnyMessage: type not set before read"); + } + if (nbytes != storage_.size()) { + throw AnyMessageError("AnyMessage: payload size mismatch on read"); + } + std::memcpy(dst, storage_.data(), nbytes); + } + + // --------------------------------------------------------------------- + // Typed helpers + // --------------------------------------------------------------------- + + template + void write(const T& payload, uint64_t time_nanos = 0) + { + ensure_type_matches(); + write_bytes(&payload, sizeof(T), time_nanos); + } + + template + void read(T& payload_out) const + { + ensure_type_matches(); + read_bytes(&payload_out, sizeof(T)); + } + + private: + template + void ensure_type_matches() const + { + const auto& info = type_info(); + if (info.size != sizeof(T) || info.align != alignof(T)) { + throw AnyMessageError("AnyMessage: type mismatch between registered payload and requested T"); + } + } + + MsgTypeHandle handle_ = 0; + std::vector storage_; + uint64_t time_written_nanos_ = 0; +}; + +} // namespace messaging +} // namespace Basilisk diff --git a/src/Basilisk/architecture/messaging/anyMsgPort.h b/src/Basilisk/architecture/messaging/anyMsgPort.h new file mode 100644 index 0000000000..cedd42736c --- /dev/null +++ b/src/Basilisk/architecture/messaging/anyMsgPort.h @@ -0,0 +1,93 @@ +#pragma once + +#include +#include +#include + +#include "Basilisk/architecture/messaging/anyMessage.h" +#include "Basilisk/architecture/messaging/msgTypeRegistry.h" + +namespace Basilisk { +namespace messaging { + +class AnyMsgPortError : public std::runtime_error +{ + public: + explicit AnyMsgPortError(const std::string& what_arg) + : std::runtime_error(what_arg) + { + } +}; + +// Minimal dynamic-typed output port. +// Owns the underlying AnyMessage storage. +class AnyMsgOut +{ + public: + explicit AnyMsgOut(MsgTypeHandle type_handle) + : type_(type_handle) + , msg_(type_handle) + { + if (type_handle == 0) { + throw AnyMsgPortError("AnyMsgOut: type handle 0 is invalid"); + } + } + + MsgTypeHandle type() const { return type_; } + AnyMessage& message() { return msg_; } + const AnyMessage& message() const { return msg_; } + + private: + MsgTypeHandle type_{ 0 }; + AnyMessage msg_; +}; + +// Minimal dynamic-typed input port. +// Points at an upstream AnyMsgOut's AnyMessage. +class AnyMsgIn +{ + public: + explicit AnyMsgIn(MsgTypeHandle expected_type_handle) + : expected_type_(expected_type_handle) + { + if (expected_type_handle == 0) { + throw AnyMsgPortError("AnyMsgIn: expected type handle 0 is invalid"); + } + } + + MsgTypeHandle expected_type() const { return expected_type_; } + + bool is_connected() const { return upstream_ != nullptr; } + + // Connect this input to an upstream output. + // Enforces type handle equality. + void connect(AnyMsgOut& upstream) + { + if (upstream.type() != expected_type_) { + std::ostringstream oss; + const MsgTypeInfo& want = MsgTypeRegistry::global().info(expected_type_); + const MsgTypeInfo& got = MsgTypeRegistry::global().info(upstream.type()); + oss << "AnyMsgIn::connect: type mismatch. expected " << want.name << " (handle " << expected_type_ + << "), got " << got.name << " (handle " << upstream.type() << ")"; + throw AnyMsgPortError(oss.str()); + } + upstream_ = &upstream; + } + + // Access the upstream message bytes. + // Throws if not connected. + const AnyMessage& message() const + { + if (!upstream_) { + throw AnyMsgPortError("AnyMsgIn::message: not connected"); + } + return upstream_->message(); + } + + private: + MsgTypeHandle expected_type_{ 0 }; + AnyMsgOut* upstream_{ nullptr }; +}; + +} // namespace messaging +} // namespace Basilisk diff --git a/src/Basilisk/architecture/messaging/autoRegister.h b/src/Basilisk/architecture/messaging/autoRegister.h new file mode 100644 index 0000000000..d6261cdb41 --- /dev/null +++ b/src/Basilisk/architecture/messaging/autoRegister.h @@ -0,0 +1,50 @@ +#pragma once + +#include "msgTypeRegistry.h" + +#include + +namespace Basilisk { +namespace messaging { +namespace detail { + +// Runs at static init +inline MsgTypeHandle +register_cpp_payload(const char* name, std::size_t size, std::size_t align) +{ + MsgTypeInfo info; + info.name = name; + info.size = size; + info.align = align; + info.version = 1; + info.schema_hash = 0; // can upgrade later + + return MsgTypeRegistry::global().register_type(info); +} + +} // namespace detail +} // namespace messaging +} // namespace Basilisk + +// ----------------------------------------------------------------------------- +// C++ payload auto-registration +// ----------------------------------------------------------------------------- +#define BSK_AUTO_REGISTER_MSG(PayloadType) \ + static_assert(std::is_standard_layout::value, "Payload must be standard layout"); \ + static_assert(std::is_trivially_copyable::value, "Payload must be trivially copyable"); \ + namespace { \ + const ::Basilisk::messaging::MsgTypeHandle _bsk_msg_handle_##PayloadType = \ + ::Basilisk::messaging::detail::register_cpp_payload(#PayloadType, sizeof(PayloadType), alignof(PayloadType)); \ + } + +#ifdef __cplusplus +extern "C" +{ +#endif + +#define BSK_AUTO_REGISTER_MSG_C(PayloadType) \ + static const int _bsk_msg_reg_##PayloadType = bsk_msg_register_payload_type(#PayloadType, sizeof(PayloadType)) + +#ifdef __cplusplus +} +#endif diff --git a/src/Basilisk/architecture/messaging/msgTypeRegistry.cpp b/src/Basilisk/architecture/messaging/msgTypeRegistry.cpp new file mode 100644 index 0000000000..80fed3124d --- /dev/null +++ b/src/Basilisk/architecture/messaging/msgTypeRegistry.cpp @@ -0,0 +1,153 @@ +#include "msgTypeRegistry.h" + +#include +#include + +namespace Basilisk { +namespace messaging { + +namespace { +inline bool +is_power_of_two(std::size_t x) +{ + return x != 0 && ((x & (x - 1)) == 0); +} +} // namespace + +MsgTypeRegistry::MsgTypeRegistry() = default; + +void +MsgTypeRegistry::validate_info(const MsgTypeInfo& info) +{ + if (info.name.empty()) { + throw MsgTypeRegistryError("MsgTypeRegistry: name must be non-empty"); + } + if (info.size == 0) { + throw MsgTypeRegistryError("MsgTypeRegistry: size must be non-zero"); + } + if (info.align == 0 || !is_power_of_two(info.align)) { + throw MsgTypeRegistryError("MsgTypeRegistry: alignment must be power of two"); + } +} + +MsgTypeHandle +MsgTypeRegistry::register_type(const MsgTypeInfo& info) +{ + validate_info(info); + + std::lock_guard lock(mtx_); + + auto it = by_name_.find(info.name); + if (it != by_name_.end()) { + MsgTypeHandle handle = it->second; + const auto& existing = by_handle_.at(handle); + + if (existing.size != info.size || existing.align != info.align || existing.version != info.version || + existing.schema_hash != info.schema_hash) { + throw MsgTypeRegistryError("MsgTypeRegistry: incompatible re-registration of type '" + info.name + "'"); + } + + return handle; + } + + MsgTypeHandle handle = next_handle_++; + by_name_.emplace(info.name, handle); + by_handle_.emplace(handle, info); + return handle; +} + +#ifndef SWIG +MsgTypeHandle +MsgTypeRegistry::lookup(std::string_view name) const +{ + if (name.empty()) { + return 0; + } + + std::lock_guard lock(mtx_); + auto it = by_name_.find(std::string(name)); + return (it == by_name_.end()) ? 0 : it->second; +} +#else +MsgTypeHandle +MsgTypeRegistry::lookup(const std::string& name) const +{ + if (name.empty()) { + return 0; + } + + std::lock_guard lock(mtx_); + auto it = by_name_.find(name); + return (it == by_name_.end()) ? 0 : it->second; +} +#endif + +const MsgTypeInfo& +MsgTypeRegistry::info(MsgTypeHandle handle) const +{ + if (handle == 0) { + throw MsgTypeRegistryError("MsgTypeRegistry: invalid handle 0"); + } + + std::lock_guard lock(mtx_); + auto it = by_handle_.find(handle); + if (it == by_handle_.end()) { + throw MsgTypeRegistryError("MsgTypeRegistry: unknown handle"); + } + return it->second; +} + +MsgTypeRegistry& +MsgTypeRegistry::global() +{ + static MsgTypeRegistry instance; + return instance; +} + +} // namespace messaging +} // namespace Basilisk + +// ----------------------------------------------------------------------------- +// C ABI +// ----------------------------------------------------------------------------- + +extern "C" +{ + + static int validate_cstr(const char* s) + { + return (s != nullptr && s[0] != '\0'); + } + + int bsk_msg_register_payload_type_ex(const char* type_name, + unsigned long payload_size_bytes, + unsigned long payload_align_bytes, + unsigned int version, + unsigned long long schema_hash) + { + try { + if (!validate_cstr(type_name) || payload_size_bytes == 0 || payload_align_bytes == 0) { + return 1; + } + + Basilisk::messaging::MsgTypeInfo info; + info.name = type_name; + info.size = static_cast(payload_size_bytes); + info.align = static_cast(payload_align_bytes); + info.version = version; + info.schema_hash = schema_hash; + + Basilisk::messaging::MsgTypeRegistry::global().register_type(info); + return 0; + } catch (...) { + return 2; + } + } + + int bsk_msg_register_payload_type(const char* type_name, unsigned long payload_size_bytes) + { + return bsk_msg_register_payload_type_ex( + type_name, payload_size_bytes, static_cast(alignof(void*)), 1u, 0ull); + } + +} // extern "C" diff --git a/src/Basilisk/architecture/messaging/msgTypeRegistry.h b/src/Basilisk/architecture/messaging/msgTypeRegistry.h new file mode 100644 index 0000000000..4c71aa5a79 --- /dev/null +++ b/src/Basilisk/architecture/messaging/msgTypeRegistry.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace Basilisk { +namespace messaging { + +using MsgTypeHandle = uint32_t; // 0 reserved as invalid + +struct MsgTypeInfo +{ + std::string name; + std::size_t size = 0; + std::size_t align = 0; + uint32_t version = 1; + uint64_t schema_hash = 0; +}; + +class MsgTypeRegistryError : public std::runtime_error +{ + public: + explicit MsgTypeRegistryError(const std::string& what_arg) + : std::runtime_error(what_arg) + { + } +}; + +class MsgTypeRegistry +{ + public: + MsgTypeRegistry(); + + MsgTypeHandle register_type(const MsgTypeInfo& info); + +#ifndef SWIG + MsgTypeHandle lookup(std::string_view name) const; +#else + // SWIG cannot parse std::string_view + MsgTypeHandle lookup(const std::string& name) const; +#endif + + const MsgTypeInfo& info(MsgTypeHandle handle) const; + + static MsgTypeRegistry& global(); + + private: + static void validate_info(const MsgTypeInfo& info); + + mutable std::mutex mtx_; + std::unordered_map by_name_; + std::unordered_map by_handle_; + MsgTypeHandle next_handle_ = 1; +}; + +} // namespace messaging +} // namespace Basilisk + +// ----------------------------------------------------------------------------- +// C ABI +// ----------------------------------------------------------------------------- + +#ifdef __cplusplus +extern "C" +{ +#endif + + int bsk_msg_register_payload_type_ex(const char* type_name, + unsigned long payload_size_bytes, + unsigned long payload_align_bytes, + unsigned int version, + unsigned long long schema_hash); + + int bsk_msg_register_payload_type(const char* type_name, unsigned long payload_size_bytes); + +#ifdef __cplusplus +} +#endif diff --git a/src/Basilisk/modules/__init__.py b/src/Basilisk/modules/__init__.py new file mode 100644 index 0000000000..a68778b4b8 --- /dev/null +++ b/src/Basilisk/modules/__init__.py @@ -0,0 +1,36 @@ +""" +Dynamic plugin namespace for Basilisk runtime modules. + +This module defers plugin discovery until attributes are accessed. Plugins are +expected to register their SysModel subclasses or factories via the global +registry that lives in :mod:`bsk_core.plugins`. +""" + +from __future__ import annotations + +from typing import Any, Iterable + +from bsk_core.plugins import GLOBAL_REGISTRY, load_all_plugins + +__all__ = ["GLOBAL_REGISTRY", "load_all_plugins"] + + +def _known_attribute_names() -> Iterable[str]: + load_all_plugins() + return tuple(set(GLOBAL_REGISTRY.py_modules) | set(GLOBAL_REGISTRY.factories)) + + +def __getattr__(name: str) -> Any: + load_all_plugins() + + if name in GLOBAL_REGISTRY.py_modules: + return GLOBAL_REGISTRY.py_modules[name] + + if name in GLOBAL_REGISTRY.factories: + return GLOBAL_REGISTRY.factories[name] + + raise AttributeError(f"module 'Basilisk.modules' has no attribute '{name}'") + + +def __dir__() -> list[str]: + return sorted(set(globals()) - {"__builtins__"} | set(_known_attribute_names())) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0c4f38f425..018e0e0d72 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -195,6 +195,21 @@ function(generate_package_libraries INIT_DIRECTORY AllLibs) # Add Target add_library(${LIB_NAME} SHARED ${C_FILES} ${BSK_FWK_FILES}) + if(${LIB_NAME} STREQUAL "architectureLib") + target_compile_definitions(${LIB_NAME} PRIVATE BASILISK_ARCH_EXPORTS=1) + endif() + + if(${LIB_NAME} STREQUAL "architectureLib") + target_sources(${LIB_NAME} PRIVATE + "${PROJECT_SOURCE_DIR}/Basilisk/architecture/messaging/msgTypeRegistry.cpp" + ) + target_include_directories(${LIB_NAME} PUBLIC + "${PROJECT_SOURCE_DIR}/Basilisk/architecture/messaging" + ) + target_compile_definitions(${LIB_NAME} PRIVATE BASILISK_ARCH_EXPORTS=1) + endif() + + # Add to list of library list(APPEND AllLibs ${LIB_NAME}) @@ -593,6 +608,11 @@ add_subdirectory("architecture/utilities") add_subdirectory("utilities") # has protobuffers included add_subdirectory("architecture/messaging/cMsgCInterface") add_subdirectory("architecture/messaging/") +if(TARGET architectureLib) + target_sources(architectureLib PRIVATE + ${CMAKE_SOURCE_DIR}/architecture/messaging/msgTypeRegistry.cpp + ) +endif() # TODO: I'd call this generate_libraries(), because it really does find all of them. TODO: I'd like for all generated # libraries to end up in a dist/Basilisk/lib folder rather than the /dist/Basilisk folder; however haven't found a way @@ -721,6 +741,24 @@ else() "from Basilisk.architecture import messaging # ensure recorders() work without first importing messaging\n") endif() +file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/Basilisk/modules") +configure_file( + "${CMAKE_SOURCE_DIR}/Basilisk/modules/__init__.py" + "${CMAKE_BINARY_DIR}/Basilisk/modules/__init__.py" + COPYONLY +) + +file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/bsk_core") +file(GLOB BSK_CORE_SOURCES "${CMAKE_SOURCE_DIR}/bsk_core/*.py" "${CMAKE_SOURCE_DIR}/bsk_core/**") +if(BSK_CORE_SOURCES) + create_symlinks("${CMAKE_BINARY_DIR}/bsk_core" ${BSK_CORE_SOURCES}) +endif() + +set(BSK_PLUGIN_SDK_ROOT "${CMAKE_SOURCE_DIR}/../sdk") +if(EXISTS "${BSK_PLUGIN_SDK_ROOT}") + create_symlinks("${CMAKE_BINARY_DIR}/Basilisk" "${BSK_PLUGIN_SDK_ROOT}") +endif() + # TODO: Iterate through all dist directories and add __init__.py's where they don't exist file( GLOB_RECURSE DIST_DIRECTORIES diff --git a/src/architecture/_GeneralModuleFiles/any_messaging.i b/src/architecture/_GeneralModuleFiles/any_messaging.i new file mode 100644 index 0000000000..72e0399192 --- /dev/null +++ b/src/architecture/_GeneralModuleFiles/any_messaging.i @@ -0,0 +1,138 @@ +%module(directors="0",threads="1") cAnyMessaging + +%{ +#include "Basilisk/architecture/messaging/msgTypeRegistry.h" +#include "Basilisk/architecture/messaging/anyMessage.h" +#include "Basilisk/architecture/messaging/anyMsgPort.h" + +#include +#include +#include + +// IMPORTANT: this must exist in the real C++ wrapper TU +namespace Basilisk { +namespace messaging { + using BskBytes = std::string; +} +} +%} + +%include "stdint.i" +%include "std_string.i" + +%include "Basilisk/architecture/messaging/msgTypeRegistry.h" +%include "Basilisk/architecture/messaging/anyMessage.h" +%include "Basilisk/architecture/messaging/anyMsgPort.h" + +%ignore Basilisk::messaging::MsgTypeRegistry::validate_info; +%ignore Basilisk::messaging::MsgTypeRegistry::global; + +// Also define for SWIG's parser +%inline %{ +namespace Basilisk { +namespace messaging { + typedef std::string BskBytes; +} +} +%} + +// Re-expose global registry as a helper +%inline %{ +namespace Basilisk { +namespace messaging { +inline MsgTypeRegistry& global_msg_type_registry() { + return MsgTypeRegistry::global(); +} +} +} +%} + +// ----------------------------------------------------------------------------- +// Typemaps: Basilisk::messaging::BskBytes <-> Python bytes/bytearray +// Limited-API safe: no Py_buffer / no buffer protocol +// ----------------------------------------------------------------------------- + +%typemap(typecheck) Basilisk::messaging::BskBytes { + $1 = PyBytes_Check($input) || PyByteArray_Check($input); +} + +%typemap(in) Basilisk::messaging::BskBytes { + if (PyBytes_Check($input)) { + char *buf = nullptr; + Py_ssize_t n = 0; + if (PyBytes_AsStringAndSize($input, &buf, &n) != 0) { + SWIG_exception_fail(SWIG_TypeError, "Failed to extract bytes payload"); + } + $1 = Basilisk::messaging::BskBytes(buf, (size_t)n); + } else if (PyByteArray_Check($input)) { + char *buf = PyByteArray_AS_STRING($input); + Py_ssize_t n = PyByteArray_Size($input); + $1 = Basilisk::messaging::BskBytes(buf, (size_t)n); + } else { + SWIG_exception_fail(SWIG_TypeError, "Expected bytes or bytearray"); + } +} + +%typemap(out) Basilisk::messaging::BskBytes { + const auto &s = $1; + $result = PyBytes_FromStringAndSize(s.data(), (Py_ssize_t)s.size()); +} + +// ----------------------------------------------------------------------------- +// Python-friendly helpers +// ----------------------------------------------------------------------------- + +%extend Basilisk::messaging::AnyMessage { + + void write_py(Basilisk::messaging::BskBytes data, uint64_t time_nanos = 0) { + self->write_bytes((void const*)data.data(), (std::size_t)data.size(), time_nanos); + } + + Basilisk::messaging::BskBytes read_py() const { + Basilisk::messaging::BskBytes out; + auto& reg = Basilisk::messaging::MsgTypeRegistry::global(); + const auto& info = reg.info(self->type()); + + out.resize(info.size); + if (!out.empty()) { + self->read_bytes(&out[0], out.size()); + } + return out; + } +} + +%extend Basilisk::messaging::AnyMsgOut { + + void write_py(Basilisk::messaging::BskBytes data, uint64_t time_nanos = 0) { + self->message().write_bytes((void const*)data.data(), (std::size_t)data.size(), time_nanos); + } + + Basilisk::messaging::BskBytes read_py() const { + Basilisk::messaging::BskBytes out; + auto& reg = Basilisk::messaging::MsgTypeRegistry::global(); + const auto& info = reg.info(self->message().type()); + + out.resize(info.size); + if (!out.empty()) { + self->message().read_bytes(&out[0], out.size()); + } + return out; + } +} + +%extend Basilisk::messaging::AnyMsgIn { + + Basilisk::messaging::BskBytes read_py() const { + const Basilisk::messaging::AnyMessage& msg = self->message(); + Basilisk::messaging::BskBytes out; + + auto& reg = Basilisk::messaging::MsgTypeRegistry::global(); + const auto& info = reg.info(msg.type()); + + out.resize(info.size); + if (!out.empty()) { + msg.read_bytes(&out[0], out.size()); + } + return out; + } +} diff --git a/src/bsk_core/__init__.py b/src/bsk_core/__init__.py new file mode 100644 index 0000000000..71e2261ecd --- /dev/null +++ b/src/bsk_core/__init__.py @@ -0,0 +1,10 @@ +""" +Lightweight core helpers that are shared across Basilisk's Python surface. + +The plugin system implemented in :mod:`bsk_core.plugins` is responsible for +discovering entry points and exposing their registrations to the runtime. +""" + +from .plugins import GLOBAL_REGISTRY, PluginRegistry, load_all_plugins + +__all__ = ["GLOBAL_REGISTRY", "PluginRegistry", "load_all_plugins"] diff --git a/src/bsk_core/plugins.py b/src/bsk_core/plugins.py new file mode 100644 index 0000000000..59d1490e87 --- /dev/null +++ b/src/bsk_core/plugins.py @@ -0,0 +1,120 @@ +""" +Runtime plugin registration support for Basilisk. + +Only Python-based registration is implemented today. C++ factories are stubbed +out to support future extensions without breaking the public API. +""" + +from __future__ import annotations + +from importlib import metadata +from typing import Any, Callable, Iterable, Optional + +from Basilisk.architecture import sysModel + +ENTRY_POINT_GROUP = "basilisk.plugins" +"""Python entry point group used to discover Basilisk plugins.""" + + +class PluginRegistry: + """Container for Basilisk plugin registrations.""" + + def __init__(self) -> None: + self.py_modules: dict[str, type[sysModel.SysModel]] = {} + self.factories: dict[str, Any] = {} + + def register_python_module(self, name: str, cls: type[sysModel.SysModel]) -> None: + """Register a Python :class:`~Basilisk.architecture.sysModel.SysModel` subclass.""" + if not isinstance(name, str) or not name: + raise TypeError("Module name must be a non-empty string") + if not isinstance(cls, type): + raise TypeError("Only classes can be registered as Python modules") + + try: + is_sysmodel = issubclass(cls, sysModel.SysModel) + except TypeError as exc: # cls is not a class or similar edge cases + raise TypeError("Only SysModel subclasses can be registered") from exc + + if not is_sysmodel: + raise TypeError( + f"Cannot register {cls!r} as '{name}': not a SysModel subclass" + ) + + existing = self.py_modules.get(name) + if existing is not None and existing is not cls: + raise ValueError( + f"Module name '{name}' already registered with {existing!r}" + ) + + self.py_modules[name] = cls + + def register_factory(self, name: str, factory: Any) -> None: + """ + Register a future C++ factory. + + No validation is performed yet; factories act as opaque callables or + objects until C++ support is implemented. + """ + if not isinstance(name, str) or not name: + raise TypeError("Factory name must be a non-empty string") + + existing = self.factories.get(name) + if existing is not None and existing is not factory: + raise ValueError(f"Factory name '{name}' already registered") + + self.factories[name] = factory + + +GLOBAL_REGISTRY = PluginRegistry() +"""Shared registry instance used across the Basilisk runtime.""" + +_PLUGINS_LOADED = False + + +def _iter_plugin_entry_points() -> Iterable[metadata.EntryPoint]: + """Return an iterable over all registered plugin entry points.""" + entry_points = metadata.entry_points() + if hasattr(entry_points, "select"): + return entry_points.select(group=ENTRY_POINT_GROUP) + return entry_points.get(ENTRY_POINT_GROUP, []) + + +def _resolve_register_callable(obj: Any) -> Callable[[PluginRegistry], None]: + """Normalize the value advertised by an entry point into a register callable.""" + if callable(obj): + return obj # The entry point points directly to register() + + register = getattr(obj, "register", None) + if callable(register): + return register + + raise TypeError( + "Basilisk plugin entry points must reference a callable or an object with " + "a callable 'register' attribute" + ) + + +def load_all_plugins(registry: Optional[PluginRegistry] = None) -> PluginRegistry: + """ + Discover and register all Basilisk plugins using ``importlib.metadata``. + + The discovery process is idempotent; repeated calls do not re-register + plugins. + """ + global _PLUGINS_LOADED + + if registry is None: + registry = GLOBAL_REGISTRY + + if _PLUGINS_LOADED: + return registry + + for entry_point in _iter_plugin_entry_points(): + register = _resolve_register_callable(entry_point.load()) + register(registry) + + _PLUGINS_LOADED = True + return registry + + +__all__ = ["GLOBAL_REGISTRY", "PluginRegistry", "load_all_plugins"] diff --git a/src/tests/test_plugin_registry.py b/src/tests/test_plugin_registry.py new file mode 100644 index 0000000000..3171aa6122 --- /dev/null +++ b/src/tests/test_plugin_registry.py @@ -0,0 +1,229 @@ +# +# ISC License +# +# Copyright (c) 2016, Autonomous Vehicle Systems Lab, University of Colorado +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +"""Smoke tests for the runtime plugin registry.""" + +from __future__ import annotations + +import importlib +import sys +import types +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +import pytest + +from Basilisk.architecture import sysModel +from bsk_core import plugins + + +@pytest.fixture(autouse=True) +def reset_registry(): + """Ensure the global registry is clean before and after every test.""" + snapshot_py = dict(plugins.GLOBAL_REGISTRY.py_modules) + snapshot_factories = dict(plugins.GLOBAL_REGISTRY.factories) + snapshot_loaded = plugins._PLUGINS_LOADED + + plugins.GLOBAL_REGISTRY.py_modules.clear() + plugins.GLOBAL_REGISTRY.factories.clear() + plugins._PLUGINS_LOADED = False + + yield plugins.GLOBAL_REGISTRY + + plugins.GLOBAL_REGISTRY.py_modules.clear() + plugins.GLOBAL_REGISTRY.py_modules.update(snapshot_py) + + plugins.GLOBAL_REGISTRY.factories.clear() + plugins.GLOBAL_REGISTRY.factories.update(snapshot_factories) + + plugins._PLUGINS_LOADED = snapshot_loaded + + +class DummySysModel(sysModel.SysModel): + def __init__(self): + super().__init__() + + +def test_register_python_module_accepts_sysmodel(reset_registry): + registry = reset_registry + registry.register_python_module("Dummy", DummySysModel) + assert registry.py_modules["Dummy"] is DummySysModel + + +def test_register_python_module_rejects_non_sysmodel(reset_registry): + registry = reset_registry + with pytest.raises(TypeError): + registry.register_python_module("Bad", object) # type: ignore[arg-type] + + +def test_register_factory_allows_simple_storage(reset_registry): + registry = reset_registry + factory = object() + registry.register_factory("factory", factory) + assert registry.factories["factory"] is factory + + +@dataclass +class _FakeEntryPoint: + loader: Callable[[plugins.PluginRegistry], None] + + def load(self): + return self.loader + + +class _FakeEntryPoints: + def __init__(self, entries): + self._entries = entries + + def select(self, *, group): + if group == plugins.ENTRY_POINT_GROUP: + return self._entries + return [] + + +def test_load_all_plugins_discovers_entry_points(monkeypatch, reset_registry): + calls = [] + + def register(registry): + calls.append(registry) + registry.register_python_module("PluginSysModel", DummySysModel) + + fake_entry_points = _FakeEntryPoints([_FakeEntryPoint(register)]) + monkeypatch.setattr(plugins.metadata, "entry_points", lambda: fake_entry_points) + + registry = plugins.load_all_plugins() + assert "PluginSysModel" in registry.py_modules + assert calls == [registry] + + second = plugins.load_all_plugins() + assert second is registry + assert calls == [registry] + + +def test_modules_namespace_exposes_registered_plugins(monkeypatch, reset_registry): + modules_pkg = importlib.import_module("Basilisk.modules") + + def fake_loader(): + # Register a module and advertise that discovery has happened. + plugins.GLOBAL_REGISTRY.register_python_module("NamespaceSysModel", DummySysModel) + plugins._PLUGINS_LOADED = True + return plugins.GLOBAL_REGISTRY + + monkeypatch.setattr(modules_pkg, "load_all_plugins", fake_loader) + + resolved = modules_pkg.NamespaceSysModel + assert resolved is DummySysModel + + exported = dir(modules_pkg) + assert "NamespaceSysModel" in exported + + with pytest.raises(AttributeError): + getattr(modules_pkg, "DoesNotExist") + + +def test_example_plugin_discovery(monkeypatch, reset_registry): + pkg = types.ModuleType("bsk_example_plugin") + simple_module = types.ModuleType("bsk_example_plugin.simple") + + class ExamplePluginModule(DummySysModel): + def __init__(self): + super().__init__("Example") + + def Reset(self, current_sim_nanos): + super().Reset(current_sim_nanos) + + def UpdateState(self, current_sim_nanos, call_time): + super().UpdateState(current_sim_nanos, call_time) + + def register(registry: plugins.PluginRegistry): + registry.register_python_module("ExamplePluginModule", ExamplePluginModule) + + simple_module.ExamplePluginModule = ExamplePluginModule + simple_module.register = register + pkg.simple = simple_module + + monkeypatch.setitem(sys.modules, "bsk_example_plugin", pkg) + monkeypatch.setitem(sys.modules, "bsk_example_plugin.simple", simple_module) + + entry_point = plugins.metadata.EntryPoint( + name="example", + value="bsk_example_plugin.simple:register", + group=plugins.ENTRY_POINT_GROUP, + ) + monkeypatch.setattr(plugins, "_iter_plugin_entry_points", lambda: [entry_point]) + + registry = plugins.load_all_plugins() + assert "ExamplePluginModule" in registry.py_modules + + modules_pkg = importlib.import_module("Basilisk.modules") + cls = modules_pkg.ExamplePluginModule + instance = cls() + instance.Reset(0) + instance.UpdateState(0, 0) + assert instance.reset_called is True + assert instance.update_called is True + + +def test_example_cpp_plugin_discovery(monkeypatch, reset_registry): + pkg = types.ModuleType("bsk_example_plugin_cpp") + register_module = types.ModuleType("bsk_example_plugin_cpp.register") + + class StubExampleCppModule: + def __init__(self): + self.reset_called = False + self.update_called = False + + def Reset(self, current_sim_nanos): + self.reset_called = True + + def UpdateState(self, current_sim_nanos, call_time): + self.update_called = True + + def stub_factory(): + return StubExampleCppModule() + + stub_extension = types.SimpleNamespace(create_factory=lambda: stub_factory) + monkeypatch.setitem(sys.modules, "bsk_example_plugin_cpp", pkg) + monkeypatch.setitem(sys.modules, "bsk_example_plugin_cpp.register", register_module) + monkeypatch.setitem(sys.modules, "bsk_example_plugin_cpp._example_cpp", stub_extension) + + def register(registry: plugins.PluginRegistry): + extension = importlib.import_module("bsk_example_plugin_cpp._example_cpp") + factory = extension.create_factory() + registry.register_factory("ExampleCppFactory", factory) + + register_module.register = register + + entry_point = plugins.metadata.EntryPoint( + name="example_cpp", + value="bsk_example_plugin_cpp.register:register", + group=plugins.ENTRY_POINT_GROUP, + ) + monkeypatch.setattr(plugins, "_iter_plugin_entry_points", lambda: [entry_point]) + + registry = plugins.load_all_plugins() + assert "ExampleCppFactory" in registry.factories + + modules_pkg = importlib.import_module("Basilisk.modules") + factory = modules_pkg.ExampleCppFactory + instance = factory() + instance.Reset(0) + instance.UpdateState(0, 0) + assert instance.reset_called is True + assert instance.update_called is True diff --git a/test_example.py b/test_example.py new file mode 100644 index 0000000000..ffac60fb37 --- /dev/null +++ b/test_example.py @@ -0,0 +1,39 @@ +from Basilisk.utilities import SimulationBaseClass, macros +from Basilisk.ExternalModules import _custom_cpp +import Basilisk.architecture.sysModel as sysModel + + +# Wrap plugin instance as a real Basilisk SysModel since im using PyBind11 +# currently +class PluginAsSysModel(sysModel.SysModel): + def __init__(self, plugin_impl): + super().__init__() + self.impl = plugin_impl + self.ModelTag = "CustomCppModuleDemo" + + def Reset(self, t): + self.impl.Reset(t) + + def UpdateState(self, t): + self.impl.UpdateState(t) + + +sim = SimulationBaseClass.SimBaseClass() +proc = sim.CreateNewProcess("proc") +task_name = "task" +proc.addTask(sim.CreateNewTask(task_name, macros.sec2nano(1.0))) + +plugin = _custom_cpp.create_factory()() +wrapper = PluginAsSysModel(plugin) + +sim.AddModelToTask(task_name, wrapper) +plugin.set_input_payload(_custom_cpp.CustomPluginMsgPayload([4.0, 5.0, 6.0])) + +sim.InitializeSimulation() +sim.ConfigureStopTime(macros.sec2nano(1.0)) +sim.ExecuteSimulation() + +print("reset_called:", plugin.reset_called) +print("update_called:", plugin.update_called) +print("Last input :", list(plugin.last_input.dataVector)) +print("Last output:", list(plugin.last_output.dataVector))