Skip to content

Your First Python Fidius Plugin

Fidius supports Python as a plugin language. The plugin author writes Python — no Rust, no Cargo, no per-architecture builds — and the host loads it through the same PluginHost API used for Rust cdylib plugins.

This tutorial walks through building a Python plugin that implements an existing Rust-defined interface, packaging it, and loading it from a host.

Prerequisites

  • A Rust interface crate that uses #[plugin_interface] (see Your First Plugin for how to write one).
  • python3 on the host machine (system Python is fine; bundling the interpreter is not required for v1).
  • The fidius CLI installed: cargo install fidius-cli.
  • The host binary built with the python feature on fidius-host: [dependencies] fidius-host = { version = "0.2", features = ["python"] }.

What you will build

my-greeter-py/
├── package.toml         # declares runtime = "python"
├── greeter.py           # the actual plugin code
├── requirements.txt     # optional pip dependencies
└── vendor/              # populated by `fidius pack`
    └── ...

The deployable artifact is a .fid archive containing the directory above. Hosts drop it into a search path; PluginHost::load_python(...) returns a PythonPluginHandle ready to dispatch.

Step 1: Generate the Python stub

If your interface crate's source is at crates/greeter/src/lib.rs and declares a Greeter trait with #[plugin_interface], generate the stub:

fidius python-stub \
    --interface crates/greeter/src/lib.rs \
    --trait-name Greeter \
    --out my-greeter-py/greeter.py

The stub gives you:

  • __interface_hash__ = 0x... — the constant the host uses at load time to verify your plugin matches the interface it claims to implement.
  • One @method-decorated stub function per trait method, with type hints from a Rust → Python primitive mapping table.

Open the file and replace each raise NotImplementedError with your implementation. Don't change the __interface_hash__ constant or the function signatures — drift from the trait surfaces as a load-time error.

A finished greeter.py might look like:

from fidius import method, PluginError

__interface_hash__ = 0xdeadbeefcafef00d  # from `fidius python-stub`

@method
def greet(name: str) -> str:
    if not name:
        raise PluginError("EMPTY_INPUT", "name must be non-empty")
    return f"Hello, {name}!"

Step 2: Write the manifest

Create my-greeter-py/package.toml:

[package]
name = "my-greeter"
version = "0.1.0"
interface = "Greeter"
interface_version = 1
runtime = "python"

[metadata]
description = "A friendly greeting service."

[python]
entry_module = "greeter"
# requirements = "requirements.txt"  # uncomment if you have dependencies

The runtime = "python" field tells the host's discover() and load_python(...) to route this package through the Python loader.

Step 3: (Optional) Declare Python dependencies

If your plugin uses third-party libraries (numpy, pyarrow, anything from PyPI), list them in my-greeter-py/requirements.txt:

numpy>=1.26
pyarrow>=14.0

fidius pack will run pip install -r requirements.txt --target vendor/ during packaging, baking the deps into the archive. Pre-existing vendor/ directories are respected (use this if you want hand-controlled or reproducible-build vendoring).

Step 4: Pack the plugin

fidius package pack ./my-greeter-py

This produces my-greeter-0.1.0.fid — a single self-contained archive including your .py file, the manifest, and vendor/ if you declared dependencies.

Step 5: (Optional) Sign the plugin

fidius keygen --out mykey
fidius package sign --key mykey.secret ./my-greeter-py
fidius package pack ./my-greeter-py  # repack to include the .sig

Signed plugins can be verified by the host before load via verify_package.

Step 6: Load and call from the host

In the host's Rust code:

use fidius_host::PluginHost;
// The descriptor is generated by your interface crate's #[plugin_interface]
// macro — it lives in the companion module alongside the cdylib metadata.
use greeter_interface::__fidius_Greeter::Greeter_PYTHON_DESCRIPTOR;

let host = PluginHost::builder()
    .search_path("./plugins")
    .build()?;

let handle = host.load_python("my-greeter", &Greeter_PYTHON_DESCRIPTOR)?;

// Call: methods are looked up by index in the descriptor's order. The host
// will gain a typed Client wrapper for Python plugins in a future release;
// today you call through the handle directly.
let input = serde_json::to_vec(&("World".to_string(),))?;
let out = handle.call_typed_json(0, &input)?;  // index 0 = greet
let result: String = serde_json::from_slice(&out)?;
println!("{result}");  // → "Hello, World!"

For methods declared #[wire(raw)] on the Rust trait, use call_raw:

let bytes_out = handle.call_raw(method_index, &input_bytes)?;

Constraint: shared sys.modules

All Python plugins loaded into one host process share the embedded Python interpreter's sys.modules. If two plugins vendor conflicting versions of the same library (e.g. numpy==1.24 vs numpy==1.26), the first one to load wins for both. Subsequent plugins silently see the wrong version.

Treat this as a deployment constraint: coordinate dependency versions across the Python plugins a host loads, the same way you would for any Python application embedding multiple subpackages. A future fidius release may use CPython sub-interpreters (PEP 684) to isolate per-plugin sys.modules, but for v1 it's the operator's responsibility.

The python feature on fidius-host transitively links libpython via PyO3. On macOS framework builds, your binary needs an rpath pointing at the framework directory or it will abort at launch with:

dyld: Library not loaded: @rpath/Python3.framework/Versions/3.X/Python3

The fix is a small build.rs in your binary crate that uses pyo3-build-config to emit the right linker arg. See crates/fidius-host/build.rs in the fidius repo for a working example that's already feature-gated on CARGO_FEATURE_PYTHON.

What's not yet supported

  • Process isolation. Python plugins run in the same address space as the host. A segfault in plugin code (or in a misbehaving native dep) takes the host down with it. A future Process tier with subprocess workers and kill-on-deadline timeouts is the natural next step for the untrusted-plugin use case.
  • Built-in timeouts. PluginHost::load_python(...).call_* runs to completion. If you need a watchdog, wrap the host process itself.
  • Hot reload. Reloading a plugin requires a host restart; Python's sys.modules cache makes in-process re-import unreliable.
  • Typed Client wrapper. Today you dispatch through the PythonPluginHandle directly. A future enhancement will let the same generated Client type backed by either a cdylib or a Python plugin transparently.