Skip to content

Commit dba662f

Browse files
Test simple plugin 1
1 parent 7e51e74 commit dba662f

File tree

9 files changed

+421
-0
lines changed

9 files changed

+421
-0
lines changed

example-plugin/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Basilisk Example Plugin
2+
3+
This standalone package demonstrates how third-party projects can integrate with
4+
the Basilisk plugin system using Python entry points.
5+
6+
## Installation
7+
8+
```bash
9+
pip install -e ./example-plugin
10+
```
11+
12+
The package advertises the `basilisk.plugins` entry point group so it is
13+
automatically discovered when Basilisk loads plugins at runtime.
14+
15+
## Provided Module
16+
17+
The `ExamplePluginModule` class is a minimal `SysModel` implementation. The
18+
`register(registry)` function publishes this class to Basilisk, allowing it to
19+
be accessed from `Basilisk.modules`.
20+
21+
This project is intended purely for testing and documentation purposes.

example-plugin/pyproject.toml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[build-system]
2+
requires = ["setuptools>=70"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "bsk-example-plugin"
7+
version = "0.1.0"
8+
description = "Example Basilisk plugin showcasing Python-based registration."
9+
readme = "README.md"
10+
requires-python = ">=3.8"
11+
dependencies = ["bsk"]
12+
authors = [{ name = "Basilisk Developers" }]
13+
license = { text = "ISC" }
14+
15+
[project.entry-points."basilisk.plugins"]
16+
example = "bsk_example_plugin.simple:register"
17+
18+
[tool.setuptools.packages.find]
19+
where = ["src"]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""
2+
Example Basilisk plugin used for integration and documentation.
3+
"""
4+
5+
from .simple import ExamplePluginModule, register
6+
7+
__all__ = ["ExamplePluginModule", "register"]
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""
2+
Minimal Basilisk plugin used to demonstrate Python-based registration.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
from typing import TYPE_CHECKING
8+
9+
from Basilisk.architecture import sysModel
10+
11+
if TYPE_CHECKING: # pragma: no cover - import guard for typing tools
12+
from bsk_core.plugins import PluginRegistry
13+
14+
15+
class ExamplePluginModule(sysModel.SysModel):
16+
"""Small SysModel derivative that toggles flags when executed."""
17+
18+
def __init__(self):
19+
super().__init__()
20+
self.reset_called = False
21+
self.update_called = False
22+
23+
def Reset(self, current_sim_nanos):
24+
self.reset_called = True
25+
26+
def UpdateState(self, current_sim_nanos, call_time):
27+
self.update_called = True
28+
29+
30+
def register(registry: "PluginRegistry") -> None:
31+
"""Entry point invoked by Basilisk to register this module."""
32+
33+
registry.register_python_module("ExamplePluginModule", ExamplePluginModule)
34+
35+
36+
__all__ = ["ExamplePluginModule", "register"]

src/Basilisk/modules/__init__.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""
2+
Dynamic plugin namespace for Basilisk runtime modules.
3+
4+
This module defers plugin discovery until attributes are accessed. Plugins are
5+
expected to register their SysModel subclasses or factories via the global
6+
registry that lives in :mod:`bsk_core.plugins`.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from typing import Any, Iterable
12+
13+
from bsk_core.plugins import GLOBAL_REGISTRY, load_all_plugins
14+
15+
__all__ = ["GLOBAL_REGISTRY", "load_all_plugins"]
16+
17+
18+
def _known_attribute_names() -> Iterable[str]:
19+
load_all_plugins()
20+
return tuple(set(GLOBAL_REGISTRY.py_modules) | set(GLOBAL_REGISTRY.factories))
21+
22+
23+
def __getattr__(name: str) -> Any:
24+
load_all_plugins()
25+
26+
if name in GLOBAL_REGISTRY.py_modules:
27+
return GLOBAL_REGISTRY.py_modules[name]
28+
29+
if name in GLOBAL_REGISTRY.factories:
30+
return GLOBAL_REGISTRY.factories[name]
31+
32+
raise AttributeError(f"module 'Basilisk.modules' has no attribute '{name}'")
33+
34+
35+
def __dir__() -> list[str]:
36+
return sorted(set(globals()) - {"__builtins__"} | set(_known_attribute_names()))

src/CMakeLists.txt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,19 @@ else()
731731
" __version__ = '0.0.0'\n")
732732
endif()
733733

734+
file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/Basilisk/modules")
735+
configure_file(
736+
"${CMAKE_SOURCE_DIR}/Basilisk/modules/__init__.py"
737+
"${CMAKE_BINARY_DIR}/Basilisk/modules/__init__.py"
738+
COPYONLY
739+
)
740+
741+
file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/bsk_core")
742+
file(GLOB BSK_CORE_SOURCES "${CMAKE_SOURCE_DIR}/bsk_core/*.py" "${CMAKE_SOURCE_DIR}/bsk_core/**")
743+
if(BSK_CORE_SOURCES)
744+
create_symlinks("${CMAKE_BINARY_DIR}/bsk_core" ${BSK_CORE_SOURCES})
745+
endif()
746+
734747
# TODO: Iterate through all dist directories and add __init__.py's where they don't exist
735748
file(
736749
GLOB_RECURSE DIST_DIRECTORIES

src/bsk_core/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""
2+
Lightweight core helpers that are shared across Basilisk's Python surface.
3+
4+
The plugin system implemented in :mod:`bsk_core.plugins` is responsible for
5+
discovering entry points and exposing their registrations to the runtime.
6+
"""
7+
8+
from .plugins import GLOBAL_REGISTRY, PluginRegistry, load_all_plugins
9+
10+
__all__ = ["GLOBAL_REGISTRY", "PluginRegistry", "load_all_plugins"]

src/bsk_core/plugins.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""
2+
Runtime plugin registration support for Basilisk.
3+
4+
Only Python-based registration is implemented today. C++ factories are stubbed
5+
out to support future extensions without breaking the public API.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from importlib import metadata
11+
from typing import Any, Callable, Iterable, Optional
12+
13+
from Basilisk.architecture import sysModel
14+
15+
ENTRY_POINT_GROUP = "basilisk.plugins"
16+
"""Python entry point group used to discover Basilisk plugins."""
17+
18+
19+
class PluginRegistry:
20+
"""Container for Basilisk plugin registrations."""
21+
22+
def __init__(self) -> None:
23+
self.py_modules: dict[str, type[sysModel.SysModel]] = {}
24+
self.factories: dict[str, Any] = {}
25+
26+
def register_python_module(self, name: str, cls: type[sysModel.SysModel]) -> None:
27+
"""Register a Python :class:`~Basilisk.architecture.sysModel.SysModel` subclass."""
28+
if not isinstance(name, str) or not name:
29+
raise TypeError("Module name must be a non-empty string")
30+
if not isinstance(cls, type):
31+
raise TypeError("Only classes can be registered as Python modules")
32+
33+
try:
34+
is_sysmodel = issubclass(cls, sysModel.SysModel)
35+
except TypeError as exc: # cls is not a class or similar edge cases
36+
raise TypeError("Only SysModel subclasses can be registered") from exc
37+
38+
if not is_sysmodel:
39+
raise TypeError(
40+
f"Cannot register {cls!r} as '{name}': not a SysModel subclass"
41+
)
42+
43+
existing = self.py_modules.get(name)
44+
if existing is not None and existing is not cls:
45+
raise ValueError(
46+
f"Module name '{name}' already registered with {existing!r}"
47+
)
48+
49+
self.py_modules[name] = cls
50+
51+
def register_factory(self, name: str, factory: Any) -> None:
52+
"""
53+
Register a future C++ factory.
54+
55+
No validation is performed yet; factories act as opaque callables or
56+
objects until C++ support is implemented.
57+
"""
58+
if not isinstance(name, str) or not name:
59+
raise TypeError("Factory name must be a non-empty string")
60+
61+
existing = self.factories.get(name)
62+
if existing is not None and existing is not factory:
63+
raise ValueError(f"Factory name '{name}' already registered")
64+
65+
self.factories[name] = factory
66+
67+
68+
GLOBAL_REGISTRY = PluginRegistry()
69+
"""Shared registry instance used across the Basilisk runtime."""
70+
71+
_PLUGINS_LOADED = False
72+
73+
74+
def _iter_plugin_entry_points() -> Iterable[metadata.EntryPoint]:
75+
"""Return an iterable over all registered plugin entry points."""
76+
entry_points = metadata.entry_points()
77+
if hasattr(entry_points, "select"):
78+
return entry_points.select(group=ENTRY_POINT_GROUP)
79+
return entry_points.get(ENTRY_POINT_GROUP, [])
80+
81+
82+
def _resolve_register_callable(obj: Any) -> Callable[[PluginRegistry], None]:
83+
"""Normalize the value advertised by an entry point into a register callable."""
84+
if callable(obj):
85+
return obj # The entry point points directly to register()
86+
87+
register = getattr(obj, "register", None)
88+
if callable(register):
89+
return register
90+
91+
raise TypeError(
92+
"Basilisk plugin entry points must reference a callable or an object with "
93+
"a callable 'register' attribute"
94+
)
95+
96+
97+
def load_all_plugins(registry: Optional[PluginRegistry] = None) -> PluginRegistry:
98+
"""
99+
Discover and register all Basilisk plugins using ``importlib.metadata``.
100+
101+
The discovery process is idempotent; repeated calls do not re-register
102+
plugins.
103+
"""
104+
global _PLUGINS_LOADED
105+
106+
if registry is None:
107+
registry = GLOBAL_REGISTRY
108+
109+
if _PLUGINS_LOADED:
110+
return registry
111+
112+
for entry_point in _iter_plugin_entry_points():
113+
register = _resolve_register_callable(entry_point.load())
114+
register(registry)
115+
116+
_PLUGINS_LOADED = True
117+
return registry
118+
119+
120+
__all__ = ["GLOBAL_REGISTRY", "PluginRegistry", "load_all_plugins"]

0 commit comments

Comments
 (0)