1use 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#[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
84pub 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
141fn 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 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}