Skip to content

Commit 60fd968

Browse files
committed
fix: remove hardcoded reference to plugins/config in the cpex/framework/isolated/client.py and update tests. remove methods_to_exclude from validator.
Signed-off-by: habeck <habeck@us.ibm.com>
1 parent 55a7675 commit 60fd968

5 files changed

Lines changed: 87 additions & 83 deletions

File tree

cpex/framework/isolated/client.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
from cpex.framework.errors import PluginError, convert_exception_to_error
2727
from cpex.framework.hooks.registry import get_hook_registry
2828
from cpex.framework.isolated.venv_comm import VenvProcessCommunicator
29-
from cpex.framework.loader.config import ConfigLoader
3029
from cpex.framework.models import PluginConfig, PluginContext, PluginErrorModel, PluginPayload, PluginResult
3130

3231
logger = logging.getLogger(__name__)
@@ -35,14 +34,12 @@
3534
class IsolatedVenvPlugin(Plugin):
3635
"""IsolatedVenvPlugin class."""
3736

38-
def __init__(self, config: PluginConfig) -> None:
37+
def __init__(self, config: PluginConfig, plugin_dirs) -> None:
3938
"""Initialize the plugin's venv environment."""
4039
super().__init__(config)
4140
self.implementation = "Python"
4241
self.comm = None
43-
tmp = os.environ.get("PLUGINS_CONFIG_FILE", "plugins/config.yaml")
44-
plugin_loader_config = ConfigLoader.load_config(Path(tmp).resolve(), use_jinja=False)
45-
self.plugin_dirs = plugin_loader_config.plugin_dirs
42+
self.plugin_dirs = plugin_dirs
4643
# use the first plugin dir specified in the plugin configuration file.
4744
path = Path(self.plugin_dirs[0]).resolve()
4845
class_root = self.config.config.get("class_name").split(".")[0]
@@ -210,23 +207,23 @@ async def initialize(self) -> None:
210207
raise FileNotFoundError(f"plugin path not found: {self.plugin_path}")
211208

212209
venv_path = self.plugin_path / ".venv"
213-
210+
214211
# Prevent directory traversal: ensure requirements_file stays within plugin_path
215212
requirements_file_input = self.config.config["requirements_file"]
216-
213+
217214
# Handle both relative and absolute paths
218215
if isinstance(requirements_file_input, Path):
219216
requirements_file = requirements_file_input
220217
else:
221218
requirements_file = Path(requirements_file_input)
222-
219+
223220
# If it's a relative path, resolve it relative to plugin_path
224221
if not requirements_file.is_absolute():
225222
requirements_file = (self.plugin_path / requirements_file).resolve()
226223
else:
227224
# If absolute, resolve it to normalize
228225
requirements_file = requirements_file.resolve()
229-
226+
230227
# Validate that the resolved path is within plugin_path (security check)
231228
try:
232229
requirements_file.relative_to(self.plugin_path.resolve())

cpex/framework/loader/plugin.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def __init__(self) -> None:
5353
{}
5454
"""
5555
self._plugin_types: dict[str, Type[Plugin]] = {}
56+
self.plugin_dirs: list[str] = []
5657

5758
def __get_plugin_type(self, kind: str) -> Type[Plugin]:
5859
"""Import a plugin type from a python module.
@@ -145,7 +146,7 @@ async def load_and_instantiate_plugin(self, config: PluginConfig) -> Plugin | No
145146
if config.kind == ISOLATED_VENV_PLUGIN_TYPE:
146147
from cpex.framework.isolated.client import IsolatedVenvPlugin # pylint: disable=import-outside-toplevel
147148

148-
plugin: Plugin = IsolatedVenvPlugin(config)
149+
plugin: Plugin = IsolatedVenvPlugin(config, plugin_dirs=self.plugin_dirs.copy())
149150
await plugin.initialize()
150151
return plugin
151152

@@ -167,8 +168,10 @@ def append_to_search_path(self, plugin_dirs: list[str]) -> None:
167168
"""
168169
for plugin_dir in plugin_dirs:
169170
resolved = str(Path(plugin_dir).resolve())
170-
if resolved not in sys.path and resolved.startswith(tuple(ALLOWED_PLUGIN_DIRS)):
171-
sys.path.append(resolved)
171+
if resolved.startswith(tuple(ALLOWED_PLUGIN_DIRS)):
172+
self.plugin_dirs.append(plugin_dir)
173+
if resolved not in sys.path:
174+
sys.path.append(resolved)
172175

173176
async def shutdown(self) -> None:
174177
"""Shutdown and cleanup plugin loader.

cpex/framework/models.py

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,17 +1329,7 @@ def to_json(self) -> dict[str, Any]:
13291329
"""
13301330
# Get the base serialization from Pydantic
13311331
data = self.model_dump(mode="json", exclude_none=False, exclude_unset=False)
1332-
1333-
# Explicitly remove any validator methods or callables that might have been included
1334-
# These are the @model_validator decorated methods that should not be serialized
1335-
methods_to_exclude = {
1336-
"_migrate_legacy_modes",
1337-
"check_url_or_script_filled",
1338-
"check_config_and_external",
1339-
}
1340-
1341-
# Filter out any methods or callables from the serialized data
1342-
return {k: v for k, v in data.items() if k not in methods_to_exclude and not callable(v)}
1332+
return data
13431333

13441334

13451335
class PluginManifest(BaseModel):

tests/unit/cpex/framework/isolated/test_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def mock_config(self, tmp_path):
5757
@pytest.fixture
5858
def plugin(self, mock_config, tmp_path):
5959
"""Create an IsolatedVenvPlugin instance."""
60-
plugin_instance = IsolatedVenvPlugin(mock_config)
60+
plugin_instance = IsolatedVenvPlugin(mock_config, plugin_dirs=[tmp_path])
6161
# Override plugin_path to use tmp_path for testing
6262
plugin_instance.plugin_path = tmp_path / "test_plugin"
6363
return plugin_instance

tests/unit/cpex/framework/isolated/test_integration.py

Lines changed: 73 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@
1313
from unittest.mock import AsyncMock, MagicMock, Mock, patch
1414

1515
import pytest
16+
import yaml
1617

1718
from cpex.framework import GlobalContext, PluginManager
1819
from cpex.framework.hooks.tools import ToolPreInvokePayload
1920
from cpex.framework.isolated.client import IsolatedVenvPlugin
20-
from cpex.framework.models import PluginConfig
21+
from cpex.framework.loader.plugin import ALLOWED_PLUGIN_DIRS
22+
from cpex.framework.models import Config, PluginConfig
2123

2224

2325
class TestIsolatedPluginIntegration:
@@ -26,48 +28,42 @@ class TestIsolatedPluginIntegration:
2628
@pytest.fixture
2729
def integration_config_path(self, tmp_path):
2830
"""Create a temporary config file for integration testing."""
29-
config_content = """
30-
plugin_dirs:
31-
- "xplugins"
3231

33-
plugin_settings:
34-
parallel_execution_within_band: true
35-
plugin_timeout: 30
36-
fail_on_plugin_error: false
37-
38-
plugins:
39-
- name: "test_isolated_plugin"
40-
kind: "isolated_venv"
41-
description: "Test isolated plugin"
42-
version: "1.0.0"
43-
author: "Test"
44-
hooks: ["tool_pre_invoke"]
45-
config:
46-
class_name: "test_plugin.TestPlugin"
47-
requirements_file: "requirements.txt"
48-
script_path: "xplugins"
49-
"""
50-
config_file = tmp_path / "test_config.yaml"
32+
cfg = Config(plugins=[PluginConfig(name="test_isolated_plugin", kind="isolated_venv",description="Test isolated plugin",version="1.0.0",author="Test",hooks=["tool_pre_invoke"],
33+
config={
34+
"class_name": "test_plugin.TestPlugin",
35+
"requirements_file": "requirements.txt"
36+
})],plugin_dirs=[str((tmp_path / "xplugins").resolve())],
37+
plugin_settings={
38+
"parallel_execution_within_band": True,
39+
"plugin_timeout": 30,
40+
"fail_on_plugin_error": False
41+
})
42+
config_file = tmp_path / "xplugins" / "test_config.yaml"
43+
class_root = tmp_path / "xplugins" / "test_plugin"
44+
class_root.mkdir(parents=True, exist_ok=True)
45+
dumped_cfg = cfg.model_dump(mode="json")
46+
config_content = yaml.safe_dump(dumped_cfg, default_flow_style=False)
5147
config_file.write_text(config_content)
5248
return str(config_file)
5349

5450
@pytest.mark.asyncio
5551
@patch("cpex.framework.isolated.client.VenvProcessCommunicator")
5652
@patch.object(IsolatedVenvPlugin, "create_venv")
5753
async def test_plugin_manager_with_isolated_plugin(
58-
self, mock_create_venv, mock_comm_class, integration_config_path
54+
self, mock_create_venv, mock_comm_class, integration_config_path, tmp_path
5955
):
6056
"""Test PluginManager loading and initializing an isolated plugin."""
6157
# Setup mocks
6258
mock_create_venv.return_value = None
6359
mock_comm = MagicMock()
6460
mock_comm.install_requirements = MagicMock()
6561
mock_comm_class.return_value = mock_comm
66-
67-
# Create manager
68-
manager = PluginManager(integration_config_path)
69-
70-
await manager.initialize()
62+
with patch('cpex.framework.loader.plugin.ALLOWED_PLUGIN_DIRS', { str((tmp_path / "xplugins" ).resolve())}):
63+
# Create manager
64+
manager = PluginManager(integration_config_path)
65+
66+
await manager.initialize()
7167

7268
@pytest.mark.asyncio
7369
@patch("cpex.framework.isolated.client.VenvProcessCommunicator")
@@ -96,35 +92,39 @@ async def test_isolated_plugin_full_lifecycle(self, mock_create_venv, mock_comm_
9692
"config": {
9793
"class_name": "test_plugin.TestPlugin",
9894
"requirements_file": "requirements.txt",
99-
"script_path": "tests/unit/cpex/fixtures/plugins/isolated"
10095
}
10196
}
97+
resolved_plugin_path = (tmp_path / "xplugins" ).resolve()
98+
plugin_root = resolved_plugin_path / "test_plugin"
99+
plugin_root.mkdir(parents=True, exist_ok=True)
100+
# resolved_plugin_path.mkdir(parents=True, exist_ok=True)
101+
with patch('cpex.framework.loader.plugin.ALLOWED_PLUGIN_DIRS', { str(resolved_plugin_path) }):
102102

103-
config = PluginConfig(**config_dict)
104-
105-
# Create and initialize plugin
106-
plugin = IsolatedVenvPlugin(config)
107-
108-
with patch("cpex.framework.isolated.client.get_hook_registry") as mock_registry:
109-
from cpex.framework.hooks.tools import ToolPreInvokeResult
110-
mock_reg = MagicMock()
111-
mock_reg.get_result_type.return_value = ToolPreInvokeResult
112-
mock_reg.json_to_result = MagicMock()
113-
mock_reg.json_to_result.return_value = ToolPreInvokeResult(continue_processing=True)
114-
mock_registry.return_value = mock_reg
103+
config = PluginConfig(**config_dict)
115104

116-
await plugin.initialize()
105+
# Create and initialize plugin
106+
plugin = IsolatedVenvPlugin(config, plugin_dirs=[resolved_plugin_path])
117107

118-
# Invoke hook
119-
payload = ToolPreInvokePayload(name="test_tool", args={})
120-
global_ctx = GlobalContext(request_id="req-123")
121-
from cpex.framework.models import PluginContext
122-
context = PluginContext(global_context=global_ctx)
123-
124-
result = await plugin.invoke_hook("tool_pre_invoke", payload, context)
125-
126-
assert result is not None
127-
assert result.continue_processing is True
108+
with patch("cpex.framework.isolated.client.get_hook_registry") as mock_registry:
109+
from cpex.framework.hooks.tools import ToolPreInvokeResult
110+
mock_reg = MagicMock()
111+
mock_reg.get_result_type.return_value = ToolPreInvokeResult
112+
mock_reg.json_to_result = MagicMock()
113+
mock_reg.json_to_result.return_value = ToolPreInvokeResult(continue_processing=True)
114+
mock_registry.return_value = mock_reg
115+
116+
await plugin.initialize()
117+
118+
# Invoke hook
119+
payload = ToolPreInvokePayload(name="test_tool", args={})
120+
global_ctx = GlobalContext(request_id="req-123")
121+
from cpex.framework.models import PluginContext
122+
context = PluginContext(global_context=global_ctx)
123+
124+
result = await plugin.invoke_hook("tool_pre_invoke", payload, context)
125+
126+
assert result is not None
127+
assert result.continue_processing is True
128128

129129
@pytest.mark.asyncio
130130
async def test_isolated_plugin_error_handling(self, tmp_path):
@@ -139,11 +139,15 @@ async def test_isolated_plugin_error_handling(self, tmp_path):
139139
"config": {
140140
"class_name": "test_plugin.TestPlugin",
141141
"requirements_file": "requirements.txt",
142-
"script_path": "tests/unit/cpex/fixtures/plugins/isolated"
143142
}
144143
}
145144
config = PluginConfig(**config_dict)
146-
plugin = IsolatedVenvPlugin(config)
145+
resolved_plugin_path = (tmp_path / "xplugins" ).resolve()
146+
cache_root = resolved_plugin_path / "test_plugin"
147+
cache_root.mkdir(parents=True, exist_ok=True)
148+
# resolved_plugin_path.mkdir(parents=True, exist_ok=True)
149+
150+
plugin = IsolatedVenvPlugin(config, plugin_dirs=[str(resolved_plugin_path)])
147151

148152
# Try to invoke hook without initialization
149153
from cpex.framework.errors import PluginError
@@ -182,7 +186,11 @@ async def test_isolated_plugin_with_multiple_hooks(
182186
}
183187

184188
config = PluginConfig(**config_dict)
185-
plugin = IsolatedVenvPlugin(config)
189+
resolved_plugin_path = (tmp_path / "xplugins" ).resolve()
190+
cache_root = resolved_plugin_path / "test_plugin"
191+
cache_root.mkdir(parents=True, exist_ok=True)
192+
193+
plugin = IsolatedVenvPlugin(config, plugin_dirs=[str(resolved_plugin_path)])
186194

187195
await plugin.initialize()
188196

@@ -266,11 +274,14 @@ def capture_task(script_path, task_data):
266274
"config": {
267275
"class_name": "test_plugin.TestPlugin",
268276
"requirements_file": "requirements.txt",
269-
"script_path": "tests/unit/cpex/fixtures/plugins/isolated"
270277
}
271278
}
272279
config = PluginConfig(**config_dict)
273-
plugin = IsolatedVenvPlugin(config)
280+
resolved_plugin_path = (tmp_path / "xplugins" ).resolve()
281+
cache_root = resolved_plugin_path / "test_plugin"
282+
cache_root.mkdir(parents=True, exist_ok=True)
283+
284+
plugin = IsolatedVenvPlugin(config, plugin_dirs=[str(resolved_plugin_path)])
274285

275286
await plugin.initialize()
276287

@@ -334,11 +345,14 @@ async def test_isolated_plugin_violation_handling(
334345
"config": {
335346
"class_name": "test_plugin.TestPlugin",
336347
"requirements_file": "requirements.txt",
337-
"script_path": "tests/unit/cpex/fixtures/plugins/isolated"
338348
}
339349
}
340350
config = PluginConfig(**config_dict)
341-
plugin = IsolatedVenvPlugin(config)
351+
resolved_plugin_path = (tmp_path / "xplugins" ).resolve()
352+
cache_root = resolved_plugin_path / "test_plugin"
353+
cache_root.mkdir(parents=True, exist_ok=True)
354+
355+
plugin = IsolatedVenvPlugin(config, plugin_dirs=[str(resolved_plugin_path)])
342356

343357
await plugin.initialize()
344358

0 commit comments

Comments
 (0)