cpex_hosts_python/
factory.rs1use std::sync::Arc;
14
15use cpex_core::{
16 error::PluginError,
17 factory::{PluginFactory, PluginInstance},
18 plugin::PluginConfig,
19};
20
21use crate::plugin::{IsolatedPythonPlugin, PythonHookAdapter};
22
23pub const KIND: &str = "isolated_venv";
30
31pub struct IsolatedVenvFactory;
33
34fn declares_ignored_plugin_dirs(config: &PluginConfig) -> bool {
40 config
41 .config
42 .as_ref()
43 .and_then(serde_json::Value::as_object)
44 .is_some_and(|block| block.contains_key("plugin_dirs"))
45}
46
47impl PluginFactory for IsolatedVenvFactory {
48 fn create(&self, config: &PluginConfig) -> Result<PluginInstance, Box<PluginError>> {
49 if config.hooks.is_empty() {
50 return Err(PluginError::Config {
51 message: format!(
52 "plugin '{}' (isolated_venv): `hooks:` must list at least one hook \
53 for the Python plugin to handle (e.g. tool_pre_invoke)",
54 config.name
55 ),
56 }
57 .boxed());
58 }
59
60 if declares_ignored_plugin_dirs(config) {
65 tracing::warn!(
66 plugin = %config.name,
67 "plugin '{}' (isolated_venv) sets `plugin_dirs` in its config block, but the \
68 host always uses '{}' at the project root. Setting ignored — remove it.",
69 config.name,
70 crate::plugin::DEFAULT_PLUGIN_DIR,
71 );
72 }
73
74 let plugin = Arc::new(IsolatedPythonPlugin::from_config(config)?);
78
79 let handlers: Vec<_> = config
80 .hooks
81 .iter()
82 .map(
83 |hook| -> (&'static str, Arc<dyn cpex_core::registry::AnyHookHandler>) {
84 let leaked: &'static str = Box::leak(hook.clone().into_boxed_str());
89 (
90 leaked,
91 Arc::new(PythonHookAdapter::new(Arc::clone(&plugin), leaked)),
92 )
93 },
94 )
95 .collect();
96
97 Ok(PluginInstance { plugin, handlers })
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use cpex_core::config::CpexConfig;
104 use cpex_core::factory::PluginFactoryRegistry;
105 use cpex_core::manager::PluginManager;
106
107 use super::*;
108
109 fn minimal_config_yaml() -> &'static str {
113 r#"
114plugins:
115 - name: pii-filter
116 kind: isolated_venv
117 hooks: [tool_pre_invoke]
118 config:
119 class_name: my_pkg.filters.PiiFilter
120"#
121 }
122
123 fn registry() -> PluginFactoryRegistry {
124 let mut factories = PluginFactoryRegistry::new();
125 factories.register(KIND, Box::new(IsolatedVenvFactory));
126 factories
127 }
128
129 #[test]
130 fn factory_registers_under_isolated_venv_kind() {
131 let factories = registry();
132 assert!(factories.has(KIND));
133 assert_eq!(KIND, "isolated_venv");
134 }
135
136 #[test]
137 fn loading_an_isolated_venv_config_succeeds() {
138 let config: CpexConfig = serde_yaml::from_str(minimal_config_yaml()).expect("valid YAML");
139 let factories = registry();
140
141 PluginManager::from_config(config, &factories)
144 .expect("config loads with the factory registered");
145 }
146
147 #[test]
148 fn missing_class_name_is_a_config_error_not_a_panic() {
149 let yaml = r#"
152plugins:
153 - name: broken
154 kind: isolated_venv
155 hooks: [tool_pre_invoke]
156 config:
157 plugin_dirs: ["./plugins"]
158"#;
159 let config: CpexConfig = serde_yaml::from_str(yaml).expect("valid YAML");
160 let Err(err) = PluginManager::from_config(config, ®istry()) else {
162 panic!("a config block without class_name must be rejected");
163 };
164
165 assert!(
166 matches!(*err, PluginError::Config { .. }),
167 "expected a Config error, got: {err:?}"
168 );
169 let msg = err.to_string();
170 assert!(
171 msg.contains("class_name"),
172 "the error should name the missing field so an operator can fix it: {msg}"
173 );
174 }
175
176 #[test]
177 fn empty_hooks_list_is_rejected() {
178 let yaml = r#"
179plugins:
180 - name: no-hooks
181 kind: isolated_venv
182 hooks: []
183 config:
184 class_name: my_pkg.filters.PiiFilter
185"#;
186 let config: CpexConfig = serde_yaml::from_str(yaml).expect("valid YAML");
187 let Err(err) = PluginManager::from_config(config, ®istry()) else {
188 panic!("a plugin declaring no hooks can never be invoked");
189 };
190 assert!(matches!(*err, PluginError::Config { .. }));
191 }
192
193 #[test]
194 fn the_ignored_plugin_dirs_key_is_detected_for_the_warning() {
195 let with_key = PluginConfig {
198 name: "legacy".into(),
199 kind: KIND.into(),
200 hooks: vec!["tool_pre_invoke".into()],
201 config: Some(serde_json::json!({
202 "class_name": "my_pkg.P",
203 "plugin_dirs": ["/somewhere"],
204 })),
205 ..Default::default()
206 };
207 assert!(declares_ignored_plugin_dirs(&with_key));
208
209 let without_key = PluginConfig {
210 config: Some(serde_json::json!({ "class_name": "my_pkg.P" })),
211 ..with_key.clone()
212 };
213 assert!(
214 !declares_ignored_plugin_dirs(&without_key),
215 "a clean config must not warn"
216 );
217
218 let no_block = PluginConfig {
220 config: None,
221 ..with_key
222 };
223 assert!(!declares_ignored_plugin_dirs(&no_block));
224 }
225
226 #[test]
227 fn an_ignored_plugin_dirs_key_still_loads() {
228 let yaml = r#"
232plugins:
233 - name: legacy-dirs
234 kind: isolated_venv
235 hooks: [tool_pre_invoke]
236 config:
237 class_name: my_pkg.filters.PiiFilter
238 plugin_dirs: ["/somewhere/else"]
239"#;
240 let config: CpexConfig = serde_yaml::from_str(yaml).expect("valid YAML");
241 PluginManager::from_config(config, ®istry())
242 .expect("an ignored plugin_dirs key must not fail the load");
243 }
244
245 #[test]
246 fn one_handler_is_produced_per_declared_hook() {
247 let config = PluginConfig {
248 name: "multi".into(),
249 kind: KIND.into(),
250 hooks: vec![
251 "tool_pre_invoke".into(),
252 "tool_post_invoke".into(),
253 "cmf.tool_pre_invoke".into(),
254 ],
255 config: Some(serde_json::json!({ "class_name": "my_pkg.Multi" })),
256 ..Default::default()
257 };
258
259 let instance = IsolatedVenvFactory
260 .create(&config)
261 .expect("a valid multi-hook entry creates an instance");
262
263 assert_eq!(instance.handlers.len(), 3);
264 let names: Vec<&str> = instance.handlers.iter().map(|(n, _)| *n).collect();
265 assert!(names.contains(&"tool_pre_invoke"));
266 assert!(names.contains(&"cmf.tool_pre_invoke"));
267 }
268}