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 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
145pub 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
210fn 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 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}