Skip to main content

fidius_python/
loader.rs

1// Copyright 2026 Colliery, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Load a Python plugin package and produce a `PythonPluginHandle` whose
16//! method-index map lets the host dispatch by index just like the cdylib
17//! path.
18//!
19//! Loading steps:
20//!
21//! 1. Read the package's `package.toml` and assert `runtime = "python"`.
22//! 2. Prepend `<dir>/vendor` and `<dir>` to `sys.path` (idempotent — repeated
23//!    loads of the same package don't insert twice).
24//! 3. Import the entry module declared in `[python].entry_module`.
25//! 4. Validate the module's `__interface_hash__` constant against the
26//!    descriptor passed by the host. Mismatch = clean load error.
27//! 5. Look up each method by name (in the descriptor's order) so vtable
28//!    indices resolve to Python callables at call time.
29//!
30//! What we don't do here: subprocess spawning, venv creation, or cancellation.
31//! All Python work happens in the host's embedded interpreter (T-0085).
32
33use std::path::{Path, PathBuf};
34
35use fidius_core::package::{load_manifest_untyped, PackageRuntime};
36use fidius_core::python_descriptor::PythonInterfaceDescriptor;
37use pyo3::prelude::*;
38use pyo3::types::{PyAnyMethods, PyList};
39use tracing::{debug, info};
40
41use crate::error::pyerr_to_plugin_error;
42use crate::handle::PythonPluginHandle;
43use crate::interpreter::ensure_initialized;
44
45/// Errors that can happen during Python plugin load.
46#[derive(Debug, thiserror::Error)]
47pub enum PythonLoadError {
48    #[error("manifest error: {0}")]
49    Manifest(#[from] fidius_core::package::PackageError),
50
51    #[error(
52        "package at {path} is not a Python plugin (manifest runtime is \"{got}\", not \"python\")"
53    )]
54    NotPythonRuntime { path: String, got: String },
55
56    #[error("package at {path} is missing the [python] section")]
57    MissingPythonSection { path: String },
58
59    #[error("entry module '{module}' import failed: {message}")]
60    ImportFailed { module: String, message: String },
61
62    #[error(
63        "interface hash mismatch for trait '{interface}': package declares {got:#018x}, host expects {expected:#018x}"
64    )]
65    InterfaceHashMismatch {
66        interface: &'static str,
67        got: u64,
68        expected: u64,
69    },
70
71    #[error("entry module '{module}' is missing required attribute '{attr}'")]
72    MissingAttr { module: String, attr: &'static str },
73
74    #[error(
75        "method '{method}' on trait '{interface}' is not registered in the entry module: {message}"
76    )]
77    MethodNotRegistered {
78        interface: &'static str,
79        method: &'static str,
80        message: String,
81    },
82}
83
84/// Load a Python plugin package against a static interface descriptor.
85///
86/// `package_dir` must point at an unpacked Python plugin directory (the
87/// thing `unpack_package` returns or that lives next to a `package.toml`
88/// during local dev).
89pub fn load_python_plugin(
90    package_dir: &Path,
91    descriptor: &'static PythonInterfaceDescriptor,
92) -> Result<PythonPluginHandle, PythonLoadError> {
93    ensure_initialized();
94
95    let manifest = load_manifest_untyped(package_dir)?;
96    if !matches!(manifest.package.runtime(), PackageRuntime::Python) {
97        return Err(PythonLoadError::NotPythonRuntime {
98            path: package_dir.display().to_string(),
99            got: manifest
100                .package
101                .runtime
102                .clone()
103                .unwrap_or_else(|| "rust".to_string()),
104        });
105    }
106    let py_meta =
107        manifest
108            .python
109            .as_ref()
110            .ok_or_else(|| PythonLoadError::MissingPythonSection {
111                path: package_dir.display().to_string(),
112            })?;
113
114    info!(
115        package = %package_dir.display(),
116        interface = descriptor.interface_name,
117        entry_module = py_meta.entry_module,
118        "loading python plugin"
119    );
120
121    Python::with_gil(|py| {
122        prepend_sys_path(py, package_dir)?;
123        let module = py.import(py_meta.entry_module.as_str()).map_err(|e| {
124            PythonLoadError::ImportFailed {
125                module: py_meta.entry_module.clone(),
126                message: e.to_string(),
127            }
128        })?;
129
130        validate_interface_hash(&module, descriptor)?;
131        let method_callables = resolve_methods(&module, descriptor)?;
132
133        Ok(PythonPluginHandle::new(
134            descriptor,
135            module.unbind().into(),
136            method_callables,
137        ))
138    })
139}
140
141/// Prepend `<dir>/vendor` and `<dir>` to `sys.path` if not already present.
142/// Both are pushed at index 0 so they shadow anything else with the same
143/// module name — important for the vendored-deps story.
144fn prepend_sys_path(py: Python<'_>, dir: &Path) -> Result<(), PythonLoadError> {
145    let sys = py.import("sys").map_err(|e| import_failure("sys", e))?;
146    let path_attr = sys
147        .getattr("path")
148        .map_err(|e| import_failure("sys.path", e))?;
149    let path: Bound<'_, PyList> = path_attr
150        .downcast::<PyList>()
151        .map_err(|e| PythonLoadError::ImportFailed {
152            module: "sys".into(),
153            message: format!("sys.path is not a list: {e}"),
154        })?
155        .clone();
156
157    let candidates: Vec<PathBuf> = vec![dir.join("vendor"), dir.to_path_buf()];
158
159    for candidate in candidates.into_iter().rev() {
160        let s = candidate.to_string_lossy().into_owned();
161        let already_present = path.iter().any(|item| {
162            item.extract::<String>()
163                .map(|existing| existing == s)
164                .unwrap_or(false)
165        });
166        if !already_present {
167            debug!(path = %s, "prepending to sys.path");
168            path.insert(0, &s)
169                .map_err(|e| import_failure("sys.path.insert", e))?;
170        }
171    }
172    Ok(())
173}
174
175fn validate_interface_hash(
176    module: &Bound<'_, PyModule>,
177    descriptor: &'static PythonInterfaceDescriptor,
178) -> Result<(), PythonLoadError> {
179    let attr = module
180        .getattr("__interface_hash__")
181        .map_err(|_| PythonLoadError::MissingAttr {
182            module: module.name().map(|n| n.to_string()).unwrap_or_default(),
183            attr: "__interface_hash__",
184        })?;
185    let got: u64 = attr.extract().map_err(|e| PythonLoadError::ImportFailed {
186        module: module.name().map(|n| n.to_string()).unwrap_or_default(),
187        message: format!("__interface_hash__ is not a u64: {e}"),
188    })?;
189    if got != descriptor.interface_hash {
190        return Err(PythonLoadError::InterfaceHashMismatch {
191            interface: descriptor.interface_name,
192            got,
193            expected: descriptor.interface_hash,
194        });
195    }
196    Ok(())
197}
198
199fn resolve_methods(
200    module: &Bound<'_, PyModule>,
201    descriptor: &'static PythonInterfaceDescriptor,
202) -> Result<Vec<Py<PyAny>>, PythonLoadError> {
203    // Resolve callables by direct attribute lookup on the loaded module.
204    // The fidius SDK's @method decorator returns the function unchanged
205    // (it's a registration-only marker), so module-attribute lookup is the
206    // canonical way to find a callable. Skipping the SDK registry here also
207    // avoids the cross-module ambiguity that arises when many plugins
208    // share the embedded interpreter.
209    let module_name = module
210        .name()
211        .map(|n| n.to_string())
212        .unwrap_or_else(|_| descriptor.interface_name.to_string());
213
214    let mut callables = Vec::with_capacity(descriptor.methods.len());
215    for method in descriptor.methods {
216        let callable = module
217            .getattr(method.name)
218            .map_err(|e| PythonLoadError::MethodNotRegistered {
219                interface: descriptor.interface_name,
220                method: method.name,
221                message: format!("module '{module_name}': {e}"),
222            })?
223            .unbind();
224        callables.push(callable);
225    }
226    Ok(callables)
227}
228
229fn import_failure(what: &str, err: PyErr) -> PythonLoadError {
230    let pe = pyerr_to_plugin_error(err);
231    PythonLoadError::ImportFailed {
232        module: what.to_string(),
233        message: pe.message,
234    }
235}