1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
//! Plugin package loading from entry points
//!
//! This module handles discovering and loading plugin packages from Python
//! entry points, with both fast (direct import) and slow (importlib.metadata) paths.
use crate::errors::BridgeError;
use r2x_config::Config;
use r2x_logger as logger;
use pyo3::prelude::*;
use pyo3::types::PyModule;
use std::path::PathBuf;
use std::time::SystemTime;
impl crate::python_bridge::Bridge {
/// Load plugin package metadata from the entry point
///
/// Each plugin package exposes an r2x_plugin entry point that returns a Package object.
/// This method:
/// 1. Discovers the r2x_plugin entry point for a package name
/// 2. Calls the entry point to get a Package object
/// 3. Serializes Package to JSON via model_dump_json()
/// 4. Returns the JSON string for Rust deserialization
///
/// # Arguments
/// * `package_name` - Name of the package (e.g., "reeds" for r2x-reeds)
///
/// # Returns
/// JSON string containing the Package object structure
pub fn load_plugin_package(&self, package_name: &str) -> Result<String, BridgeError> {
let load_start = std::time::Instant::now();
// Convert package name format: "reeds" -> "r2x_reeds"
// Normalize hyphens to underscores for Python package names (PEP 503)
let normalized_package_name = package_name.replace('-', "_");
let full_package_name = format!("r2x_{}", normalized_package_name);
logger::debug(&format!(
"Attempting fast path for package: {} (full name: {})",
package_name, full_package_name
));
// Try the fast path first
if let Some(result) =
Self::load_plugin_package_fast(&normalized_package_name, &full_package_name)
{
logger::debug(&format!(
"Fast path succeeded (took: {:?})",
load_start.elapsed()
));
return result;
}
logger::debug("Fast path failed, falling back to slow path");
// Fall back to slow path using importlib.metadata
let slow_path_start = std::time::Instant::now();
let result = Python::attach(|py| {
// Import importlib.metadata
let metadata = PyModule::import(py, "importlib.metadata").map_err(|e| {
BridgeError::Import("importlib.metadata".to_string(), format!("{}", e))
})?;
// Get entry_points function
let entry_points_func = metadata.getattr("entry_points")?;
let eps = entry_points_func.call0()?;
// Select r2x_plugin group
let kwargs = pyo3::types::PyDict::new(py);
kwargs.set_item("group", "r2x_plugin")?;
let plugin_eps = eps.call_method("select", (), Some(&kwargs))?;
// Find entry point matching package name (normalized with underscores)
let mut found_ep = None;
let iterator = plugin_eps.try_iter()?;
for ep_result in iterator {
let ep = ep_result?;
let name = ep.getattr("name")?.extract::<String>()?;
if name == normalized_package_name {
found_ep = Some(ep);
break;
}
}
let ep = match found_ep {
Some(e) => e,
None => {
return Err(BridgeError::PluginNotFound(format!(
"r2x_plugin entry point not found for package: {} (normalized: {})",
package_name, normalized_package_name
)))
}
};
// Load and call the entry point function
let register_fn = ep.call_method0("load")?;
let package_obj = register_fn.call0()?;
// Serialize Package to JSON
let model_dump_json = package_obj.getattr("model_dump_json")?;
let json_str = model_dump_json.call0()?.extract::<String>()?;
Ok(json_str)
})?;
logger::debug(&format!(
"Slow path took: {:?}, total load time: {:?}",
slow_path_start.elapsed(),
load_start.elapsed()
));
Ok(result)
}
/// Load plugin package directly from entry point without discovery
///
/// This is a fast path that bypasses importlib.metadata discovery by:
/// 1. Reading entry_points.txt from the installed package's dist-info
/// 2. Parsing it to find the entry point module and function
/// 3. Calling the function directly
///
/// Falls back to the slow discovery path if needed.
fn load_plugin_package_fast(
_package_name: &str,
full_package_name: &str,
) -> Option<Result<String, BridgeError>> {
// Try to find and parse entry_points.txt
let parse_start = std::time::Instant::now();
let ep_info = Self::parse_entry_point_from_dist_info(full_package_name)?;
logger::debug(&format!(
"parse_entry_point_from_dist_info took: {:?}",
parse_start.elapsed()
));
logger::debug(&format!("Parsed entry point: {}", ep_info));
Python::attach(|py| {
// Parse module:function format
let parts: Vec<&str> = ep_info.split(':').collect();
if parts.len() != 2 {
return Some(Err(BridgeError::InvalidEntryPoint(format!(
"Invalid entry point format: {}",
ep_info
))));
}
let module_name = parts[0];
let func_name = parts[1];
logger::debug(&format!(
"Importing module '{}' and calling function '{}'",
module_name, func_name
));
// Directly import and call the function
let result = (|| -> Result<String, BridgeError> {
let wall_start = SystemTime::now();
let import_start = std::time::Instant::now();
let module = PyModule::import(py, module_name)
.map_err(|e| BridgeError::Import(module_name.to_string(), format!("{}", e)))?;
let wall_elapsed = wall_start.elapsed().unwrap_or_default();
logger::debug(&format!(
"PyModule::import took: {:?} (Instant), {:?} (SystemTime)",
import_start.elapsed(),
wall_elapsed
));
let getattr_start = std::time::Instant::now();
let func = module.getattr(func_name).map_err(|_| {
BridgeError::PluginNotFound(format!(
"Function '{}' not found in module '{}'",
func_name, module_name
))
})?;
logger::debug(&format!(
"module.getattr took: {:?}",
getattr_start.elapsed()
));
let call_start = std::time::Instant::now();
let package_obj = func.call0()?;
logger::debug(&format!("func.call0() took: {:?}", call_start.elapsed()));
// Serialize Package to JSON
let serialize_start = std::time::Instant::now();
let model_dump_json = package_obj.getattr("model_dump_json")?;
let json_str = model_dump_json.call0()?.extract::<String>()?;
logger::debug(&format!(
"Serialization took: {:?}",
serialize_start.elapsed()
));
Ok(json_str)
})();
Some(result)
})
}
/// Parse entry point from dist-info/entry_points.txt
fn parse_entry_point_from_dist_info(full_package_name: &str) -> Option<String> {
use std::fs;
// Get the venv path from config
let config = Config::load().ok()?;
let venv_path = PathBuf::from(config.get_venv_path());
logger::debug(&format!(
"Looking for entry_points.txt for package: {}",
full_package_name
));
logger::debug(&format!(
"Venv path: {}",
venv_path.display()
));
// Find site-packages directory using centralized resolver
let site_packages_path = match crate::utils::resolve_site_package_path(&venv_path) {
Ok(path) => {
logger::debug(&format!(
"Found site-packages at: {}",
path.display()
));
path
}
Err(e) => {
logger::debug(&format!(
"Failed to resolve site-packages path: {}",
e
));
return None;
}
};
// Find dist-info directory matching the package name (with version)
// dist-info dirs are named like: r2x_reeds-0.0.1.dist-info
// We need to match exactly: package_name + "-" to avoid matching r2x_sienna when looking for r2x_sienna_to_plexos
logger::debug(&format!(
"Searching for dist-info directory in: {}",
site_packages_path.display()
));
let mut dist_info_dir = None;
if let Ok(entries) = fs::read_dir(&site_packages_path) {
for entry in entries.flatten() {
let file_name = entry.file_name().to_string_lossy().to_string();
// Match package name followed by hyphen (for version) to ensure exact match
let expected_prefix = format!("{}-", full_package_name);
if file_name.starts_with(&expected_prefix) && file_name.ends_with(".dist-info") {
logger::debug(&format!(
"Found dist-info directory: {}",
file_name
));
dist_info_dir = Some(entry.path());
break;
}
}
} else {
logger::debug(&format!(
"Failed to read site-packages directory: {}",
site_packages_path.display()
));
}
let dist_info_dir = dist_info_dir?;
let entry_points_path = dist_info_dir.join("entry_points.txt");
logger::debug(&format!(
"Looking for entry_points.txt at: {}",
entry_points_path.display()
));
logger::debug(&format!(
"Entry points path: {}",
entry_points_path.display()
));
if !entry_points_path.exists() {
logger::debug("Entry points file not found");
return None;
}
// Parse entry_points.txt for r2x_plugin group
let content = fs::read_to_string(&entry_points_path).ok()?;
let mut in_r2x_plugin_section = false;
for line in content.lines() {
let line = line.trim();
// Check for [r2x_plugin] section
if line == "[r2x_plugin]" {
in_r2x_plugin_section = true;
continue;
}
// Check for new section (starts with [)
if line.starts_with('[') {
in_r2x_plugin_section = false;
continue;
}
// If we're in r2x_plugin section, parse the entry
if in_r2x_plugin_section && line.contains('=') {
let parts: Vec<&str> = line.split('=').collect();
if parts.len() == 2 {
// Return the right side (module:function)
return Some(parts[1].trim().to_string());
}
}
}
None
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_entry_point_parsing() {
let ep = "module.path:function_name";
let parts: Vec<&str> = ep.split(':').collect();
assert_eq!(parts.len(), 2);
assert_eq!(parts[0], "module.path");
assert_eq!(parts[1], "function_name");
}
#[test]
fn test_invalid_entry_point() {
let ep = "no_colon_here";
let parts: Vec<&str> = ep.split(':').collect();
assert_ne!(parts.len(), 2);
}
}