Writing plugins¶
TLDR
A minisky plugin is just an ordinary Python package that exports a Plugin object and advertises it through the minisky.plugins Python entry-point group. Specify a build function that mounts Python objects through PluginContext.mount. Mounted objects can define stack commands with @command, simulation callbacks with @hook, and per-aircraft arrays with Entity. Provide alternative simulator implementations with @replacement.
To validate plugin-specific configuration under the [plugins.<plugin_id>] table, set Plugin.config_class to a pydantic BaseModel. To manage external resources, use an async context manager.
First, scaffold out a new Python library:
uv init --lib minisky-example
from minisky import Plugin, PluginContext, PluginSpec
class Example:
pass
def build(context: PluginContext) -> PluginSpec:
context.mount(Example()) # (2)!
return context.finish()
plugin = Plugin(build=build) # (1)!
Plugindescribes how the package creates its plugin. Itsbuildfunction is called each time aMiniSkyruntime loads the plugin.context.mount(...)attaches an object to the runtime. Note that here, we create theExample()object inside thebuildfunction so multiple minisky runtimes can have their own independent state.
Register the plugin¶
Now that the plugin is defined, we need to advertise its location to minisky. Define a new entry point in your pyproject.toml:
[project.entry-points."minisky.plugins"]
example = "minisky_example:plugin"
The left-hand side, example, is the plugin ID, used in the PLUGIN LOAD <plugin_id> stack command and the user configuration TOML.
The right-hand side should point to the Python object we defined above.
Configuration¶
minisky's configuration TOML supports defining plugin-specific configuration.
Suppose you want to accept the following in the user TOML:
[plugins.example]
message = "hello from my plugin :)"
Define the shape with a Pydantic BaseModel:
from minisky import Plugin, PluginContext, PluginSpec
from pydantic import BaseModel, ConfigDict
class ExampleConfig(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
message: str
class Example:
def __init__(self, config: ExampleConfig) -> None:
self.config = config
def build(context: PluginContext[ExampleConfig]) -> PluginSpec:
context.mount(Example(context.config))
return context.finish()
plugin = Plugin(build=build, config_class=ExampleConfig) # (1)!
- When you specify
config_class=ExampleConfig, minisky will first validate the plugin's configuration using Pydantic. The validated configuration can then be accessed in thebuildfunction viacontext.config.
Commands¶
To add a new stack command, use the @command decorator. As a simple example:
from minisky import Ok, Result, command
class Example:
def __init__(self, config: ExampleConfig) -> None:
self.config = config
@command
def hello(self) -> Result[str, str]:
"""Print the configured message."""
return Ok(self.config.message)
> PLUGIN LOAD EXAMPLE
Successfully loaded plugin EXAMPLE
> HELLO
hello from my plugin
More details (the decorator, parser, argument types, validation, generated documentation) are covered in the command developer guide.
Simulation hooks¶
To run code automatically as the simulation advances, hook plugin methods into the simulation loop with minisky.hook.
For example, to run a method once during every normal update:
from minisky import hook
class Example:
@hook # (1)!
def update(self) -> None:
self.nupdates += 1
- With no argument,
@hookuses the method name as the hook name. A method namedupdate()attaches to minisky'supdatephase.
Or periodically:
class Example:
@hook("update", interval=5.0) # (1)!
def sample(self, dt: float) -> None:
self.elapsed += dt
- Here,
interval=5.0runssample()every five simulation seconds. Adtparameter receives the simulated time elapsed since the previous call.
Per-aircraft state¶
Warning
This API may be confusing to use; a new one is currently being designed.
minisky stores aircraft state with the structure-of-arrays model. The \(i\)th row of each numpy array refers to an aircraft.
To manipulate these arrays, inherit from Entity and use settrafarrays() to register arrays or lists as per-aircraft state:
import numpy as np
from minisky import Entity
class Example(Entity):
def __init__(self) -> None:
super().__init__()
with self.settrafarrays(): # (1)!
self.npassengers = np.array([], dtype=int)
def create(self, n: int = 1) -> None:
super().create(n) # (2)!
self.npassengers[-n:] = 0
Here, Entity.create() overrides the base class behaviour. The arrays can grow and shrink as aircraft are added, removed or reset.
Remember to mount the entity in the Plugin.build function. The self.traffic object will then be available after attachment.
Detailed example
class Example(Entity):
"""Track passenger count for every aircraft in the owning runtime."""
def __init__(self, random: Random) -> None:
super().__init__()
self.random = random
self.updates = 0
with self.settrafarrays():
self.npassengers = np.array([])
def create(self, n: int = 1) -> None:
super().create(n)
self.npassengers[-n:] = [self.random.randint(50, 250) for _ in range(n)]
@hook(interval=5.0)
def update(self) -> None:
"""Count periodic execution."""
self.updates += 1
@command(name="PASSENGERS")
def passenger_count(self, index: AcId) -> Result[str, str]:
"""Show the number of passengers on an aircraft."""
callsign = self.traffic.callsign[index]
return Ok(f"Aircraft {callsign} has {int(self.npassengers[index])} passengers")
@command(name="PASSENGERS")
def set_passenger_count(
self, index: AcId, count: Annotated[int, Ge(0), Le(500)]
) -> Result[str, str]:
"""Set the number of passengers on an aircraft."""
callsign = self.traffic.callsign[index]
self.npassengers[index] = count
return Ok(f"Set {callsign} passengers to {count}")
External resources¶
In some cases, you may want to configure resources (e.g. httpx.AsyncClient or background threads) on plugin startup, and shut it down cleanly. To do so, use an async context manager, for example:
@asynccontextmanager
async def lifespan(runtime: PluginRuntime):
bridge.start(runtime) # (1)!
try:
yield # (2)!
finally:
bridge.stop() # (3)!
return context.finish(lifespan=lifespan) # (4)!
- This starts the external resource as the plugin starts up.
- Execution is suspended here (plugin continues to load).
- Run any cleanup here during plugin shutdown.
context.finish()attaches the lifespan to this plugin.
Here, code before yield runs during plugin startup, and cleans up after yield runs during plugin shutdown.
Detailed example in the tangram plugin
def build(context: PluginContext[TangramConfig]) -> PluginSpec:
bridge = context.mount(
TangramBridge(
redis_url=context.config.redis_url,
channel=context.config.channel,
max_hz=context.config.max_hz,
)
)
@asynccontextmanager
async def lifespan(runtime: PluginRuntime) -> AsyncGenerator[None]:
runtime.subscribe_console(bridge.capture_console)
match bridge.start(runtime):
case Ok(message):
runtime.echo(message)
case Err(message):
runtime.echo(message)
raise RuntimeError(message)
try:
yield
finally:
bridge.stop()
return context.finish(lifespan=lifespan)
plugin = Plugin(build=build, config_class=TangramConfig)
Replacing a simulator component¶
Warning
This is a legacy API: it does not permit multiple performance models to coexist, making multi-plugin coordination difficult. It is currently being redesigned and may change without notice.
Most plugins add behavior alongside minisky. However, in some cases you may want to replace an entire core implementation. For example, to override the internal autopilot implementation:
@replacement # (1)!
class CustomAutoPilot(Autopilot):
def update(self) -> None:
super().update()
self.new_variable += 1
def build(context: PluginContext) -> PluginSpec:
return context.finish(replacements=(CustomAutoPilot,)) # (2)!
@replacementdeclaresCustomAutoPilotas an alternative implementation of its supported base component.- Pass the class to
replacements=to advertise it to the core.
Users can then select an implementation with the SELECTIMPL stack command:
SELECTIMPL AUTOPILOT CUSTOMAUTOPILOT