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 module_name = module
132            .name()
133            .map(|n| n.to_string())
134            .unwrap_or_else(|_| descriptor.interface_name.to_string());
135        let method_callables = resolve_methods(module.as_any(), descriptor, &module_name)?;
136
137        Ok(PythonPluginHandle::new(
138            descriptor,
139            module.unbind().into(),
140            method_callables,
141        ))
142    })
143}
144
145/// Load a **configured** Python plugin instance (FIDIUS-A-0006 / CI.4): import the
146/// module, call its module-level `__fidius_configure__(config) -> instance` with
147/// the deserialized config, and bind methods on the returned instance — so the
148/// config is bound once and N differently-configured instances coexist (each is a
149/// distinct object). The module must export `__fidius_configure__`.
150pub fn load_python_plugin_configured(
151    package_dir: &Path,
152    descriptor: &'static PythonInterfaceDescriptor,
153    config: &serde_json::Value,
154) -> Result<PythonPluginHandle, PythonLoadError> {
155    ensure_initialized();
156
157    let manifest = load_manifest_untyped(package_dir)?;
158    if !matches!(manifest.package.runtime(), PackageRuntime::Python) {
159        return Err(PythonLoadError::NotPythonRuntime {
160            path: package_dir.display().to_string(),
161            got: manifest
162                .package
163                .runtime
164                .clone()
165                .unwrap_or_else(|| "rust".to_string()),
166        });
167    }
168    let py_meta =
169        manifest
170            .python
171            .as_ref()
172            .ok_or_else(|| PythonLoadError::MissingPythonSection {
173                path: package_dir.display().to_string(),
174            })?;
175
176    Python::with_gil(|py| {
177        prepend_sys_path(py, package_dir)?;
178        let module = py.import(py_meta.entry_module.as_str()).map_err(|e| {
179            PythonLoadError::ImportFailed {
180                module: py_meta.entry_module.clone(),
181                message: e.to_string(),
182            }
183        })?;
184        validate_interface_hash(&module, descriptor)?;
185
186        let cfg_obj = crate::value_bridge::value_to_pyobject(py, config).map_err(|e| {
187            PythonLoadError::ImportFailed {
188                module: py_meta.entry_module.clone(),
189                message: format!("config conversion failed: {e}"),
190            }
191        })?;
192        let instance = module
193            .getattr("__fidius_configure__")
194            .and_then(|f| f.call1((cfg_obj,)))
195            .map_err(|e| PythonLoadError::ImportFailed {
196                module: py_meta.entry_module.clone(),
197                message: format!("__fidius_configure__ failed: {e}"),
198            })?;
199
200        let ctx = format!("{} instance", py_meta.entry_module);
201        let method_callables = resolve_methods(&instance, descriptor, &ctx)?;
202        Ok(PythonPluginHandle::new(
203            descriptor,
204            instance.unbind(),
205            method_callables,
206        ))
207    })
208}
209
210/// Prepend `<dir>/vendor` and `<dir>` to `sys.path` if not already present.
211/// Both are pushed at index 0 so they shadow anything else with the same
212/// module name — important for the vendored-deps story.
213fn prepend_sys_path(py: Python<'_>, dir: &Path) -> Result<(), PythonLoadError> {
214    let sys = py.import("sys").map_err(|e| import_failure("sys", e))?;
215    let path_attr = sys
216        .getattr("path")
217        .map_err(|e| import_failure("sys.path", e))?;
218    let path: Bound<'_, PyList> = path_attr
219        .downcast::<PyList>()
220        .map_err(|e| PythonLoadError::ImportFailed {
221            module: "sys".into(),
222            message: format!("sys.path is not a list: {e}"),
223        })?
224        .clone();
225
226    let candidates: Vec<PathBuf> = vec![dir.join("vendor"), dir.to_path_buf()];
227
228    for candidate in candidates.into_iter().rev() {
229        let s = candidate.to_string_lossy().into_owned();
230        let already_present = path.iter().any(|item| {
231            item.extract::<String>()
232                .map(|existing| existing == s)
233                .unwrap_or(false)
234        });
235        if !already_present {
236            debug!(path = %s, "prepending to sys.path");
237            path.insert(0, &s)
238                .map_err(|e| import_failure("sys.path.insert", e))?;
239        }
240    }
241    Ok(())
242}
243
244fn validate_interface_hash(
245    module: &Bound<'_, PyModule>,
246    descriptor: &'static PythonInterfaceDescriptor,
247) -> Result<(), PythonLoadError> {
248    let attr = module
249        .getattr("__interface_hash__")
250        .map_err(|_| PythonLoadError::MissingAttr {
251            module: module.name().map(|n| n.to_string()).unwrap_or_default(),
252            attr: "__interface_hash__",
253        })?;
254    let got: u64 = attr.extract().map_err(|e| PythonLoadError::ImportFailed {
255        module: module.name().map(|n| n.to_string()).unwrap_or_default(),
256        message: format!("__interface_hash__ is not a u64: {e}"),
257    })?;
258    if got != descriptor.interface_hash {
259        return Err(PythonLoadError::InterfaceHashMismatch {
260            interface: descriptor.interface_name,
261            got,
262            expected: descriptor.interface_hash,
263        });
264    }
265    Ok(())
266}
267
268fn resolve_methods(
269    obj: &Bound<'_, PyAny>,
270    descriptor: &'static PythonInterfaceDescriptor,
271    ctx_name: &str,
272) -> Result<Vec<Py<PyAny>>, PythonLoadError> {
273    // Resolve callables by direct attribute lookup on `obj` — the loaded module
274    // (zero-config: the SDK's @method decorator returns the function unchanged,
275    // so module-attribute lookup is canonical) OR a configured instance returned
276    // by `__fidius_configure__` (FIDIUS-A-0006 / CI.4: bound methods on the
277    // instance, which closes over the bound config).
278    let module_name = ctx_name.to_string();
279
280    let mut callables = Vec::with_capacity(descriptor.methods.len());
281    for method in descriptor.methods {
282        let callable = obj
283            .getattr(method.name)
284            .map_err(|e| PythonLoadError::MethodNotRegistered {
285                interface: descriptor.interface_name,
286                method: method.name,
287                message: format!("module '{module_name}': {e}"),
288            })?
289            .unbind();
290        callables.push(callable);
291    }
292    Ok(callables)
293}
294
295fn import_failure(what: &str, err: PyErr) -> PythonLoadError {
296    let pe = pyerr_to_plugin_error(err);
297    PythonLoadError::ImportFailed {
298        module: what.to_string(),
299        message: pe.message,
300    }
301}