From e3decc4e9631c30e56c7aaa6d4c60ad1b9146a87 Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 5 Mar 2026 15:55:05 -0500 Subject: [PATCH 01/60] enh: cpex/framework/models.py: added comprehensive field validators for the PluginPackageInfo class and created 34 unit tests to verify their functionality Signed-off-by: habeck --- cpex/framework/models.py | 203 ++++++++ .../unit/cpex/framework/test_plugin_models.py | 454 ++++++++++++++++++ 2 files changed, 657 insertions(+) diff --git a/cpex/framework/models.py b/cpex/framework/models.py index 09902ba4..adf813de 100644 --- a/cpex/framework/models.py +++ b/cpex/framework/models.py @@ -1545,3 +1545,206 @@ class PluginPayload(BaseModel): """ model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) + +class PluginPackageInfo(BaseModel): + """Plugin package information. + + Defines how to install a plugin: + - `pypi_package`: Install from PyPI (e.g., "apex-pii-filter") + - `git_repository`: Install from Git (e.g., "https://github.com/example/plugin.git") + - `git_branch/tag/commit`: Specify which version to clone + - `version_constraint`: Semantic version constraints (e.g., ">=1.0.0,<2.0.0") + + Examples: + >>> PluginPackageInfo(pypi_package="test", git_repository="test", git_branch_tag_commit="test", version_constraint="test") + PluginPackageInfo(pypi_package='test', git_repository='test', git_branch_tag_commit='test', version_constraint='test') + """ + pypi_package: Optional[str] = None + git_repository: Optional[str] = None + git_branch_tag_commit: Optional[str] = None + version_constraint: Optional[str] = None + + @field_validator("pypi_package", mode="after") + @classmethod + def validate_pypi_package(cls, pypi_package: str | None) -> str | None: + """Validate PyPI package name format. + + Args: + pypi_package: The PyPI package name to validate. + + Returns: + The validated package name or None if none is set. + + Raises: + ValueError: If the package name is invalid. + """ + if pypi_package is not None and pypi_package != "": + # PyPI package names must contain only ASCII letters, numbers, hyphens, underscores, and periods + # They cannot start or end with hyphens or periods + if not pypi_package.strip(): + raise ValueError("PyPI package name cannot be empty or whitespace") + + # Check for valid characters + import re + if not re.match(r"^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$", pypi_package): + raise ValueError( + f"Invalid PyPI package name '{pypi_package}'. " + "Package names must start and end with a letter or number, " + "and can only contain ASCII letters, numbers, hyphens, underscores, and periods." + ) + + # Check length (PyPI has a 214 character limit for package names) + if len(pypi_package) > 214: + raise ValueError(f"PyPI package name '{pypi_package}' exceeds maximum length of 214 characters") + + return pypi_package if pypi_package != "" else None + + @field_validator("git_repository", mode="after") + @classmethod + def validate_git_repository(cls, git_repository: str | None) -> str | None: + """Validate Git repository URL format. + + Args: + git_repository: The Git repository URL to validate. + + Returns: + The validated repository URL or None if none is set. + + Raises: + ValueError: If the repository URL is invalid. + """ + if git_repository is not None and git_repository != "": + if not git_repository.strip(): + raise ValueError("Git repository URL cannot be empty or whitespace") + + # Support common Git URL formats: https://, git://, ssh://, git@ + import re + git_url_pattern = re.compile( + r"^(https?://|git://|git@)" + r"[a-zA-Z0-9._-]+" + r"(/|:)" + r"[a-zA-Z0-9._/-]+" + r"(\.git)?$" + ) + + if not git_url_pattern.match(git_repository): + raise ValueError( + f"Invalid Git repository URL '{git_repository}'. " + "Must be a valid Git URL (e.g., https://github.com/user/repo.git, " + "git@github.com:user/repo.git)" + ) + + # Additional validation for https/http URLs using existing validator + if git_repository.startswith(("http://", "https://")): + validate_plugin_url(git_repository, "Git repository URL") + + return git_repository if git_repository != "" else None + + @field_validator("git_branch_tag_commit", mode="after") + @classmethod + def validate_git_branch_tag_commit(cls, git_branch_tag_commit: str | None) -> str | None: + """Validate Git branch, tag, or commit reference. + + Args: + git_branch_tag_commit: The Git reference to validate. + + Returns: + The validated reference or None if none is set. + + Raises: + ValueError: If the reference is invalid. + """ + if git_branch_tag_commit is not None and git_branch_tag_commit != "": + if not git_branch_tag_commit.strip(): + raise ValueError("Git branch/tag/commit cannot be empty or whitespace") + + # Git refs can contain alphanumeric characters, hyphens, underscores, slashes, and periods + # Commit hashes are typically 7-40 hex characters + import re + if not re.match(r"^[a-zA-Z0-9._/-]+$", git_branch_tag_commit): + raise ValueError( + f"Invalid Git branch/tag/commit '{git_branch_tag_commit}'. " + "Must contain only alphanumeric characters, hyphens, underscores, slashes, and periods." + ) + + # Check for common invalid patterns + if git_branch_tag_commit.startswith(("/", ".", "-")) or git_branch_tag_commit.endswith(("/", ".")): + raise ValueError( + f"Invalid Git branch/tag/commit '{git_branch_tag_commit}'. " + "Cannot start with /, ., or - or end with / or ." + ) + + if len(git_branch_tag_commit) > 255: + raise ValueError(f"Git branch/tag/commit '{git_branch_tag_commit}' exceeds maximum length of 255 characters") + + return git_branch_tag_commit if git_branch_tag_commit != "" else None + + @field_validator("version_constraint", mode="after") + @classmethod + def validate_version_constraint(cls, version_constraint: str | None) -> str | None: + """Validate semantic version constraint format. + + Args: + version_constraint: The version constraint to validate. + + Returns: + The validated version constraint or None if none is set. + + Raises: + ValueError: If the version constraint is invalid. + """ + if version_constraint is not None and version_constraint != "": + if not version_constraint.strip(): + raise ValueError("Version constraint cannot be empty or whitespace") + + # Validate semantic version constraint format (e.g., ">=1.0.0,<2.0.0", "~=1.2.3", "==1.0.0") + import re + # Pattern for version specifiers: operator + optional space + version number + version_pattern = re.compile( + r"^(==|!=|<=|>=|<|>|~=|===)\s*" + r"\d+(\.\d+)*" + r"([a-zA-Z0-9._-]*)?$" + ) + + # Split by comma for multiple constraints + constraints = [c.strip() for c in version_constraint.split(",")] + + for constraint in constraints: + if not constraint: + raise ValueError("Version constraint cannot contain empty parts") + + if not version_pattern.match(constraint): + raise ValueError( + f"Invalid version constraint '{constraint}'. " + "Must follow PEP 440 format (e.g., '>=1.0.0', '~=1.2.3', '==1.0.0,<2.0.0')" + ) + + if len(version_constraint) > 255: + raise ValueError(f"Version constraint '{version_constraint}' exceeds maximum length of 255 characters") + + return version_constraint if version_constraint != "" else None + + @model_validator(mode="after") + def validate_installation_method(self) -> Self: + """Validate that at least one installation method is specified. + + Returns: + The validated model instance. + + Raises: + ValueError: If neither PyPI package nor Git repository is specified. + """ + if not self.pypi_package and not self.git_repository: + raise ValueError( + "At least one installation method must be specified: " + "either 'pypi_package' or 'git_repository'" + ) + + # If git_branch_tag_commit is specified, git_repository must also be specified + if self.git_branch_tag_commit and not self.git_repository: + raise ValueError( + "'git_branch_tag_commit' can only be specified when 'git_repository' is provided" + ) + + return self + diff --git a/tests/unit/cpex/framework/test_plugin_models.py b/tests/unit/cpex/framework/test_plugin_models.py index 01454db6..9e99e12b 100644 --- a/tests/unit/cpex/framework/test_plugin_models.py +++ b/tests/unit/cpex/framework/test_plugin_models.py @@ -197,3 +197,457 @@ def test_plugin_config_external_config_disallowed(): mcp = MCPClientConfig(proto=TransportType.SSE, url="https://example.com") with pytest.raises(ValueError): PluginConfig(name="external", kind=EXTERNAL_PLUGIN_TYPE, config={"x": 1}, mcp=mcp) + + +# ============================================================================= +# PluginPackageInfo Validator Tests +# ============================================================================= + + +class TestPluginPackageInfoValidators: + """Tests for PluginPackageInfo field validators.""" + + # ------------------------------------------------------------------------- + # PyPI Package Validator Tests + # ------------------------------------------------------------------------- + + def test_pypi_package_valid(self): + """Valid PyPI package names should be accepted.""" + from cpex.framework.models import PluginPackageInfo + + # Standard package names + pkg = PluginPackageInfo(pypi_package="my-package") + assert pkg.pypi_package == "my-package" + + pkg = PluginPackageInfo(pypi_package="my_package") + assert pkg.pypi_package == "my_package" + + pkg = PluginPackageInfo(pypi_package="my.package") + assert pkg.pypi_package == "my.package" + + pkg = PluginPackageInfo(pypi_package="MyPackage123") + assert pkg.pypi_package == "MyPackage123" + + # Complex valid names + pkg = PluginPackageInfo(pypi_package="apex-pii-filter") + assert pkg.pypi_package == "apex-pii-filter" + + pkg = PluginPackageInfo(pypi_package="package_name.with-everything123") + assert pkg.pypi_package == "package_name.with-everything123" + + def test_pypi_package_invalid_empty(self): + """Empty or whitespace-only PyPI package names should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + # Empty string is treated as None, so model validator catches it + with pytest.raises(ValueError, match="At least one installation method"): + PluginPackageInfo(pypi_package="") + + with pytest.raises(ValueError, match="cannot be empty or whitespace"): + PluginPackageInfo(pypi_package=" ") + + def test_pypi_package_invalid_start_end(self): + """PyPI package names starting/ending with invalid characters should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + with pytest.raises(ValueError, match="Invalid PyPI package name"): + PluginPackageInfo(pypi_package="-invalid") + + with pytest.raises(ValueError, match="Invalid PyPI package name"): + PluginPackageInfo(pypi_package="invalid-") + + with pytest.raises(ValueError, match="Invalid PyPI package name"): + PluginPackageInfo(pypi_package=".invalid") + + with pytest.raises(ValueError, match="Invalid PyPI package name"): + PluginPackageInfo(pypi_package="invalid.") + + def test_pypi_package_invalid_characters(self): + """PyPI package names with invalid characters should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + with pytest.raises(ValueError, match="Invalid PyPI package name"): + PluginPackageInfo(pypi_package="my package") + + with pytest.raises(ValueError, match="Invalid PyPI package name"): + PluginPackageInfo(pypi_package="my@package") + + with pytest.raises(ValueError, match="Invalid PyPI package name"): + PluginPackageInfo(pypi_package="my/package") + + def test_pypi_package_too_long(self): + """PyPI package names exceeding 214 characters should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + long_name = "a" * 215 + with pytest.raises(ValueError, match="exceeds maximum length of 214 characters"): + PluginPackageInfo(pypi_package=long_name) + + def test_pypi_package_none_allowed(self): + """None should be allowed for pypi_package when git_repository is provided.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git") + assert pkg.pypi_package is None + + # ------------------------------------------------------------------------- + # Git Repository Validator Tests + # ------------------------------------------------------------------------- + + def test_git_repository_valid_https(self): + """Valid HTTPS Git repository URLs should be accepted.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git") + assert pkg.git_repository == "https://github.com/user/repo.git" + + pkg = PluginPackageInfo(git_repository="https://gitlab.com/user/repo.git") + assert pkg.git_repository == "https://gitlab.com/user/repo.git" + + pkg = PluginPackageInfo(git_repository="https://github.com/user/repo") + assert pkg.git_repository == "https://github.com/user/repo" + + def test_git_repository_valid_http(self): + """Valid HTTP Git repository URLs should be accepted.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(git_repository="http://example.com/user/repo.git") + assert pkg.git_repository == "http://example.com/user/repo.git" + + def test_git_repository_valid_git_protocol(self): + """Valid git:// protocol URLs should be accepted.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(git_repository="git://github.com/user/repo.git") + assert pkg.git_repository == "git://github.com/user/repo.git" + + def test_git_repository_valid_ssh(self): + """Valid SSH Git repository URLs should be accepted.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(git_repository="git@github.com:user/repo.git") + assert pkg.git_repository == "git@github.com:user/repo.git" + + def test_git_repository_invalid_empty(self): + """Empty or whitespace-only Git repository URLs should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + # Empty string is treated as None, so model validator catches it + with pytest.raises(ValueError, match="At least one installation method"): + PluginPackageInfo(git_repository="") + + with pytest.raises(ValueError, match="cannot be empty or whitespace"): + PluginPackageInfo(git_repository=" ") + + def test_git_repository_invalid_format(self): + """Invalid Git repository URL formats should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + with pytest.raises(ValueError, match="Invalid Git repository URL"): + PluginPackageInfo(git_repository="not-a-valid-url") + + with pytest.raises(ValueError, match="Invalid Git repository URL"): + PluginPackageInfo(git_repository="ftp://example.com/repo.git") + + def test_git_repository_none_allowed(self): + """None should be allowed for git_repository when pypi_package is provided.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(pypi_package="my-package") + assert pkg.git_repository is None + + # ------------------------------------------------------------------------- + # Git Branch/Tag/Commit Validator Tests + # ------------------------------------------------------------------------- + + def test_git_branch_tag_commit_valid(self): + """Valid Git branch/tag/commit references should be accepted.""" + from cpex.framework.models import PluginPackageInfo + + # Branch names + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="main" + ) + assert pkg.git_branch_tag_commit == "main" + + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="feature/new-feature" + ) + assert pkg.git_branch_tag_commit == "feature/new-feature" + + # Tag names + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="v1.0.0" + ) + assert pkg.git_branch_tag_commit == "v1.0.0" + + # Commit hashes + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="abc123def456" + ) + assert pkg.git_branch_tag_commit == "abc123def456" + + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0" + ) + assert pkg.git_branch_tag_commit == "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0" + + def test_git_branch_tag_commit_invalid_empty(self): + """Empty or whitespace-only Git references should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + # Empty string is treated as None, which is valid + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="" + ) + assert pkg.git_branch_tag_commit is None + + with pytest.raises(ValueError, match="cannot be empty or whitespace"): + PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit=" " + ) + + def test_git_branch_tag_commit_invalid_characters(self): + """Git references with invalid characters should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + with pytest.raises(ValueError, match="Invalid Git branch/tag/commit"): + PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="branch with spaces" + ) + + with pytest.raises(ValueError, match="Invalid Git branch/tag/commit"): + PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="branch@invalid" + ) + + def test_git_branch_tag_commit_invalid_start_end(self): + """Git references with invalid start/end characters should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + with pytest.raises(ValueError, match="Cannot start with"): + PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="/invalid" + ) + + with pytest.raises(ValueError, match="Cannot start with"): + PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit=".invalid" + ) + + with pytest.raises(ValueError, match="Cannot start with"): + PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="-invalid" + ) + + with pytest.raises(ValueError, match="end with"): + PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="invalid/" + ) + + with pytest.raises(ValueError, match="end with"): + PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="invalid." + ) + + def test_git_branch_tag_commit_too_long(self): + """Git references exceeding 255 characters should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + long_ref = "a" * 256 + with pytest.raises(ValueError, match="exceeds maximum length of 255 characters"): + PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit=long_ref + ) + + def test_git_branch_tag_commit_none_allowed(self): + """None should be allowed for git_branch_tag_commit.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git") + assert pkg.git_branch_tag_commit is None + + # ------------------------------------------------------------------------- + # Version Constraint Validator Tests + # ------------------------------------------------------------------------- + + def test_version_constraint_valid_single(self): + """Valid single version constraints should be accepted.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(pypi_package="my-package", version_constraint=">=1.0.0") + assert pkg.version_constraint == ">=1.0.0" + + pkg = PluginPackageInfo(pypi_package="my-package", version_constraint="==1.2.3") + assert pkg.version_constraint == "==1.2.3" + + pkg = PluginPackageInfo(pypi_package="my-package", version_constraint="~=1.2.3") + assert pkg.version_constraint == "~=1.2.3" + + pkg = PluginPackageInfo(pypi_package="my-package", version_constraint="<2.0.0") + assert pkg.version_constraint == "<2.0.0" + + def test_version_constraint_valid_multiple(self): + """Valid multiple version constraints should be accepted.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo( + pypi_package="my-package", + version_constraint=">=1.0.0,<2.0.0" + ) + assert pkg.version_constraint == ">=1.0.0,<2.0.0" + + pkg = PluginPackageInfo( + pypi_package="my-package", + version_constraint=">=1.0.0, <2.0.0, !=1.5.0" + ) + assert pkg.version_constraint == ">=1.0.0, <2.0.0, !=1.5.0" + + def test_version_constraint_valid_with_prerelease(self): + """Version constraints with pre-release identifiers should be accepted.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo( + pypi_package="my-package", + version_constraint=">=1.0.0-alpha" + ) + assert pkg.version_constraint == ">=1.0.0-alpha" + + pkg = PluginPackageInfo( + pypi_package="my-package", + version_constraint="==1.0.0rc1" + ) + assert pkg.version_constraint == "==1.0.0rc1" + + def test_version_constraint_invalid_empty(self): + """Empty or whitespace-only version constraints should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + # Empty string is treated as None, which is valid + pkg = PluginPackageInfo(pypi_package="my-package", version_constraint="") + assert pkg.version_constraint is None + + with pytest.raises(ValueError, match="cannot be empty or whitespace"): + PluginPackageInfo(pypi_package="my-package", version_constraint=" ") + + def test_version_constraint_invalid_format(self): + """Invalid version constraint formats should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + with pytest.raises(ValueError, match="Invalid version constraint"): + PluginPackageInfo(pypi_package="my-package", version_constraint="invalid") + + with pytest.raises(ValueError, match="Invalid version constraint"): + PluginPackageInfo(pypi_package="my-package", version_constraint="1.0.0") + + def test_version_constraint_invalid_empty_parts(self): + """Version constraints with empty parts should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + with pytest.raises(ValueError, match="cannot contain empty parts"): + PluginPackageInfo( + pypi_package="my-package", + version_constraint=">=1.0.0,," + ) + + def test_version_constraint_too_long(self): + """Version constraints exceeding 255 characters should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + long_constraint = ">=1.0.0," + ",".join([f"!={i}.0.0" for i in range(100)]) + with pytest.raises(ValueError, match="exceeds maximum length of 255 characters"): + PluginPackageInfo(pypi_package="my-package", version_constraint=long_constraint) + + def test_version_constraint_none_allowed(self): + """None should be allowed for version_constraint.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(pypi_package="my-package") + assert pkg.version_constraint is None + + # ------------------------------------------------------------------------- + # Model Validator Tests + # ------------------------------------------------------------------------- + + def test_installation_method_required(self): + """At least one installation method must be specified.""" + from cpex.framework.models import PluginPackageInfo + + with pytest.raises(ValueError, match="At least one installation method must be specified"): + PluginPackageInfo() + + def test_installation_method_pypi_only(self): + """PyPI package alone should be valid.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(pypi_package="my-package") + assert pkg.pypi_package == "my-package" + assert pkg.git_repository is None + + def test_installation_method_git_only(self): + """Git repository alone should be valid.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git") + assert pkg.git_repository == "https://github.com/user/repo.git" + assert pkg.pypi_package is None + + def test_installation_method_both_allowed(self): + """Both PyPI package and Git repository can be specified.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo( + pypi_package="my-package", + git_repository="https://github.com/user/repo.git" + ) + assert pkg.pypi_package == "my-package" + assert pkg.git_repository == "https://github.com/user/repo.git" + + def test_git_branch_requires_repository(self): + """git_branch_tag_commit requires git_repository.""" + from cpex.framework.models import PluginPackageInfo + + with pytest.raises(ValueError, match="can only be specified when 'git_repository' is provided"): + PluginPackageInfo( + pypi_package="my-package", + git_branch_tag_commit="main" + ) + + def test_complete_git_installation(self): + """Complete Git installation with all fields should be valid.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="v1.0.0", + version_constraint=">=1.0.0" + ) + assert pkg.git_repository == "https://github.com/user/repo.git" + assert pkg.git_branch_tag_commit == "v1.0.0" + assert pkg.version_constraint == ">=1.0.0" + + def test_complete_pypi_installation(self): + """Complete PyPI installation with version constraint should be valid.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo( + pypi_package="my-package", + version_constraint=">=1.0.0,<2.0.0" + ) + assert pkg.pypi_package == "my-package" + assert pkg.version_constraint == ">=1.0.0,<2.0.0" From cb4b5baddac9dfeda3053d372d94c8023b43c079 Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 5 Mar 2026 16:28:28 -0500 Subject: [PATCH 02/60] chore: provde better examples for PluginPackageInfo constructor Signed-off-by: habeck --- cpex/framework/models.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cpex/framework/models.py b/cpex/framework/models.py index adf813de..b8594f07 100644 --- a/cpex/framework/models.py +++ b/cpex/framework/models.py @@ -1556,8 +1556,10 @@ class PluginPackageInfo(BaseModel): - `version_constraint`: Semantic version constraints (e.g., ">=1.0.0,<2.0.0") Examples: - >>> PluginPackageInfo(pypi_package="test", git_repository="test", git_branch_tag_commit="test", version_constraint="test") - PluginPackageInfo(pypi_package='test', git_repository='test', git_branch_tag_commit='test', version_constraint='test') + >>> pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="v1.0.0", + version_constraint=">=1.0.0") + >>> pkg2 = PluginPackageInfo(pypi_package="my-package", version_constraint=">=1.0.0") """ pypi_package: Optional[str] = None git_repository: Optional[str] = None From ad608d8f961be27ed1309c7563994c8b8f16e23d Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 5 Mar 2026 18:05:36 -0500 Subject: [PATCH 03/60] enh: add PluginVersionInfo and PluginVersionRegistry models w/unit tests Signed-off-by: habeck --- cpex/framework/models.py | 95 +++++- pyproject.toml | 1 + .../unit/cpex/framework/test_plugin_models.py | 279 ++++++++++++++++++ 3 files changed, 374 insertions(+), 1 deletion(-) diff --git a/cpex/framework/models.py b/cpex/framework/models.py index b8594f07..583b5d8e 100644 --- a/cpex/framework/models.py +++ b/cpex/framework/models.py @@ -14,7 +14,7 @@ import logging import os from pathlib import Path -from typing import Any, Generic, Optional, Self, TypeVar, Union +from typing import Any, Generic, List, Optional, Self, TypeVar, Union # Third-Party from pydantic import ( @@ -27,6 +27,8 @@ PrivateAttr, ValidationInfo, ) +from packaging.version import Version, InvalidVersion + # First-Party from cpex.framework.constants import ( @@ -1750,3 +1752,94 @@ def validate_installation_method(self) -> Self: return self +class PluginVersionInfo(BaseModel): + """Represents the version information of a plugin. + + Attributes: + version (str): The version of the plugin. + released (str): The release date of the plugin. + breaking_changes: (bool): Whether the version contains breaking changes. + deprecated (bool): Whether the version is deprecated. + manifest_file (str): The manifest file of the plugin. + changelog (str): The release notes for the plugin. + min_max_framework_version (str): The minimum and maximum framework version required for the plugin (comma separated). + """ + version: str + released: str + breaking_changes: Optional[bool] = None + deprecated: bool = False + manifest_file: str + changelog: Optional[str] = None + min_max_framework_version: Optional[str] = "0.1.0.dev4,0.1.0.dev4" + +class PluginVersionRegistry(BaseModel): + """Represents the version registry of a plugin. + Attributes: + versions (List[PluginVersionInfo]): A list of PluginVersionInfo objects representing the different versions of the plugin. + """ + latest: Optional[PluginVersionInfo] = None + latest_prerelease: Optional[PluginVersionInfo] = None + versions: List[PluginVersionInfo] + + def get_version(self) -> Optional[PluginVersionInfo]: + """Returns the latest version of the plugin. + Returns: + Optional[PluginVersionInfo]: The latest version of the plugin, or None if no version is available. + """ + return self.latest + + def get_latest_compatible(self, framework_version: str) -> Optional[PluginVersionInfo]: + """Returns the latest compatible version for the given framework version. + + Args: + framework_version (str): The framework version to check compatibility against. + + Returns: + Optional[PluginVersionInfo]: The latest compatible version, or None if no compatible version is found. + """ + + try: + fw_version = Version(framework_version) + except InvalidVersion: + logging.getLogger(__name__).warning( + f"Invalid framework version format: {framework_version}" + ) + return None + + compatible_versions = [] + + for version_info in self.versions: + if not version_info.min_max_framework_version: + continue + + try: + # Parse min and max framework versions + parts = version_info.min_max_framework_version.split(',') + if len(parts) != 2: + continue + + min_version = Version(parts[0].strip()) + max_version = Version(parts[1].strip()) + + # Check if framework version is within range + if min_version <= fw_version <= max_version: + compatible_versions.append(version_info) + + except (InvalidVersion, ValueError): + continue + + if not compatible_versions: + return None + + # Sort by version and return the latest + try: + sorted_versions = sorted( + compatible_versions, + key=lambda v: Version(v.version), + reverse=True + ) + return sorted_versions[0] + except InvalidVersion: + # If sorting fails, return the first compatible version + return compatible_versions[0] + diff --git a/pyproject.toml b/pyproject.toml index eccaef13..5ef4f5cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "pydantic-settings>=2.13.1", "pydantic>=2.12.5", "pyyaml>=6.0.3", + "packaging>=26.0" ] [project.optional-dependencies] diff --git a/tests/unit/cpex/framework/test_plugin_models.py b/tests/unit/cpex/framework/test_plugin_models.py index 9e99e12b..c5d3da82 100644 --- a/tests/unit/cpex/framework/test_plugin_models.py +++ b/tests/unit/cpex/framework/test_plugin_models.py @@ -651,3 +651,282 @@ def test_complete_pypi_installation(self): ) assert pkg.pypi_package == "my-package" assert pkg.version_constraint == ">=1.0.0,<2.0.0" + + +# ============================================================================= +# PluginVersionRegistry Tests +# ============================================================================= + + +class TestPluginVersionRegistry: + """Tests for PluginVersionRegistry class.""" + + def test_get_version_returns_latest(self): + """get_version should return the latest version.""" + from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry + + latest_version = PluginVersionInfo( + version="2.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=latest_version, + versions=[latest_version] + ) + + assert registry.get_version() == latest_version + + def test_get_version_returns_none_when_no_latest(self): + """get_version should return None when latest is not set.""" + from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry + + version = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json" + ) + + registry = PluginVersionRegistry( + latest=None, + versions=[version] + ) + + assert registry.get_version() is None + + def test_get_latest_compatible_finds_compatible_version(self): + """get_latest_compatible should find a version compatible with the framework version.""" + from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry + + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.1.5" + ) + v2 = PluginVersionInfo( + version="2.0.0", + released="2024-02-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.5,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=v2, + versions=[v1, v2] + ) + + # Framework version 0.1.3 should match v1 + result = registry.get_latest_compatible("0.1.3") + assert result == v1 + + # Framework version 0.1.8 should match v2 + result = registry.get_latest_compatible("0.1.8") + assert result == v2 + + def test_get_latest_compatible_returns_latest_when_multiple_match(self): + """get_latest_compatible should return the latest version when multiple versions match.""" + from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry + + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + v2 = PluginVersionInfo( + version="1.5.0", + released="2024-02-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + v3 = PluginVersionInfo( + version="2.0.0", + released="2024-03-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=v3, + versions=[v1, v2, v3] + ) + + # All versions support 0.1.5, should return the latest (v3) + result = registry.get_latest_compatible("0.1.5") + assert result == v3 + assert result.version == "2.0.0" + + def test_get_latest_compatible_returns_none_when_no_match(self): + """get_latest_compatible should return None when no version is compatible.""" + from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry + + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.1.5" + ) + + registry = PluginVersionRegistry( + latest=v1, + versions=[v1] + ) + + # Framework version 0.2.0 is outside the range + result = registry.get_latest_compatible("0.2.0") + assert result is None + + def test_get_latest_compatible_handles_invalid_framework_version(self): + """get_latest_compatible should return None for invalid framework version.""" + from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry + + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=v1, + versions=[v1] + ) + + # Invalid version format + result = registry.get_latest_compatible("not-a-version") + assert result is None + + def test_get_latest_compatible_skips_versions_without_min_max(self): + """get_latest_compatible should skip versions without min_max_framework_version.""" + from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry + + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version=None + ) + v2 = PluginVersionInfo( + version="2.0.0", + released="2024-02-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=v2, + versions=[v1, v2] + ) + + # Should only find v2 since v1 has no min_max_framework_version + result = registry.get_latest_compatible("0.1.5") + assert result == v2 + + def test_get_latest_compatible_handles_malformed_min_max(self): + """get_latest_compatible should skip versions with malformed min_max_framework_version.""" + from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry + + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0" # Missing max version + ) + v2 = PluginVersionInfo( + version="2.0.0", + released="2024-02-01", + manifest_file="manifest.json", + min_max_framework_version="invalid,version" # Invalid versions + ) + v3 = PluginVersionInfo( + version="3.0.0", + released="2024-03-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" # Valid + ) + + registry = PluginVersionRegistry( + latest=v3, + versions=[v1, v2, v3] + ) + + # Should only find v3 + result = registry.get_latest_compatible("0.1.5") + assert result == v3 + + def test_get_latest_compatible_with_prerelease_versions(self): + """get_latest_compatible should handle pre-release versions correctly.""" + from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry + + v1 = PluginVersionInfo( + version="1.0.0rc1", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0.dev1,0.1.0.dev5" + ) + v2 = PluginVersionInfo( + version="1.0.0", + released="2024-02-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=v2, + versions=[v1, v2] + ) + + # Dev version should match v1 + result = registry.get_latest_compatible("0.1.0.dev3") + assert result == v1 + + # Stable version should match v2 + result = registry.get_latest_compatible("0.1.5") + assert result == v2 + + def test_get_latest_compatible_boundary_conditions(self): + """get_latest_compatible should correctly handle boundary conditions.""" + from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry + + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=v1, + versions=[v1] + ) + + # Exact min boundary + result = registry.get_latest_compatible("0.1.0") + assert result == v1 + + # Exact max boundary + result = registry.get_latest_compatible("0.2.0") + assert result == v1 + + # Just below min + result = registry.get_latest_compatible("0.0.9") + assert result is None + + # Just above max + result = registry.get_latest_compatible("0.2.1") + assert result is None + + def test_get_latest_compatible_with_empty_versions_list(self): + """get_latest_compatible should return None when versions list is empty.""" + from cpex.framework.models import PluginVersionRegistry + + registry = PluginVersionRegistry( + latest=None, + versions=[] + ) + + result = registry.get_latest_compatible("0.1.0") + assert result is None From 80111bfa9da423eefd6770cc4229b9e053ad6910 Mon Sep 17 00:00:00 2001 From: habeck Date: Fri, 6 Mar 2026 11:26:07 -0500 Subject: [PATCH 04/60] chore: lint fix Signed-off-by: habeck --- cpex/framework/models.py | 109 ++++++++++++++++++--------------------- 1 file changed, 51 insertions(+), 58 deletions(-) diff --git a/cpex/framework/models.py b/cpex/framework/models.py index 583b5d8e..62355f9a 100644 --- a/cpex/framework/models.py +++ b/cpex/framework/models.py @@ -29,7 +29,6 @@ ) from packaging.version import Version, InvalidVersion - # First-Party from cpex.framework.constants import ( CMD, @@ -1548,6 +1547,7 @@ class PluginPayload(BaseModel): model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) + class PluginPackageInfo(BaseModel): """Plugin package information. @@ -1563,6 +1563,7 @@ class PluginPackageInfo(BaseModel): version_constraint=">=1.0.0") >>> pkg2 = PluginPackageInfo(pypi_package="my-package", version_constraint=">=1.0.0") """ + pypi_package: Optional[str] = None git_repository: Optional[str] = None git_branch_tag_commit: Optional[str] = None @@ -1587,20 +1588,21 @@ def validate_pypi_package(cls, pypi_package: str | None) -> str | None: # They cannot start or end with hyphens or periods if not pypi_package.strip(): raise ValueError("PyPI package name cannot be empty or whitespace") - + # Check for valid characters import re + if not re.match(r"^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$", pypi_package): raise ValueError( f"Invalid PyPI package name '{pypi_package}'. " "Package names must start and end with a letter or number, " "and can only contain ASCII letters, numbers, hyphens, underscores, and periods." ) - + # Check length (PyPI has a 214 character limit for package names) if len(pypi_package) > 214: raise ValueError(f"PyPI package name '{pypi_package}' exceeds maximum length of 214 characters") - + return pypi_package if pypi_package != "" else None @field_validator("git_repository", mode="after") @@ -1620,28 +1622,25 @@ def validate_git_repository(cls, git_repository: str | None) -> str | None: if git_repository is not None and git_repository != "": if not git_repository.strip(): raise ValueError("Git repository URL cannot be empty or whitespace") - + # Support common Git URL formats: https://, git://, ssh://, git@ import re + git_url_pattern = re.compile( - r"^(https?://|git://|git@)" - r"[a-zA-Z0-9._-]+" - r"(/|:)" - r"[a-zA-Z0-9._/-]+" - r"(\.git)?$" + r"^(https?://|git://|git@)" r"[a-zA-Z0-9._-]+" r"(/|:)" r"[a-zA-Z0-9._/-]+" r"(\.git)?$" ) - + if not git_url_pattern.match(git_repository): raise ValueError( f"Invalid Git repository URL '{git_repository}'. " "Must be a valid Git URL (e.g., https://github.com/user/repo.git, " "git@github.com:user/repo.git)" ) - + # Additional validation for https/http URLs using existing validator if git_repository.startswith(("http://", "https://")): validate_plugin_url(git_repository, "Git repository URL") - + return git_repository if git_repository != "" else None @field_validator("git_branch_tag_commit", mode="after") @@ -1661,26 +1660,29 @@ def validate_git_branch_tag_commit(cls, git_branch_tag_commit: str | None) -> st if git_branch_tag_commit is not None and git_branch_tag_commit != "": if not git_branch_tag_commit.strip(): raise ValueError("Git branch/tag/commit cannot be empty or whitespace") - + # Git refs can contain alphanumeric characters, hyphens, underscores, slashes, and periods # Commit hashes are typically 7-40 hex characters import re + if not re.match(r"^[a-zA-Z0-9._/-]+$", git_branch_tag_commit): raise ValueError( f"Invalid Git branch/tag/commit '{git_branch_tag_commit}'. " "Must contain only alphanumeric characters, hyphens, underscores, slashes, and periods." ) - + # Check for common invalid patterns if git_branch_tag_commit.startswith(("/", ".", "-")) or git_branch_tag_commit.endswith(("/", ".")): raise ValueError( f"Invalid Git branch/tag/commit '{git_branch_tag_commit}'. " "Cannot start with /, ., or - or end with / or ." ) - + if len(git_branch_tag_commit) > 255: - raise ValueError(f"Git branch/tag/commit '{git_branch_tag_commit}' exceeds maximum length of 255 characters") - + raise ValueError( + f"Git branch/tag/commit '{git_branch_tag_commit}' exceeds maximum length of 255 characters" + ) + return git_branch_tag_commit if git_branch_tag_commit != "" else None @field_validator("version_constraint", mode="after") @@ -1700,32 +1702,29 @@ def validate_version_constraint(cls, version_constraint: str | None) -> str | No if version_constraint is not None and version_constraint != "": if not version_constraint.strip(): raise ValueError("Version constraint cannot be empty or whitespace") - + # Validate semantic version constraint format (e.g., ">=1.0.0,<2.0.0", "~=1.2.3", "==1.0.0") import re + # Pattern for version specifiers: operator + optional space + version number - version_pattern = re.compile( - r"^(==|!=|<=|>=|<|>|~=|===)\s*" - r"\d+(\.\d+)*" - r"([a-zA-Z0-9._-]*)?$" - ) - + version_pattern = re.compile(r"^(==|!=|<=|>=|<|>|~=|===)\s*" r"\d+(\.\d+)*" r"([a-zA-Z0-9._-]*)?$") + # Split by comma for multiple constraints constraints = [c.strip() for c in version_constraint.split(",")] - + for constraint in constraints: if not constraint: raise ValueError("Version constraint cannot contain empty parts") - + if not version_pattern.match(constraint): raise ValueError( f"Invalid version constraint '{constraint}'. " "Must follow PEP 440 format (e.g., '>=1.0.0', '~=1.2.3', '==1.0.0,<2.0.0')" ) - + if len(version_constraint) > 255: raise ValueError(f"Version constraint '{version_constraint}' exceeds maximum length of 255 characters") - + return version_constraint if version_constraint != "" else None @model_validator(mode="after") @@ -1740,18 +1739,16 @@ def validate_installation_method(self) -> Self: """ if not self.pypi_package and not self.git_repository: raise ValueError( - "At least one installation method must be specified: " - "either 'pypi_package' or 'git_repository'" + "At least one installation method must be specified: " "either 'pypi_package' or 'git_repository'" ) - + # If git_branch_tag_commit is specified, git_repository must also be specified if self.git_branch_tag_commit and not self.git_repository: - raise ValueError( - "'git_branch_tag_commit' can only be specified when 'git_repository' is provided" - ) - + raise ValueError("'git_branch_tag_commit' can only be specified when 'git_repository' is provided") + return self - + + class PluginVersionInfo(BaseModel): """Represents the version information of a plugin. @@ -1764,6 +1761,7 @@ class PluginVersionInfo(BaseModel): changelog (str): The release notes for the plugin. min_max_framework_version (str): The minimum and maximum framework version required for the plugin (comma separated). """ + version: str released: str breaking_changes: Optional[bool] = None @@ -1772,11 +1770,13 @@ class PluginVersionInfo(BaseModel): changelog: Optional[str] = None min_max_framework_version: Optional[str] = "0.1.0.dev4,0.1.0.dev4" + class PluginVersionRegistry(BaseModel): """Represents the version registry of a plugin. Attributes: versions (List[PluginVersionInfo]): A list of PluginVersionInfo objects representing the different versions of the plugin. """ + latest: Optional[PluginVersionInfo] = None latest_prerelease: Optional[PluginVersionInfo] = None versions: List[PluginVersionInfo] @@ -1790,56 +1790,49 @@ def get_version(self) -> Optional[PluginVersionInfo]: def get_latest_compatible(self, framework_version: str) -> Optional[PluginVersionInfo]: """Returns the latest compatible version for the given framework version. - + Args: framework_version (str): The framework version to check compatibility against. - + Returns: Optional[PluginVersionInfo]: The latest compatible version, or None if no compatible version is found. """ - + try: fw_version = Version(framework_version) except InvalidVersion: - logging.getLogger(__name__).warning( - f"Invalid framework version format: {framework_version}" - ) + logging.getLogger(__name__).warning(f"Invalid framework version format: {framework_version}") return None - + compatible_versions = [] - + for version_info in self.versions: if not version_info.min_max_framework_version: continue - + try: # Parse min and max framework versions - parts = version_info.min_max_framework_version.split(',') + parts = version_info.min_max_framework_version.split(",") if len(parts) != 2: continue - + min_version = Version(parts[0].strip()) max_version = Version(parts[1].strip()) - + # Check if framework version is within range if min_version <= fw_version <= max_version: compatible_versions.append(version_info) - + except (InvalidVersion, ValueError): continue - + if not compatible_versions: return None - + # Sort by version and return the latest try: - sorted_versions = sorted( - compatible_versions, - key=lambda v: Version(v.version), - reverse=True - ) + sorted_versions = sorted(compatible_versions, key=lambda v: Version(v.version), reverse=True) return sorted_versions[0] except InvalidVersion: # If sorting fails, return the first compatible version return compatible_versions[0] - From fa74300c35eb115e532eb1ff6f5fee000aac7dc7 Mon Sep 17 00:00:00 2001 From: habeck Date: Fri, 6 Mar 2026 16:27:26 -0500 Subject: [PATCH 05/60] enh: added PluginInstallationType model Signed-off-by: habeck --- cpex/framework/models.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cpex/framework/models.py b/cpex/framework/models.py index 62355f9a..87166afa 100644 --- a/cpex/framework/models.py +++ b/cpex/framework/models.py @@ -1836,3 +1836,10 @@ def get_latest_compatible(self, framework_version: str) -> Optional[PluginVersio except InvalidVersion: # If sorting fails, return the first compatible version return compatible_versions[0] + +class PluginInstallationType(StrEnum): + """Plugin installation type.""" + BUNDLED = "bundled" # Pre-installed with framework + PYPI = "pypi" # Installed from PyPI + GIT = "git" # Installed from Git repo + LOCAL = "local" # Installed from local path \ No newline at end of file From 5a7c61fb4de86c4c149d0bc8de613a7208ff9be1 Mon Sep 17 00:00:00 2001 From: habeck Date: Fri, 6 Mar 2026 16:32:56 -0500 Subject: [PATCH 06/60] chore: lint fix Signed-off-by: habeck --- cpex/framework/models.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/cpex/framework/models.py b/cpex/framework/models.py index 4929c900..cf8dab05 100644 --- a/cpex/framework/models.py +++ b/cpex/framework/models.py @@ -16,6 +16,8 @@ from pathlib import Path from typing import Any, Generic, List, Optional, Self, TypeVar, Union +from packaging.version import InvalidVersion, Version + # Third-Party from pydantic import ( BaseModel, @@ -27,7 +29,6 @@ field_validator, model_validator, ) -from packaging.version import Version, InvalidVersion # First-Party from cpex.framework.constants import ( @@ -1737,7 +1738,7 @@ def validate_installation_method(self) -> Self: """ if not self.pypi_package and not self.git_repository: raise ValueError( - "At least one installation method must be specified: " "either 'pypi_package' or 'git_repository'" + "At least one installation method must be specified: either 'pypi_package' or 'git_repository'" ) # If git_branch_tag_commit is specified, git_repository must also be specified @@ -1835,9 +1836,11 @@ def get_latest_compatible(self, framework_version: str) -> Optional[PluginVersio # If sorting fails, return the first compatible version return compatible_versions[0] + class PluginInstallationType(StrEnum): """Plugin installation type.""" - BUNDLED = "bundled" # Pre-installed with framework - PYPI = "pypi" # Installed from PyPI - GIT = "git" # Installed from Git repo - LOCAL = "local" # Installed from local path \ No newline at end of file + + BUNDLED = "bundled" # Pre-installed with framework + PYPI = "pypi" # Installed from PyPI + GIT = "git" # Installed from Git repo + LOCAL = "local" # Installed from local path From 1fab4bba9b0925bd65cc65cff671ae76d8b7bdb9 Mon Sep 17 00:00:00 2001 From: habeck Date: Mon, 9 Mar 2026 10:56:35 -0400 Subject: [PATCH 07/60] enh: isolated venv Signed-off-by: habeck --- cpex/framework/constants.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cpex/framework/constants.py b/cpex/framework/constants.py index 0ed341a7..64d8e090 100644 --- a/cpex/framework/constants.py +++ b/cpex/framework/constants.py @@ -13,6 +13,8 @@ # Model constants. # Specialized plugin types. EXTERNAL_PLUGIN_TYPE = "external" +ISOLATED_VENV_PLUGIN_TYPE = "isolated_venv" + # MCP related constants. PYTHON_SUFFIX = ".py" From 39e11810ee14d942f794c92281a0c04591e49197 Mon Sep 17 00:00:00 2001 From: habeck Date: Mon, 9 Mar 2026 10:58:05 -0400 Subject: [PATCH 08/60] enh: serialization support Signed-off-by: habeck --- cpex/framework/models.py | 43 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/cpex/framework/models.py b/cpex/framework/models.py index cf8dab05..b8bbbd80 100644 --- a/cpex/framework/models.py +++ b/cpex/framework/models.py @@ -16,6 +16,7 @@ from pathlib import Path from typing import Any, Generic, List, Optional, Self, TypeVar, Union +import orjson from packaging.version import InvalidVersion, Version # Third-Party @@ -1273,6 +1274,48 @@ def check_config_and_external(self, info: ValidationInfo) -> Self: # pylint: di return self + def get_safe_config(self) -> str: + """Return a new PluginConfig instance without validator methods. + + This method creates a new PluginConfig instance from the serialized data, + ensuring that validator methods are not included. This is useful when passing + the config to external processes or serializing it. + + Returns: + PluginConfig: A new PluginConfig instance with only data fields. + """ + # Get the JSON-safe dictionary representation + safe_data = self.toJSON() + + # Create a new PluginConfig instance from the safe data + # This will run validators again, but the resulting object will be clean + return orjson.dumps(safe_data).decode() + + def toJSON(self) -> dict[str, Any]: + """Serialize the PluginConfig object to a JSON-compatible dictionary. + + This method converts the PluginConfig instance to a dictionary that can be + serialized to JSON. It explicitly excludes validator methods and other + non-data attributes, ensuring only the actual configuration fields are included. + + Returns: + dict[str, Any]: A dictionary representation of the PluginConfig object + with all data fields, ready for JSON serialization. + """ + # Get the base serialization from Pydantic + data = self.model_dump(mode="json", exclude_none=False, exclude_unset=False) + + # Explicitly remove any validator methods or callables that might have been included + # These are the @model_validator decorated methods that should not be serialized + methods_to_exclude = { + "_migrate_legacy_modes", + "check_url_or_script_filled", + "check_config_and_external", + } + + # Filter out any methods or callables from the serialized data + return {k: v for k, v in data.items() if k not in methods_to_exclude and not callable(v)} + class PluginManifest(BaseModel): """Plugin manifest. From 33f68819e97089eee5b4445d13a6adb5e33cab76 Mon Sep 17 00:00:00 2001 From: habeck Date: Mon, 9 Mar 2026 11:23:33 -0400 Subject: [PATCH 09/60] enh: adding support for an isolated plugin via venv. Signed-off-by: habeck --- cpex/framework/loader/plugin.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cpex/framework/loader/plugin.py b/cpex/framework/loader/plugin.py index 48248f6d..3a8627a9 100644 --- a/cpex/framework/loader/plugin.py +++ b/cpex/framework/loader/plugin.py @@ -17,7 +17,7 @@ # First-Party from cpex.framework.base import Plugin -from cpex.framework.constants import EXTERNAL_PLUGIN_TYPE +from cpex.framework.constants import EXTERNAL_PLUGIN_TYPE, ISOLATED_VENV_PLUGIN_TYPE from cpex.framework.external.mcp.client import ExternalPlugin from cpex.framework.models import PluginConfig from cpex.framework.utils import import_module, parse_class_name @@ -142,6 +142,13 @@ async def load_and_instantiate_plugin(self, config: PluginConfig) -> Plugin | No await plugin.initialize() return plugin + if config.kind == ISOLATED_VENV_PLUGIN_TYPE: + from cpex.framework.isolated.client import IsolatedVenvPlugin # pylint: disable=import-outside-toplevel + + plugin: Plugin = IsolatedVenvPlugin(config) + await plugin.initialize() + return plugin + # Handle other plugin types if config.kind not in self._plugin_types: self.__register_plugin_type(config.kind) From 8ab9ed1d6a4868b2b2fb2fb5395c1f8abad06b17 Mon Sep 17 00:00:00 2001 From: habeck Date: Mon, 9 Mar 2026 11:25:31 -0400 Subject: [PATCH 10/60] enh: adding support for an isolated plugin via venv. Signed-off-by: habeck --- cpex/framework/isolated/client.py | 144 +++++++++++++++++++++++++++ cpex/framework/isolated/venv_comm.py | 103 +++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 cpex/framework/isolated/client.py create mode 100644 cpex/framework/isolated/venv_comm.py diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py new file mode 100644 index 00000000..20d72540 --- /dev/null +++ b/cpex/framework/isolated/client.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +"""Location: ./cpex/framework/isolated/client.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck + +Isolated plugin client +Module that contains plugin client code to serve venv isolated plugins. +""" + +import logging +import os +import sys +import venv + +from typing_extensions import Any + +from cpex.framework.base import Plugin +from cpex.framework.constants import CONTEXT, HOOK_TYPE, PAYLOAD, PLUGIN_NAME +from cpex.framework.errors import PluginError, convert_exception_to_error +from cpex.framework.hooks.prompts import PromptPosthookResult, PromptPrehookResult +from cpex.framework.hooks.registry import get_hook_registry +from cpex.framework.hooks.tools import ToolPostInvokeResult, ToolPreInvokeResult +from cpex.framework.isolated.venv_comm import VenvProcessCommunicator +from cpex.framework.models import PluginConfig, PluginContext, PluginErrorModel, PluginPayload, PluginResult + +logger = logging.getLogger(__name__) + + +class IsolatedVenvPlugin(Plugin): + """IsolatedVenvPlugin class.""" + + def __init__(self, config: PluginConfig) -> None: + """Initialize the plugin's venv environment.""" + super().__init__(config) + self.implementation = "Python" + self.comm = None + self.script_path: str = config.config["script_path"] + + async def create_venv(self, venv_path: str = ".venv") -> None: + """Create a new venv environment.""" + # Check Python version + python_version = sys.version_info + print(f"Current Python version: {python_version.major}.{python_version.minor}.{python_version.micro}") + # Create the EnvBuilder with common options + builder = venv.EnvBuilder( + system_site_packages=True, # Don't include system site-packages + clear=False, # Don't clear existing venv if it exists + symlinks=False, # Use symlinks (recommended on Unix-like systems) + upgrade=False, # Don't upgrade existing venv + with_pip=True, # Install pip in the venv + prompt=None, # Use default prompt (directory name) + ) + # Create the virtual environment + print(f"\nCreating virtual environment at: {os.path.abspath(venv_path)}") + try: + builder.create(venv_path) + print("✓ Virtual environment created successfully!") + print("\nTo activate the virtual environment:") + print(f" source {venv_path}/bin/activate # On Unix/macOS") + print(f" {venv_path}\\Scripts\\activate # On Windows") + except Exception as e: + print(f"✗ Error creating virtual environment: {e}") + raise e + + # Called by plugins/framework/loader/plugin.py load_and_instantiate_plugin() + # The plugins/framework/manager.py class (PluginManager) loads and registers the plugin + async def initialize(self) -> None: + """Initialize the plugin's venv environment.""" + self.venv = await self.create_venv(self.config.config["venv_path"]) + self.comm = VenvProcessCommunicator(self.config.config["venv_path"]) + self.comm.install_requirements(self.config.config["requirements_file"]) + + async def invoke_hook(self, hook_type: str, payload: PluginPayload, context: PluginContext) -> PluginResult: + """Invoke a plugin in the context of the active venv (self.comm)""" + registry = get_hook_registry() + result_type = registry.get_result_type(hook_type) + if not result_type: + raise PluginError( + error=PluginErrorModel( + message=f"Hook type '{hook_type}' not registered in hook registry", plugin_name=self.name + ) + ) + + if not self.comm: + raise PluginError(error=PluginErrorModel(message="Plugin comm not initialized", plugin_name=self.name)) + + safe_config = self.config.get_safe_config() + + try: + # Serialize payload and context to ensure they are JSON-serializable + serialized_payload = payload.model_dump(mode="json") if payload else None + serialized_context = context.model_dump(mode="json") if context else None + + # build up the task to send + task = { + "task_type": "load_and_run_hook", + "script_path": self.config.config["script_path"], + "class_name": self.config.config["class_name"], + "config": safe_config, + HOOK_TYPE: hook_type, + PLUGIN_NAME: self.name, + PAYLOAD: serialized_payload, + CONTEXT: serialized_context, + } + result: Any = self.comm.send_task(script_path="cpex/framework/isolated/worker.py", task_data=task) + # + # This is going to be tricky. Need to see what the response is and initialize the proper result object from the dict + # task_data + if hook_type == "tool_pre_invoke": + result = ToolPreInvokeResult( + continue_processing=result.get("continue_processing"), + modified_payload=result.get("modified_payload"), + violation=result.get("violation"), + metadata=result.get("metadata"), + ) + if hook_type == "tool_post_invoke": + result = ToolPostInvokeResult( + continue_processing=result.get("continue_processing"), + modified_payload=result.get("modified_payload"), + violation=result.get("violation"), + metadata=result.get("metadata"), + ) + if hook_type == "prompt_pre_fetch": + result = PromptPrehookResult( + continue_processing=result.get("continue_processing"), + modified_payload=result.get("modified_payload"), + violation=result.get("violation"), + metadata=result.get("metadata"), + ) + if hook_type == "prompt_post_fetch": + result = PromptPosthookResult( + continue_processing=result.get("continue_processing"), + modified_payload=result.get("modified_payload"), + violation=result.get("violation"), + metadata=result.get("metadata"), + ) + return result + except PluginError as pe: + logger.exception(pe) + raise + except Exception as e: + logger.exception(e) + raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) from e diff --git a/cpex/framework/isolated/venv_comm.py b/cpex/framework/isolated/venv_comm.py new file mode 100644 index 00000000..60c9933f --- /dev/null +++ b/cpex/framework/isolated/venv_comm.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +""" +Location: ./cpex/framework/isolated/venv_comm.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Fred Araujo, Ted Habeck +""" + +import json +import logging +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +import orjson + +logger = logging.getLogger(__name__) + + +class VenvProcessCommunicator: + """Handles communication with child processes in different virtual environments.""" + + def __init__(self, venv_path: str) -> None: + """ + Initialize communicator with target virtual environment. + + Args: + venv_path (str): Path to the virtual environment directory + """ + self.venv_path = Path(venv_path) + self.python_executable = self._get_python_executable() + logger.info("cwd: %s", os.getcwd()) + + def _get_python_executable(self): + """Get the Python executable path for the target venv.""" + if sys.platform == "win32": + python_exe = self.venv_path / "Scripts" / "python.exe" + else: + python_exe = self.venv_path / "bin" / "python" + + if not python_exe.exists(): + raise FileNotFoundError(f"Python executable not found at {python_exe}") + + return str(python_exe) + + def install_requirements(self, requirements_file: str) -> None: + """ + Install Python requirements from a file in the target venv. + Args: + requirements_file (str): Path to the requirements file. + """ + requirements_path = Path(requirements_file) + if requirements_path.exists(): + rc = subprocess.check_call([self.python_executable, "-m", "pip", "install", "-r", requirements_file]) + if rc != 0: + raise Exception(f"Failed to install requirements from {requirements_file}") + + def send_task(self, script_path: str, task_data: Any) -> Any: + """ + Send a task to child process and get response. + + Args: + script_path (str): Path to the child script + task_data (dict): Data to send to child process + + Returns: + dict: Response from child process + """ + process = None + try: + # Prepare input data as JSON + input_json = orjson.dumps(task_data).decode() + # Start child process + process = subprocess.Popen( + [self.python_executable, script_path], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=os.getcwd(), # Maintain current working directory + ) + + # Send data and get response + stdout, stderr = process.communicate(input=input_json, timeout=30) + + if process.returncode != 0: + raise RuntimeError(f"Child process failed: {stderr}") + + # Parse response + try: + response = json.loads(stdout.strip()) + return response + except json.JSONDecodeError: + raise RuntimeError(f"Invalid JSON response from child: {stdout}") + + except subprocess.TimeoutExpired: + if process: + process.kill() + raise RuntimeError("Child process timed out") + except Exception as e: + raise RuntimeError(f"Communication error: {e}") From ebe537acb22974fdf3a0f3fe824f01bed5474463 Mon Sep 17 00:00:00 2001 From: habeck Date: Mon, 9 Mar 2026 12:52:55 -0400 Subject: [PATCH 11/60] enh: adding support for an isolated plugin via venv. Signed-off-by: habeck --- cpex/framework/isolated/worker.py | 125 ++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 cpex/framework/isolated/worker.py diff --git a/cpex/framework/isolated/worker.py b/cpex/framework/isolated/worker.py new file mode 100644 index 00000000..3d220dad --- /dev/null +++ b/cpex/framework/isolated/worker.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +"""Location: ./cpex/framework/isolated/server.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck, Fred Araujo + +Isolated plugin server +Module that contains plugin server code to invoke hooks in native plugins. +""" + +import asyncio +import importlib.metadata +import json +import logging +import platform +import sys +from pathlib import Path +from types import ModuleType +from typing import Type, cast + +from cpex.framework.base import HookRef, Plugin, PluginRef +from cpex.framework.constants import HOOK_TYPE +from cpex.framework.loader.config import ConfigLoader +from cpex.framework.manager import PluginExecutor +from cpex.framework.models import PluginContext +from cpex.framework.utils import parse_class_name + +logger = logging.getLogger(__name__) + + +def get_environment_info(): + """Get information about current Python environment.""" + return { + "python_version": sys.version, + "python_executable": sys.executable, + "platform": platform.platform(), + "installed_packages": [str(d) for d in importlib.metadata.entry_points()][:10], # First 10 packages + } + + +def get_proper_config(name, module_path): + """ + Load a config which has all it's proper decorations + """ + plugin_loader_config = ConfigLoader.load_config(Path(f"{module_path}/config.yaml").resolve(), use_jinja=False) + plugins: list[dict] = [] + config = None + if plugin_loader_config.plugins: + for plug in plugin_loader_config.plugins: + plugins.append(plug.model_dump()) + if plug.name == name: + # config = plug.model_dump() + config = plug + return config + return None + + +async def process_task(task_data): + """Process the task received from parent.""" + task_type = task_data.get("task_type") + + if task_type == "info": + return { + "status": "success", + "environment": get_environment_info(), + "message": "Environment info retrieved successfully", + } + # This is essentially emulating the plugin loader's load and instantiate plugin + if task_type == "load_and_run_hook": + # relative path from project root. + json_config = task_data.get("config") + config_raw = json.loads(json_config) + module_path: str = task_data.get("script_path") + sys.path.append(str(Path(module_path).resolve())) + config = get_proper_config(config_raw.get("name"), module_path) + hook_type = task_data.get(HOOK_TYPE) + cls_name: str = task_data.get("class_name") + mod_name, n_cls_name = parse_class_name(cls_name) + module: ModuleType = importlib.import_module(mod_name) + # cool, we found the module, and verified it implemented the hook type. + class_ = getattr(module, n_cls_name) + plugin_type = cast(Type[Plugin], class_) + plugin = plugin_type(config) + await plugin.initialize() + # now invoke the hook + plugin_ref = PluginRef(plugin) + hook_ref = HookRef(hook_type, plugin_ref) + executor = PluginExecutor(None, 30) + # retrieve the context + context = task_data.get("context") + # ^^ may need to json.loads(context) before passing it to PluginContext below vv + plugin_context = PluginContext( + state=context.get("state"), global_context=context.get("global_context"), metadata=context.get("metadata") + ) + # global_context = context.get("global_context") + result = await executor.execute_plugin( + hook_ref, payload=task_data.get("payload"), local_context=plugin_context, violations_as_exceptions=False + ) + return result + + +async def main(): + """Main function - read from stdin, process, write to stdout.""" + try: + # Read input from parent process + input_data = sys.stdin.read() + task_data = json.loads(input_data) + + # Process the task + response = await process_task(task_data) + serializable_response = response.model_dump(mode="json") if response else None + # Send response back to parent + print(json.dumps(serializable_response)) + + except json.JSONDecodeError: + error_response = {"status": "error", "message": "Invalid JSON input"} + print(json.dumps(error_response)) + except Exception as e: + error_response = {"status": "error", "message": f"Unexpected error: {str(e)}"} + print(json.dumps(error_response)) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + asyncio.run(main()) From a826dc0a25e3e9bfda3ec7ab13dab9c26a8b7936 Mon Sep 17 00:00:00 2001 From: habeck Date: Mon, 9 Mar 2026 16:20:40 -0400 Subject: [PATCH 12/60] fix: initialization should fail if script path does not exist. Signed-off-by: habeck --- cpex/framework/isolated/client.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index 20d72540..da24dac8 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -10,6 +10,7 @@ import logging import os +from pathlib import Path import sys import venv @@ -67,6 +68,11 @@ async def create_venv(self, venv_path: str = ".venv") -> None: # The plugins/framework/manager.py class (PluginManager) loads and registers the plugin async def initialize(self) -> None: """Initialize the plugin's venv environment.""" + # ensure the config is validated + path = Path(self.config.config.get("script_path")).resolve() + if not os.path.exists(path): + raise FileNotFoundError(f"script_path not found: {path}") + self.venv = await self.create_venv(self.config.config["venv_path"]) self.comm = VenvProcessCommunicator(self.config.config["venv_path"]) self.comm.install_requirements(self.config.config["requirements_file"]) From fd8113a5fc4b71215e92fdea2780a0e1e0aefe9b Mon Sep 17 00:00:00 2001 From: habeck Date: Mon, 9 Mar 2026 17:02:04 -0400 Subject: [PATCH 13/60] chore: unit tests and fixtures for plugin isolation via venv. Signed-off-by: habeck --- .../fixtures/configs/isolated_plugin.yaml | 35 ++ tests/unit/cpex/framework/isolated/README.md | 199 +++++++++ .../unit/cpex/framework/isolated/__init__.py | 10 + .../unit/cpex/framework/isolated/conftest.py | 145 +++++++ .../cpex/framework/isolated/test_client.py | 371 +++++++++++++++++ .../framework/isolated/test_integration.py | 384 ++++++++++++++++++ .../cpex/framework/isolated/test_venv_comm.py | 245 +++++++++++ .../cpex/framework/isolated/test_worker.py | 294 ++++++++++++++ 8 files changed, 1683 insertions(+) create mode 100644 tests/unit/cpex/fixtures/configs/isolated_plugin.yaml create mode 100644 tests/unit/cpex/framework/isolated/README.md create mode 100644 tests/unit/cpex/framework/isolated/__init__.py create mode 100644 tests/unit/cpex/framework/isolated/conftest.py create mode 100644 tests/unit/cpex/framework/isolated/test_client.py create mode 100644 tests/unit/cpex/framework/isolated/test_integration.py create mode 100644 tests/unit/cpex/framework/isolated/test_venv_comm.py create mode 100644 tests/unit/cpex/framework/isolated/test_worker.py diff --git a/tests/unit/cpex/fixtures/configs/isolated_plugin.yaml b/tests/unit/cpex/fixtures/configs/isolated_plugin.yaml new file mode 100644 index 00000000..3b803d31 --- /dev/null +++ b/tests/unit/cpex/fixtures/configs/isolated_plugin.yaml @@ -0,0 +1,35 @@ +# Plugin directories to scan +plugin_dirs: +- "tests/unit/cpex/fixtures/plugins/isolated" + +# Global plugin settings +plugin_settings: + parallel_execution_within_band: true + plugin_timeout: 30 + fail_on_plugin_error: false + enable_plugin_api: true + plugin_health_check_interval: 60 + + +plugins: + - name: "test_plugin" + kind: "isolated_venv" + description: "A framework testing filter plugin" + version: "0.1.0" + author: "habeck" + hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] + tags: ["plugin"] + mode: "sequential" # enforce | permissive | disabled + priority: 150 + conditions: + # Apply to specific tools/servers + - server_ids: [] # Apply to all servers + tenant_ids: [] # Apply to all tenants + config: + # Plugin config dict passed to the plugin constructor + class_name: "test_plugin.plugin.TestPlugin" + venv_path: "tests/unit/cpex/fixtures/plugins/isolated/test_plugin/.venv" + requirements_file: "tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt" + # essentially the plugin folder hosting the plugin + script_path: "tests/unit/cpex/fixtures/plugins/isolated" + diff --git a/tests/unit/cpex/framework/isolated/README.md b/tests/unit/cpex/framework/isolated/README.md new file mode 100644 index 00000000..162e5489 --- /dev/null +++ b/tests/unit/cpex/framework/isolated/README.md @@ -0,0 +1,199 @@ +# Isolated Plugin Framework Tests + +This directory contains comprehensive unit and integration tests for the isolated plugin framework, which enables running plugins in separate Python virtual environments. + +## Overview + +The isolated plugin framework consists of three main components: + +1. **VenvProcessCommunicator** (`venv_comm.py`) - Handles communication with child processes in different virtual environments +2. **IsolatedVenvPlugin** (`client.py`) - Plugin client that manages venv-isolated plugins +3. **Worker** (`worker.py`) - Worker process that runs inside the venv and executes plugin hooks + +## Test Files + +### `test_venv_comm.py` +Tests for the `VenvProcessCommunicator` class that handles inter-process communication. + +**Coverage:** +- Virtual environment path validation (Unix/Windows) +- Python executable detection +- Requirements installation (success/failure cases) +- Task sending and response handling +- Error handling (timeouts, invalid JSON, process failures) +- Complex data serialization +- Working directory maintenance + +**Key Test Cases:** +- `test_init_valid_venv` - Validates proper initialization with valid venv +- `test_send_task_success` - Tests successful task execution +- `test_send_task_timeout` - Tests timeout handling +- `test_install_requirements_success` - Tests pip installation + +### `test_client.py` +Tests for the `IsolatedVenvPlugin` class that serves as the plugin client. + +**Coverage:** +- Plugin initialization and configuration +- Virtual environment creation +- Hook invocation for all hook types (tool_pre_invoke, tool_post_invoke, prompt_pre_fetch, prompt_post_fetch) +- Payload and context serialization +- Error handling (PluginError, generic exceptions) +- Policy violation handling +- Safe config generation + +**Key Test Cases:** +- `test_invoke_hook_tool_pre_invoke_success` - Tests tool pre-invoke hook +- `test_invoke_hook_with_violation` - Tests policy violation handling +- `test_invoke_hook_plugin_error` - Tests PluginError propagation +- `test_invoke_hook_serialization` - Tests proper data serialization + +### `test_worker.py` +Tests for the worker process functions that execute inside the venv. + +**Coverage:** +- Environment information retrieval +- Plugin configuration loading +- Task processing (info, load_and_run_hook) +- Plugin loading and instantiation +- Hook execution +- Error handling (import errors, missing configs) +- Multiple hook type support +- sys.path modification + +**Key Test Cases:** +- `test_get_environment_info` - Tests environment info collection +- `test_process_task_load_and_run_hook_success` - Tests successful hook execution +- `test_process_task_with_different_hook_types` - Tests all hook types +- `test_process_task_import_error` - Tests import error handling + +### `test_integration.py` +Integration tests that verify the entire isolated plugin system working together. + +**Coverage:** +- Full plugin lifecycle (initialization → hook invocation → cleanup) +- PluginManager integration with isolated plugins +- Context propagation through the isolation boundary +- Multiple hook type execution +- Policy violation handling end-to-end +- Error handling across process boundaries + +**Key Test Cases:** +- `test_isolated_plugin_full_lifecycle` - Tests complete plugin lifecycle +- `test_isolated_plugin_context_propagation` - Tests context serialization +- `test_isolated_plugin_with_multiple_hooks` - Tests multiple hook types +- `test_isolated_plugin_violation_handling` - Tests violation propagation + +### `conftest.py` +Pytest fixtures shared across all isolated plugin tests. + +**Fixtures:** +- `mock_venv_structure` - Creates mock venv directory structure +- `sample_plugin_config` - Provides sample plugin configuration +- `sample_global_context` - Creates test GlobalContext +- `sample_plugin_context` - Creates test PluginContext +- `mock_communicator` - Provides mock VenvProcessCommunicator +- `sample_requirements_file` - Creates test requirements.txt + +## Running the Tests + +### Run all isolated plugin tests: +```bash +pytest tests/unit/cpex/framework/isolated/ +``` + +### Run specific test file: +```bash +pytest tests/unit/cpex/framework/isolated/test_venv_comm.py +``` + +### Run with coverage: +```bash +pytest tests/unit/cpex/framework/isolated/ --cov=cpex.framework.isolated --cov-report=html +``` + +### Run specific test: +```bash +pytest tests/unit/cpex/framework/isolated/test_client.py::TestIsolatedVenvPlugin::test_invoke_hook_tool_pre_invoke_success +``` + +## Test Architecture + +### Mocking Strategy +The tests use extensive mocking to avoid: +- Creating actual virtual environments (slow and resource-intensive) +- Installing real packages via pip +- Spawning actual subprocesses +- File system operations where possible + +### Fixtures +Common test fixtures are defined in `conftest.py` to promote code reuse and consistency across tests. + +### Test Organization +Tests are organized by component: +- **Unit tests** - Test individual functions and methods in isolation +- **Integration tests** - Test components working together + +## Coverage Goals + +The test suite aims for: +- **Line coverage**: >90% +- **Branch coverage**: >85% +- **Function coverage**: 100% + +## Key Testing Patterns + +### 1. Async Testing +```python +@pytest.mark.asyncio +async def test_async_function(): + result = await some_async_function() + assert result is not None +``` + +### 2. Mock Subprocess Communication +```python +@patch("subprocess.Popen") +def test_send_task(mock_popen): + mock_process = MagicMock() + mock_process.communicate.return_value = ('{"status": "ok"}', "") + mock_popen.return_value = mock_process + # Test code here +``` + +### 3. Context Propagation Testing +```python +def test_context_propagation(): + # Create context with specific data + context = PluginContext(global_context=GlobalContext(...)) + # Invoke hook + result = await plugin.invoke_hook(hook_type, payload, context) + # Verify context was properly serialized and sent +``` + +## Common Issues and Solutions + +### Issue: Tests fail with "Python executable not found" +**Solution**: Ensure mock_venv_structure fixture is being used, which creates the proper directory structure. + +### Issue: Async tests hang +**Solution**: Ensure all async functions are properly awaited and use `@pytest.mark.asyncio` decorator. + +### Issue: Import errors in tests +**Solution**: Check that all required dependencies are installed in the test environment. + +## Contributing + +When adding new tests: +1. Follow the existing naming conventions (`test__`) +2. Add docstrings explaining what the test validates +3. Use fixtures from `conftest.py` where applicable +4. Mock external dependencies (filesystem, network, subprocesses) +5. Test both success and failure paths +6. Update this README if adding new test files + +## Related Documentation + +- [Isolated Plugin Design](../../../../cpex/framework/isolated/design.md) +- [Plugin Framework Documentation](../../../../cpex/framework/README.md) +- [Main Test Suite](../../../README.md) \ No newline at end of file diff --git a/tests/unit/cpex/framework/isolated/__init__.py b/tests/unit/cpex/framework/isolated/__init__.py new file mode 100644 index 00000000..f34f5b12 --- /dev/null +++ b/tests/unit/cpex/framework/isolated/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +"""Location: ./tests/unit/cpex/framework/isolated/__init__.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck + +Unit tests for isolated plugin framework. +""" + +# Made with Bob diff --git a/tests/unit/cpex/framework/isolated/conftest.py b/tests/unit/cpex/framework/isolated/conftest.py new file mode 100644 index 00000000..9b79a787 --- /dev/null +++ b/tests/unit/cpex/framework/isolated/conftest.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +"""Location: ./tests/unit/cpex/framework/isolated/conftest.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck + +Pytest fixtures for isolated plugin tests. +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from cpex.framework import GlobalContext +from cpex.framework.models import PluginConfig, PluginContext + + +@pytest.fixture +def mock_venv_structure(tmp_path): + """Create a mock virtual environment directory structure. + + Args: + tmp_path: pytest tmp_path fixture + + Returns: + Path to the mock venv directory + """ + venv_path = tmp_path / ".venv" + venv_path.mkdir() + + # Create appropriate bin/Scripts directory based on platform + if sys.platform == "win32": + scripts_dir = venv_path / "Scripts" + scripts_dir.mkdir() + python_exe = scripts_dir / "python.exe" + else: + bin_dir = venv_path / "bin" + bin_dir.mkdir() + python_exe = bin_dir / "python" + + # Create a dummy python executable + python_exe.touch() + python_exe.chmod(0o755) + + return venv_path + + +@pytest.fixture +def sample_plugin_config(tmp_path): + """Create a sample plugin configuration for testing. + + Args: + tmp_path: pytest tmp_path fixture + + Returns: + PluginConfig instance + """ + venv_path = tmp_path / ".venv" + script_path = tmp_path / "plugin" + requirements_file = tmp_path / "requirements.txt" + + config_dict = { + "name": "test_isolated_plugin", + "kind": "isolated_venv", + "description": "Test isolated plugin", + "version": "1.0.0", + "author": "Test Author", + "hooks": ["tool_pre_invoke", "tool_post_invoke"], + "config": { + "venv_path": str(venv_path), + "script_path": str(script_path), + "requirements_file": str(requirements_file), + "class_name": "test_plugin.TestPlugin" + } + } + return PluginConfig(**config_dict) + + +@pytest.fixture +def sample_global_context(): + """Create a sample GlobalContext for testing. + + Returns: + GlobalContext instance + """ + return GlobalContext( + request_id="test-req-123", + user="test_user", + tenant_id="test-tenant", + server_id="test-server" + ) + + +@pytest.fixture +def sample_plugin_context(sample_global_context): + """Create a sample PluginContext for testing. + + Args: + sample_global_context: GlobalContext fixture + + Returns: + PluginContext instance + """ + return PluginContext( + global_context=sample_global_context, + state={"test_key": "test_value"}, + metadata={"test_meta": "test_data"} + ) + + +@pytest.fixture +def mock_communicator(): + """Create a mock VenvProcessCommunicator. + + Returns: + MagicMock instance configured as a communicator + """ + mock_comm = MagicMock() + mock_comm.install_requirements = MagicMock() + mock_comm.send_task = MagicMock(return_value={ + "continue_processing": True, + "modified_payload": None, + "violation": None, + "metadata": {} + }) + return mock_comm + + +@pytest.fixture +def sample_requirements_file(tmp_path): + """Create a sample requirements.txt file. + + Args: + tmp_path: pytest tmp_path fixture + + Returns: + Path to the requirements file + """ + requirements_file = tmp_path / "requirements.txt" + requirements_file.write_text("pytest>=7.0.0\nrequests>=2.28.0\n") + return requirements_file + +# Made with Bob diff --git a/tests/unit/cpex/framework/isolated/test_client.py b/tests/unit/cpex/framework/isolated/test_client.py new file mode 100644 index 00000000..e7863179 --- /dev/null +++ b/tests/unit/cpex/framework/isolated/test_client.py @@ -0,0 +1,371 @@ +# -*- coding: utf-8 -*- +"""Location: ./tests/unit/cpex/framework/isolated/test_client.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck + +Unit tests for IsolatedVenvPlugin. +""" + +import asyncio +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest + +from cpex.framework.errors import PluginError +from cpex.framework.hooks.prompts import PromptPosthookResult, PromptPrehookResult +from cpex.framework.hooks.tools import ToolPostInvokeResult, ToolPreInvokeResult +from cpex.framework.isolated.client import IsolatedVenvPlugin +from cpex.framework.models import PluginConfig, PluginContext, PluginErrorModel + + +class TestIsolatedVenvPlugin: + """Test suite for IsolatedVenvPlugin class.""" + + @pytest.fixture + def mock_config(self, tmp_path): + """Create a mock plugin configuration.""" + venv_path = tmp_path / ".venv" + script_path = "tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt" + requirements_file = tmp_path / "requirements.txt" + + # config_dict = { + # "name": "test_isolated_plugin", + # "kind": "isolated_venv", + # "description": "Test isolated plugin", + # "version": "1.0.0", + # "author": "Test Author", + # "hooks": ["tool_pre_invoke", "tool_post_invoke"], + # "config": { + # "venv_path": str(venv_path), + # "script_path": str(script_path), + # "requirements_file": str(requirements_file), + # "class_name": "test_plugin.TestPlugin", + # }, + # } + config_dict = { + "name": "test_plugin", + "kind": "isolated_venv", + "description": "Test plugin", + "version": "1.0.0", + "author": "Test", + "hooks": ["tool_pre_invoke"], + "config": { + "class_name": "test_plugin.TestPlugin", + "venv_path": venv_path, + "requirements_file": requirements_file, + "script_path": script_path + } + } + + return PluginConfig(**config_dict) + + @pytest.fixture + def plugin(self, mock_config): + """Create an IsolatedVenvPlugin instance.""" + return IsolatedVenvPlugin(mock_config) + + @pytest.fixture + def plugin_context(self): + """Create a PluginContext instance""" + context = {"state": {}, "global_context": {"request_id": "req-123"}, "metadata": {}} + plugin_context = PluginContext( + state=context.get("state"), global_context=context.get("global_context"), metadata=context.get("metadata") + ) + return plugin_context + + def test_init(self, plugin, mock_config): + """Test plugin initialization.""" + assert plugin.name == "test_plugin" + assert plugin.implementation == "Python" + assert plugin.script_path == mock_config.config["script_path"] + assert plugin.comm is None + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.venv.EnvBuilder") + async def test_create_venv_success(self, mock_builder_class, plugin, tmp_path): + """Test successful venv creation.""" + venv_path = tmp_path / ".venv" + mock_builder = MagicMock() + mock_builder_class.return_value = mock_builder + + await plugin.create_venv(str(venv_path)) + + mock_builder_class.assert_called_once() + mock_builder.create.assert_called_once_with(str(venv_path)) + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.venv.EnvBuilder") + async def test_create_venv_failure(self, mock_builder_class, plugin, tmp_path): + """Test venv creation failure.""" + venv_path = tmp_path / ".venv" + mock_builder = MagicMock() + mock_builder.create.side_effect = Exception("Creation failed") + mock_builder_class.return_value = mock_builder + + with pytest.raises(Exception, match="Creation failed"): + await plugin.create_venv(str(venv_path)) + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.VenvProcessCommunicator") + @patch.object(IsolatedVenvPlugin, "create_venv") + async def test_initialize_success(self, mock_create_venv, mock_comm_class, plugin): + """Test successful plugin initialization.""" + mock_create_venv.return_value = None + mock_comm = MagicMock() + mock_comm_class.return_value = mock_comm + + await plugin.initialize() + + mock_create_venv.assert_called_once() + mock_comm_class.assert_called_once() + mock_comm.install_requirements.assert_called_once() + assert plugin.comm is not None + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.get_hook_registry") + async def test_invoke_hook_unregistered_hook_type(self, mock_get_registry, plugin, plugin_context): + """Test invoking an unregistered hook type.""" + mock_registry = MagicMock() + mock_registry.get_result_type.return_value = None + mock_get_registry.return_value = mock_registry + + plugin.comm = MagicMock() + + with pytest.raises(PluginError, match="Hook type .* not registered"): + await plugin.invoke_hook("invalid_hook", None, plugin_context) + + @pytest.mark.asyncio + async def test_invoke_hook_no_comm(self, plugin, plugin_context): + """Test invoking hook without initialized communicator.""" + plugin.comm = None + with pytest.raises(PluginError, match="Plugin comm not initialized"): + await plugin.invoke_hook("tool_pre_invoke", None, plugin_context) + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.get_hook_registry") + async def test_invoke_hook_tool_pre_invoke_success(self, mock_get_registry, plugin, plugin_context): + """Test successful tool_pre_invoke hook invocation.""" + # Setup registry + mock_registry = MagicMock() + mock_registry.get_result_type.return_value = ToolPreInvokeResult + mock_get_registry.return_value = mock_registry + + # Setup communicator + mock_comm = MagicMock() + response_data = { + "continue_processing": True, + "modified_payload": {"name": "test_tool", "args": {}}, + "violation": None, + "metadata": {}, + } + mock_comm.send_task.return_value = response_data + plugin.comm = mock_comm + + # Create payload and context + from cpex.framework.hooks.tools import ToolPreInvokePayload + + payload = ToolPreInvokePayload(name="test_tool", args={}) + result = await plugin.invoke_hook("tool_pre_invoke", payload, plugin_context) + + assert isinstance(result, ToolPreInvokeResult) + assert result.continue_processing is True + mock_comm.send_task.assert_called_once() + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.get_hook_registry") + async def test_invoke_hook_tool_post_invoke_success(self, mock_get_registry, plugin, plugin_context): + """Test successful tool_post_invoke hook invocation.""" + mock_registry = MagicMock() + mock_registry.get_result_type.return_value = ToolPostInvokeResult + mock_get_registry.return_value = mock_registry + + mock_comm = MagicMock() + response_data = { + "continue_processing": True, + "modified_payload": {"name": "test_tool", "result": "success"}, + "violation": None, + "metadata": {}, + } + mock_comm.send_task.return_value = response_data + plugin.comm = mock_comm + + from cpex.framework.hooks.tools import ToolPostInvokePayload + + payload = ToolPostInvokePayload(name="test_tool", result="success") + + result = await plugin.invoke_hook("tool_post_invoke", payload, plugin_context) + + assert isinstance(result, ToolPostInvokeResult) + assert result.continue_processing is True + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.get_hook_registry") + async def test_invoke_hook_prompt_pre_fetch_success(self, mock_get_registry, plugin, plugin_context): + """Test successful prompt_pre_fetch hook invocation.""" + mock_registry = MagicMock() + mock_registry.get_result_type.return_value = PromptPrehookResult + mock_get_registry.return_value = mock_registry + + mock_comm = MagicMock() + response_data = { + "continue_processing": True, + "modified_payload": {"prompt_id": "test", "args": {}}, + "violation": None, + "metadata": {}, + } + mock_comm.send_task.return_value = response_data + plugin.comm = mock_comm + + from cpex.framework.hooks.prompts import PromptPrehookPayload + + payload = PromptPrehookPayload(prompt_id="test", args={}) + + result = await plugin.invoke_hook("prompt_pre_fetch", payload, plugin_context) + + assert isinstance(result, PromptPrehookResult) + assert result.continue_processing is True + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.get_hook_registry") + async def test_invoke_hook_prompt_post_fetch_success(self, mock_get_registry, plugin, plugin_context): + """Test successful prompt_post_fetch hook invocation.""" + mock_registry = MagicMock() + mock_registry.get_result_type.return_value = PromptPosthookResult + mock_get_registry.return_value = mock_registry + + mock_comm = MagicMock() + response_data = { + "continue_processing": True, + "modified_payload": {"prompt_id": "test", "result": {}}, + "violation": None, + "metadata": {}, + } + mock_comm.send_task.return_value = response_data + plugin.comm = mock_comm + + from cpex.framework.hooks.prompts import PromptPosthookPayload + + payload = PromptPosthookPayload(prompt_id="test", result={}) + result = await plugin.invoke_hook("prompt_post_fetch", payload, plugin_context) + + assert isinstance(result, PromptPosthookResult) + assert result.continue_processing is True + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.get_hook_registry") + async def test_invoke_hook_with_violation(self, mock_get_registry, plugin, plugin_context): + """Test hook invocation that returns a violation.""" + mock_registry = MagicMock() + mock_registry.get_result_type.return_value = ToolPreInvokeResult + mock_get_registry.return_value = mock_registry + + mock_comm = MagicMock() + response_data = { + "continue_processing": False, + "modified_payload": None, + "violation": {"reason": "Policy violation", "description":"severity high", "code": "PROHIBITED_CONTENT"}, + "metadata": {}, + } + mock_comm.send_task.return_value = response_data + plugin.comm = mock_comm + + from cpex.framework.hooks.tools import ToolPreInvokePayload + + payload = ToolPreInvokePayload(name="test_tool", args={}) + + result = await plugin.invoke_hook("tool_pre_invoke", payload, plugin_context) + + assert isinstance(result, ToolPreInvokeResult) + assert result.continue_processing is False + assert result.violation is not None + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.get_hook_registry") + async def test_invoke_hook_plugin_error(self, mock_get_registry, plugin, plugin_context): + """Test hook invocation that raises PluginError.""" + mock_registry = MagicMock() + mock_registry.get_result_type.return_value = ToolPreInvokeResult + mock_get_registry.return_value = mock_registry + + mock_comm = MagicMock() + mock_comm.send_task.side_effect = PluginError( + error=PluginErrorModel(message="Test error", plugin_name="test_plugin") + ) + plugin.comm = mock_comm + + from cpex.framework.hooks.tools import ToolPreInvokePayload + + payload = ToolPreInvokePayload(name="test_tool", args={}) + with pytest.raises(PluginError): + await plugin.invoke_hook("tool_pre_invoke", payload, plugin_context) + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.get_hook_registry") + @patch("cpex.framework.isolated.client.convert_exception_to_error") + async def test_invoke_hook_generic_exception(self, mock_convert, mock_get_registry, plugin, plugin_context): + """Test hook invocation that raises generic exception.""" + mock_registry = MagicMock() + mock_registry.get_result_type.return_value = ToolPreInvokeResult + mock_get_registry.return_value = mock_registry + + mock_comm = MagicMock() + mock_comm.send_task.side_effect = ValueError("Test error") + plugin.comm = mock_comm + + mock_convert.return_value = PluginErrorModel(message="Converted error", plugin_name="test_plugin") + + from cpex.framework.hooks.tools import ToolPreInvokePayload + + payload = ToolPreInvokePayload(name="test_tool", args={}) + + with pytest.raises(PluginError): + await plugin.invoke_hook("tool_pre_invoke", payload, plugin_context) + + mock_convert.assert_called_once() + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.get_hook_registry") + async def test_invoke_hook_serialization(self, mock_get_registry, plugin): + """Test that payload and context are properly serialized.""" + mock_registry = MagicMock() + mock_registry.get_result_type.return_value = ToolPreInvokeResult + mock_get_registry.return_value = mock_registry + + mock_comm = MagicMock() + response_data = {"continue_processing": True, "modified_payload": None, "violation": None, "metadata": {}} + mock_comm.send_task.return_value = response_data + plugin.comm = mock_comm + + from cpex.framework.hooks.tools import ToolPreInvokePayload + from cpex.framework import GlobalContext + + payload = ToolPreInvokePayload(name="test_tool", args={"key": "value"}) + global_ctx = GlobalContext(request_id="req-123", user="alice") + context = PluginContext(global_context=global_ctx) + + await plugin.invoke_hook("tool_pre_invoke", payload, context) + + # Verify send_task was called with serialized data + call_args = mock_comm.send_task.call_args + task_data = call_args[1]["task_data"] + + assert "payload" in task_data + assert "context" in task_data + assert task_data["hook_type"] == "tool_pre_invoke" + assert task_data["plugin_name"] == plugin.name + + def test_get_safe_config(self, plugin): + """Test that get_safe_config returns sanitized config.""" + safe_config = plugin.config.get_safe_config() + assert isinstance(safe_config, str) + # Should be valid JSON + import json + + config_dict = json.loads(safe_config) + assert "name" in config_dict + + +# Made with Bob diff --git a/tests/unit/cpex/framework/isolated/test_integration.py b/tests/unit/cpex/framework/isolated/test_integration.py new file mode 100644 index 00000000..f3a856f7 --- /dev/null +++ b/tests/unit/cpex/framework/isolated/test_integration.py @@ -0,0 +1,384 @@ +# -*- coding: utf-8 -*- +"""Location: ./tests/unit/cpex/framework/isolated/test_integration.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck + +Integration tests for isolated plugin system. +""" + +import asyncio +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest + +from cpex.framework import GlobalContext, PluginManager +from cpex.framework.hooks.tools import ToolPreInvokePayload +from cpex.framework.isolated.client import IsolatedVenvPlugin +from cpex.framework.models import PluginConfig + + +class TestIsolatedPluginIntegration: + """Integration tests for the isolated plugin system.""" + + @pytest.fixture + def integration_config_path(self, tmp_path): + """Create a temporary config file for integration testing.""" + config_content = """ +plugin_dirs: + - "xplugins" + +plugin_settings: + parallel_execution_within_band: true + plugin_timeout: 30 + fail_on_plugin_error: false + +plugins: + - name: "test_isolated_plugin" + kind: "isolated_venv" + description: "Test isolated plugin" + version: "1.0.0" + author: "Test" + hooks: ["tool_pre_invoke"] + config: + class_name: "test_plugin.TestPlugin" + venv_path: "xplugins/test_plugin/.venv" + requirements_file: "xplugins/test_plugin/requirements.txt" + script_path: "xplugins" +""" + config_file = tmp_path / "test_config.yaml" + config_file.write_text(config_content) + return str(config_file) + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.VenvProcessCommunicator") + @patch.object(IsolatedVenvPlugin, "create_venv") + async def test_plugin_manager_with_isolated_plugin( + self, mock_create_venv, mock_comm_class, integration_config_path + ): + """Test PluginManager loading and initializing an isolated plugin.""" + # Setup mocks + mock_create_venv.return_value = None + mock_comm = MagicMock() + mock_comm.install_requirements = MagicMock() + mock_comm_class.return_value = mock_comm + + # Create manager + manager = PluginManager(integration_config_path) + + # This will fail because the config path doesn't exist in the test environment + # but we can test the structure + with pytest.raises(RuntimeError): + await manager.initialize() + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.VenvProcessCommunicator") + @patch.object(IsolatedVenvPlugin, "create_venv") + async def test_isolated_plugin_full_lifecycle(self, mock_create_venv, mock_comm_class, tmp_path): + """Test full lifecycle of an isolated plugin.""" + # Setup + mock_create_venv.return_value = None + mock_comm = MagicMock() + mock_comm.install_requirements = MagicMock() + mock_comm.send_task.return_value = { + "continue_processing": True, + "modified_payload": None, + "violation": None, + "metadata": {} + } + mock_comm_class.return_value = mock_comm + # manager = PluginManager("./tests/unit/cpex/fixtures/configs/isolated_plugin.yaml") + # Create plugin config + # config_dict = { + # "name": "test_plugin", + # "kind": "isolated_venv", + # "description": "Test plugin", + # "version": "1.0.0", + # "author": "Test", + # "hooks": ["tool_pre_invoke"], + # "config": { + # "class_name": "test_plugin.TestPlugin", + # "venv_path": str(tmp_path / ".venv"), + # "requirements_file": str(tmp_path / "requirements.txt"), + # "script_path": str(tmp_path / "plugins") + # } + # } + + config_dict = { + "name": "test_plugin", + "kind": "isolated_venv", + "description": "Test plugin", + "version": "1.0.0", + "author": "Test", + "hooks": ["tool_pre_invoke"], + "config": { + "class_name": "test_plugin.TestPlugin", + "venv_path": str(tmp_path / ".venv"), + "requirements_file": "tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt", + "script_path": "tests/unit/cpex/fixtures/plugins/isolated" + } + } + + config = PluginConfig(**config_dict) + + # Create and initialize plugin + plugin = IsolatedVenvPlugin(config) + + with patch("cpex.framework.isolated.client.get_hook_registry") as mock_registry: + from cpex.framework.hooks.tools import ToolPreInvokeResult + mock_reg = MagicMock() + mock_reg.get_result_type.return_value = ToolPreInvokeResult + mock_registry.return_value = mock_reg + + await plugin.initialize() + + # Invoke hook + payload = ToolPreInvokePayload(name="test_tool", args={}) + global_ctx = GlobalContext(request_id="req-123") + from cpex.framework.models import PluginContext + context = PluginContext(global_context=global_ctx) + + result = await plugin.invoke_hook("tool_pre_invoke", payload, context) + + assert result is not None + assert result.continue_processing is True + + @pytest.mark.asyncio + async def test_isolated_plugin_error_handling(self, tmp_path): + """Test error handling in isolated plugin.""" + config_dict = { + "name": "test_plugin", + "kind": "isolated_venv", + "description": "Test plugin", + "version": "1.0.0", + "author": "Test", + "hooks": ["tool_pre_invoke"], + "config": { + "class_name": "test_plugin.TestPlugin", + "venv_path": str(tmp_path / ".venv"), + "requirements_file": str(tmp_path / "requirements.txt"), + "script_path": str(tmp_path / "plugins") + } + } + config = PluginConfig(**config_dict) + plugin = IsolatedVenvPlugin(config) + + # Try to invoke hook without initialization + from cpex.framework.errors import PluginError + payload = ToolPreInvokePayload(name="test_tool", args={}) + global_ctx = GlobalContext(request_id="req-123") + from cpex.framework.models import PluginContext + context = PluginContext(global_context=global_ctx) + + with pytest.raises(PluginError, match="Plugin comm not initialized"): + await plugin.invoke_hook("tool_pre_invoke", payload, context) + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.VenvProcessCommunicator") + @patch.object(IsolatedVenvPlugin, "create_venv") + async def test_isolated_plugin_with_multiple_hooks( + self, mock_create_venv, mock_comm_class, tmp_path + ): + """Test isolated plugin with multiple hook types.""" + mock_create_venv.return_value = None + mock_comm = MagicMock() + mock_comm.install_requirements = MagicMock() + mock_comm_class.return_value = mock_comm + + config_dict = { + "name": "test_plugin", + "kind": "isolated_venv", + "description": "Test plugin", + "version": "1.0.0", + "author": "Test", + "hooks": ["tool_pre_invoke", "tool_post_invoke", "prompt_pre_fetch", "prompt_post_fetch"], + "config": { + "class_name": "test_plugin.TestPlugin", + "venv_path": str(tmp_path / ".venv"), + "requirements_file": "tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt", + "script_path": "tests/unit/cpex/fixtures/plugins/isolated" + } + } + + config = PluginConfig(**config_dict) + plugin = IsolatedVenvPlugin(config) + + await plugin.initialize() + + # Test each hook type + hook_types = [ + ("tool_pre_invoke", "ToolPreInvokeResult"), + ("tool_post_invoke", "ToolPostInvokeResult"), + ("prompt_pre_fetch", "PromptPrehookResult"), + ("prompt_post_fetch", "PromptPosthookResult") + ] + + for hook_type, result_type_name in hook_types: + mock_comm.send_task.return_value = { + "continue_processing": True, + "modified_payload": None, + "violation": None, + "metadata": {} + } + + with patch("cpex.framework.isolated.client.get_hook_registry") as mock_registry: + # Import the appropriate result type + if "Tool" in result_type_name: + from cpex.framework.hooks.tools import ToolPreInvokeResult, ToolPostInvokeResult + result_class = ToolPreInvokeResult if "Pre" in result_type_name else ToolPostInvokeResult + else: + from cpex.framework.hooks.prompts import PromptPrehookResult, PromptPosthookResult + result_class = PromptPrehookResult if "Pre" in result_type_name else PromptPosthookResult + + mock_reg = MagicMock() + mock_reg.get_result_type.return_value = result_class + mock_registry.return_value = mock_reg + + # Create appropriate payload + if "tool" in hook_type: + from cpex.framework.hooks.tools import ToolPreInvokePayload, ToolPostInvokePayload + payload = ToolPreInvokePayload(name="test", args={}) if "pre" in hook_type else ToolPostInvokePayload(name="test", result={}) + else: + from cpex.framework.hooks.prompts import PromptPrehookPayload, PromptPosthookPayload + payload = PromptPrehookPayload(prompt_id="test", args={}) if "pre" in hook_type else PromptPosthookPayload(prompt_id="test", result={}) + + global_ctx = GlobalContext(request_id="req-123") + from cpex.framework.models import PluginContext + context = PluginContext(global_context=global_ctx) + + result = await plugin.invoke_hook(hook_type, payload, context) + assert result is not None + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.VenvProcessCommunicator") + @patch.object(IsolatedVenvPlugin, "create_venv") + async def test_isolated_plugin_context_propagation( + self, mock_create_venv, mock_comm_class, tmp_path + ): + """Test that context is properly propagated through isolated plugin.""" + mock_create_venv.return_value = None + mock_comm = MagicMock() + mock_comm.install_requirements = MagicMock() + + # Capture the task data sent + captured_task = None + def capture_task(script_path, task_data): + nonlocal captured_task + captured_task = task_data + return { + "continue_processing": True, + "modified_payload": None, + "violation": None, + "metadata": {} + } + + mock_comm.send_task = capture_task + mock_comm_class.return_value = mock_comm + + config_dict = { + "name": "test_plugin", + "kind": "isolated_venv", + "description": "Test plugin", + "version": "1.0.0", + "author": "Test", + "hooks": ["tool_pre_invoke"], + "config": { + "class_name": "test_plugin.TestPlugin", + "venv_path": str(tmp_path / ".venv"), + "requirements_file": "tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt", + "script_path": "tests/unit/cpex/fixtures/plugins/isolated" + } + } + config = PluginConfig(**config_dict) + plugin = IsolatedVenvPlugin(config) + + await plugin.initialize() + + with patch("cpex.framework.isolated.client.get_hook_registry") as mock_registry: + from cpex.framework.hooks.tools import ToolPreInvokeResult + mock_reg = MagicMock() + mock_reg.get_result_type.return_value = ToolPreInvokeResult + mock_registry.return_value = mock_reg + + # Create context with metadata + global_ctx = GlobalContext(request_id="req-123", user="alice", tenant_id="tenant-1") + from cpex.framework.models import PluginContext + context = PluginContext( + global_context=global_ctx, + state={"key": "value"}, + metadata={"custom": "data"} + ) + + payload = ToolPreInvokePayload(name="test_tool", args={"arg1": "value1"}) + + await plugin.invoke_hook("tool_pre_invoke", payload, context) + + # Verify context was properly serialized and sent + assert captured_task is not None + assert "context" in captured_task + assert captured_task["context"]["global_context"]["request_id"] == "req-123" + assert captured_task["context"]["global_context"]["user"] == "alice" + assert captured_task["context"]["state"]["key"] == "value" + assert captured_task["context"]["metadata"]["custom"] == "data" + + # Verify payload was serialized + assert "payload" in captured_task + assert captured_task["payload"]["name"] == "test_tool" + assert captured_task["payload"]["args"]["arg1"] == "value1" + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.VenvProcessCommunicator") + @patch.object(IsolatedVenvPlugin, "create_venv") + async def test_isolated_plugin_violation_handling( + self, mock_create_venv, mock_comm_class, tmp_path + ): + """Test handling of policy violations in isolated plugin.""" + mock_create_venv.return_value = None + mock_comm = MagicMock() + mock_comm.install_requirements = MagicMock() + mock_comm.send_task.return_value = { + "continue_processing": False, + "modified_payload": None, + "violation": {"reason": "Policy violation", "description":"severity high", "code": "PROHIBITED_CONTENT"}, + "metadata": {} + } + mock_comm_class.return_value = mock_comm + + config_dict = { + "name": "test_plugin", + "kind": "isolated_venv", + "description": "Test plugin", + "version": "1.0.0", + "author": "Test", + "hooks": ["tool_pre_invoke"], + "config": { + "class_name": "test_plugin.TestPlugin", + "venv_path": str(tmp_path / ".venv"), + "requirements_file": "tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt", + "script_path": "tests/unit/cpex/fixtures/plugins/isolated" + } + } + config = PluginConfig(**config_dict) + plugin = IsolatedVenvPlugin(config) + + await plugin.initialize() + + with patch("cpex.framework.isolated.client.get_hook_registry") as mock_registry: + from cpex.framework.hooks.tools import ToolPreInvokeResult + mock_reg = MagicMock() + mock_reg.get_result_type.return_value = ToolPreInvokeResult + mock_registry.return_value = mock_reg + + payload = ToolPreInvokePayload(name="dangerous_tool", args={}) + global_ctx = GlobalContext(request_id="req-123") + from cpex.framework.models import PluginContext + context = PluginContext(global_context=global_ctx) + + result = await plugin.invoke_hook("tool_pre_invoke", payload, context) + + assert result.continue_processing is False + assert result.violation is not None + +# Made with Bob diff --git a/tests/unit/cpex/framework/isolated/test_venv_comm.py b/tests/unit/cpex/framework/isolated/test_venv_comm.py new file mode 100644 index 00000000..b143d74c --- /dev/null +++ b/tests/unit/cpex/framework/isolated/test_venv_comm.py @@ -0,0 +1,245 @@ +# -*- coding: utf-8 -*- +"""Location: ./tests/unit/cpex/framework/isolated/test_venv_comm.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck + +Unit tests for VenvProcessCommunicator. +""" + +import json +import subprocess +import sys +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from cpex.framework.isolated.venv_comm import VenvProcessCommunicator + + +class TestVenvProcessCommunicator: + """Test suite for VenvProcessCommunicator class.""" + + @pytest.fixture + def mock_venv_path(self, tmp_path): + """Create a mock venv directory structure.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + + # Create appropriate bin/Scripts directory based on platform + if sys.platform == "win32": + scripts_dir = venv_path / "Scripts" + scripts_dir.mkdir() + python_exe = scripts_dir / "python.exe" + else: + bin_dir = venv_path / "bin" + bin_dir.mkdir() + python_exe = bin_dir / "python" + + # Create a dummy python executable + python_exe.touch() + python_exe.chmod(0o755) + + return venv_path + + @pytest.fixture + def communicator(self, mock_venv_path): + """Create a VenvProcessCommunicator instance with mock venv.""" + return VenvProcessCommunicator(str(mock_venv_path)) + + def test_init_valid_venv(self, mock_venv_path): + """Test initialization with valid venv path.""" + comm = VenvProcessCommunicator(str(mock_venv_path)) + assert comm.venv_path == mock_venv_path + assert comm.python_executable is not None + assert Path(comm.python_executable).exists() + + def test_init_invalid_venv(self, tmp_path): + """Test initialization with invalid venv path raises error.""" + invalid_path = tmp_path / "nonexistent" + with pytest.raises(FileNotFoundError, match="Python executable not found"): + VenvProcessCommunicator(str(invalid_path)) + + def test_get_python_executable_unix(self, tmp_path): + """Test getting Python executable path on Unix-like systems.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + bin_dir = venv_path / "bin" + bin_dir.mkdir() + python_exe = bin_dir / "python" + python_exe.touch() + + with patch("sys.platform", "linux"): + comm = VenvProcessCommunicator(str(venv_path)) + assert comm.python_executable == str(python_exe) + + def test_get_python_executable_windows(self, tmp_path): + """Test getting Python executable path on Windows.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + scripts_dir = venv_path / "Scripts" + scripts_dir.mkdir() + python_exe = scripts_dir / "python.exe" + python_exe.touch() + + with patch("sys.platform", "win32"): + comm = VenvProcessCommunicator(str(venv_path)) + assert comm.python_executable == str(python_exe) + + @patch("subprocess.check_call") + def test_install_requirements_success(self, mock_check_call, communicator, tmp_path): + """Test successful requirements installation.""" + requirements_file = tmp_path / "requirements.txt" + requirements_file.write_text("pytest>=7.0.0\n") + + mock_check_call.return_value = 0 + + communicator.install_requirements(str(requirements_file)) + + mock_check_call.assert_called_once_with([ + communicator.python_executable, + "-m", + "pip", + "install", + "-r", + str(requirements_file) + ]) + + @patch("subprocess.check_call") + def test_install_requirements_failure(self, mock_check_call, communicator, tmp_path): + """Test requirements installation failure.""" + requirements_file = tmp_path / "requirements.txt" + requirements_file.write_text("invalid-package-name-xyz\n") + + mock_check_call.return_value = 1 + + with pytest.raises(Exception, match="Failed to install requirements"): + communicator.install_requirements(str(requirements_file)) + + def test_install_requirements_nonexistent_file(self, communicator): + """Test install_requirements with nonexistent file does nothing.""" + # Should not raise an error if file doesn't exist + communicator.install_requirements("nonexistent_requirements.txt") + + @patch("subprocess.Popen") + def test_send_task_success(self, mock_popen, communicator): + """Test successful task sending and response.""" + task_data = {"task_type": "info", "data": "test"} + expected_response = {"status": "success", "result": "ok"} + + # Mock the process + mock_process = MagicMock() + mock_process.communicate.return_value = (json.dumps(expected_response), "") + mock_process.returncode = 0 + mock_popen.return_value = mock_process + + result = communicator.send_task("test_script.py", task_data) + + assert result == expected_response + mock_popen.assert_called_once() + mock_process.communicate.assert_called_once() + + @patch("subprocess.Popen") + def test_send_task_process_failure(self, mock_popen, communicator): + """Test task sending with process failure.""" + task_data = {"task_type": "test"} + + mock_process = MagicMock() + mock_process.communicate.return_value = ("", "Error occurred") + mock_process.returncode = 1 + mock_popen.return_value = mock_process + + with pytest.raises(RuntimeError, match="Child process failed"): + communicator.send_task("test_script.py", task_data) + + @patch("subprocess.Popen") + def test_send_task_invalid_json_response(self, mock_popen, communicator): + """Test task sending with invalid JSON response.""" + task_data = {"task_type": "test"} + + mock_process = MagicMock() + mock_process.communicate.return_value = ("invalid json", "") + mock_process.returncode = 0 + mock_popen.return_value = mock_process + + with pytest.raises(RuntimeError, match="Invalid JSON response"): + communicator.send_task("test_script.py", task_data) + + @patch("subprocess.Popen") + def test_send_task_timeout(self, mock_popen, communicator): + """Test task sending with timeout.""" + task_data = {"task_type": "test"} + + mock_process = MagicMock() + mock_process.communicate.side_effect = subprocess.TimeoutExpired("cmd", 30) + mock_popen.return_value = mock_process + + with pytest.raises(RuntimeError, match="Child process timed out"): + communicator.send_task("test_script.py", task_data) + + mock_process.kill.assert_called_once() + + @patch("subprocess.Popen") + def test_send_task_communication_error(self, mock_popen, communicator): + """Test task sending with communication error.""" + task_data = {"task_type": "test"} + + mock_popen.side_effect = OSError("Connection failed") + + with pytest.raises(RuntimeError, match="Communication error"): + communicator.send_task("test_script.py", task_data) + + @patch("subprocess.Popen") + def test_send_task_with_complex_data(self, mock_popen, communicator): + """Test sending task with complex nested data structures.""" + task_data = { + "task_type": "load_and_run_hook", + "config": {"nested": {"data": [1, 2, 3]}}, + "payload": {"args": {"key": "value"}}, + "context": {"state": {}, "metadata": {}} + } + expected_response = {"status": "success", "result": {"data": "processed"}} + + mock_process = MagicMock() + mock_process.communicate.return_value = (json.dumps(expected_response), "") + mock_process.returncode = 0 + mock_popen.return_value = mock_process + + result = communicator.send_task("worker.py", task_data) + + assert result == expected_response + # Verify the task was serialized properly + call_args = mock_popen.call_args + assert call_args is not None + + @patch("subprocess.Popen") + @patch("os.getcwd") + def test_send_task_maintains_cwd(self, mock_getcwd, mock_popen, communicator): + """Test that send_task maintains current working directory.""" + mock_getcwd.return_value = "/test/path" + task_data = {"task_type": "test"} + + mock_process = MagicMock() + mock_process.communicate.return_value = ('{"status": "ok"}', "") + mock_process.returncode = 0 + mock_popen.return_value = mock_process + + communicator.send_task("test_script.py", task_data) + + # Verify cwd was passed to Popen + call_kwargs = mock_popen.call_args[1] + assert call_kwargs["cwd"] == "/test/path" + + def test_python_executable_property(self, communicator): + """Test that python_executable property is accessible.""" + assert communicator.python_executable is not None + assert isinstance(communicator.python_executable, str) + assert Path(communicator.python_executable).exists() + + def test_venv_path_property(self, communicator, mock_venv_path): + """Test that venv_path property is accessible.""" + assert communicator.venv_path == mock_venv_path + assert isinstance(communicator.venv_path, Path) + +# Made with Bob diff --git a/tests/unit/cpex/framework/isolated/test_worker.py b/tests/unit/cpex/framework/isolated/test_worker.py new file mode 100644 index 00000000..178183f7 --- /dev/null +++ b/tests/unit/cpex/framework/isolated/test_worker.py @@ -0,0 +1,294 @@ +# -*- coding: utf-8 -*- +"""Location: ./tests/unit/cpex/framework/isolated/test_worker.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck + +Unit tests for worker.py functions. +""" + +import asyncio +import json +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest + +from cpex.framework.isolated.worker import get_environment_info, get_proper_config, process_task + + +class TestWorkerFunctions: + """Test suite for worker.py functions.""" + + def test_get_environment_info(self): + """Test getting environment information.""" + info = get_environment_info() + + assert "python_version" in info + assert "python_executable" in info + assert "platform" in info + assert "installed_packages" in info + + assert info["python_version"] == sys.version + assert info["python_executable"] == sys.executable + assert isinstance(info["installed_packages"], list) + assert len(info["installed_packages"]) <= 10 # Limited to first 10 + + @patch("cpex.framework.isolated.worker.ConfigLoader.load_config") + def test_get_proper_config_found(self, mock_load_config): + """Test getting proper config when plugin is found.""" + # Create mock plugin config + mock_plugin = MagicMock() + mock_plugin.name = "test_plugin" + mock_plugin.model_dump.return_value = {"name": "test_plugin", "kind": "isolated_venv", "config": {}} + + mock_config = MagicMock() + mock_config.plugins = [mock_plugin] + mock_load_config.return_value = mock_config + + result = get_proper_config("test_plugin", "plugins") + + assert result is not None + assert result.name == "test_plugin" + + @patch("cpex.framework.isolated.worker.ConfigLoader.load_config") + def test_get_proper_config_not_found(self, mock_load_config): + """Test getting proper config when plugin is not found.""" + mock_plugin = MagicMock() + mock_plugin.name = "other_plugin" + + mock_config = MagicMock() + mock_config.plugins = [mock_plugin] + mock_load_config.return_value = mock_config + + result = get_proper_config("test_plugin", "plugins") + + assert result is None + + @patch("cpex.framework.isolated.worker.ConfigLoader.load_config") + def test_get_proper_config_no_plugins(self, mock_load_config): + """Test getting proper config when no plugins exist.""" + mock_config = MagicMock() + mock_config.plugins = None + mock_load_config.return_value = mock_config + + result = get_proper_config("test_plugin", "plugins") + + assert result is None + + @pytest.mark.asyncio + async def test_process_task_info(self): + """Test processing info task.""" + task_data = {"task_type": "info"} + + result = await process_task(task_data) + + assert result["status"] == "success" + assert "environment" in result + assert "message" in result + assert result["message"] == "Environment info retrieved successfully" + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.worker.get_proper_config") + @patch("cpex.framework.isolated.worker.importlib.import_module") + @patch("cpex.framework.isolated.worker.PluginExecutor") + async def test_process_task_load_and_run_hook_success(self, mock_executor_class, mock_import, mock_get_config): + """Test processing load_and_run_hook task successfully.""" + # Setup mock config + mock_config = MagicMock() + mock_config.name = "test_plugin" + mock_get_config.return_value = mock_config + + # Setup mock plugin class + mock_plugin_instance = AsyncMock() + mock_plugin_instance.initialize = AsyncMock() + mock_plugin_instance.tool_pre_invoke = AsyncMock() + mock_plugin_instance.tool_post_invoke = AsyncMock() + mock_plugin_instance.tool_exception = AsyncMock() + mock_plugin_instance.tool_cleanup = AsyncMock() + mock_plugin_class = MagicMock(return_value=mock_plugin_instance) + + mock_module = MagicMock() + mock_module.TestPlugin = mock_plugin_class + mock_import.return_value = mock_module + + # Setup mock executor + mock_executor = MagicMock() + mock_result = MagicMock() + mock_result.continue_processing = True + mock_executor.execute_plugin = AsyncMock(return_value=mock_result) + mock_executor_class.return_value = mock_executor + + # Create task data + config_dict = {"name": "test_plugin", "kind": "isolated_venv", "config": {}} + task_data = { + "task_type": "load_and_run_hook", + "config": json.dumps(config_dict), + "script_path": "plugins", + "class_name": "test_plugin.TestPlugin", + "hook_type": "tool_pre_invoke", + "payload": {"name": "test_tool", "args": {}}, + "context": {"state": {}, "global_context": {"request_id": "req-123"}, "metadata": {}}, + } + + result = await process_task(task_data) + + assert result is not None + mock_plugin_instance.initialize.assert_called_once() + mock_executor.execute_plugin.assert_called_once() + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.worker.get_proper_config") + async def test_process_task_load_and_run_hook_no_config(self, mock_get_config): + """Test processing load_and_run_hook task when config not found.""" + mock_get_config.return_value = None + + config_dict = {"name": "test_plugin", "kind": "isolated_venv"} + task_data = { + "task_type": "load_and_run_hook", + "config": json.dumps(config_dict), + "script_path": "plugins", + "class_name": "test_plugin.TestPlugin", + "hook_type": "tool_pre_invoke", + "payload": {}, + "context": {"state": {}, "global_context": {}, "metadata": {}}, + } + + # Should raise an error or return None + with pytest.raises((AttributeError, TypeError)): + await process_task(task_data) + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.worker.get_proper_config") + @patch("cpex.framework.isolated.worker.importlib.import_module") + async def test_process_task_load_and_run_hook_import_error(self, mock_import, mock_get_config): + """Test processing load_and_run_hook task with import error.""" + mock_config = MagicMock() + mock_get_config.return_value = mock_config + + mock_import.side_effect = ImportError("Module not found") + + config_dict = {"name": "test_plugin", "kind": "isolated_venv"} + task_data = { + "task_type": "load_and_run_hook", + "config": json.dumps(config_dict), + "script_path": "plugins", + "class_name": "test_plugin.TestPlugin", + "hook_type": "tool_pre_invoke", + "payload": {}, + "context": {"state": {}, "global_context": {}, "metadata": {}}, + } + + with pytest.raises(ImportError): + await process_task(task_data) + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.worker.get_proper_config") + @patch("cpex.framework.isolated.worker.importlib.import_module") + @patch("cpex.framework.isolated.worker.PluginExecutor") + async def test_process_task_with_different_hook_types(self, mock_executor_class, mock_import, mock_get_config): + """Test processing tasks with different hook types.""" + # Setup mocks + mock_config = MagicMock() + mock_get_config.return_value = mock_config + + mock_plugin_instance = MagicMock() + mock_plugin_instance.initialize = AsyncMock() + mock_plugin_instance.tool_pre_invoke = AsyncMock() + mock_plugin_instance.tool_post_invoke = AsyncMock() + mock_plugin_instance.prompt_pre_fetch = AsyncMock() + mock_plugin_instance.prompt_post_fetch = AsyncMock() + mock_plugin_instance.tool_exception = AsyncMock() + mock_plugin_instance.tool_cleanup = AsyncMock() + mock_plugin_class = MagicMock(return_value=mock_plugin_instance) + + mock_module = MagicMock() + mock_module.TestPlugin = mock_plugin_class + mock_import.return_value = mock_module + + mock_executor = MagicMock() + mock_result = MagicMock() + mock_executor.execute_plugin = AsyncMock(return_value=mock_result) + mock_executor_class.return_value = mock_executor + + hook_types = ["tool_pre_invoke", "tool_post_invoke", "prompt_pre_fetch", "prompt_post_fetch"] + + for hook_type in hook_types: + config_dict = {"name": "test_plugin", "kind": "isolated_venv"} + task_data = { + "task_type": "load_and_run_hook", + "config": json.dumps(config_dict), + "script_path": "plugins", + "class_name": "test_plugin.TestPlugin", + "hook_type": hook_type, + "payload": {}, + "context": {"state": {}, "global_context": {"request_id": "req-123"}, "metadata": {}}, + } + + result = await process_task(task_data) + assert result is not None + + @pytest.mark.asyncio + async def test_process_task_unknown_task_type(self): + """Test processing task with unknown task type.""" + task_data = {"task_type": "unknown_type"} + + # Should return None or handle gracefully + result = await process_task(task_data) + assert result is None + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.worker.get_proper_config") + @patch("cpex.framework.isolated.worker.importlib.import_module") + @patch("cpex.framework.isolated.worker.PluginExecutor") + async def test_process_task_with_metadata(self, mock_executor_class, mock_import, mock_get_config): + """Test processing task with metadata in context.""" + mock_config = MagicMock() + mock_get_config.return_value = mock_config + + mock_plugin_instance = AsyncMock() + mock_plugin_instance.initialize = AsyncMock() + mock_plugin_instance.tool_pre_invoke = AsyncMock() + mock_plugin_instance.tool_post_invoke = AsyncMock() + mock_plugin_instance.prompt_pre_fetch = AsyncMock() + mock_plugin_instance.prompt_post_fetch = AsyncMock() + mock_plugin_instance.tool_exception = AsyncMock() + mock_plugin_instance.tool_cleanup = AsyncMock() + + mock_plugin_class = MagicMock(return_value=mock_plugin_instance) + + mock_module = MagicMock() + mock_module.TestPlugin = mock_plugin_class + mock_import.return_value = mock_module + + mock_executor = MagicMock() + mock_result = MagicMock() + mock_executor.execute_plugin = AsyncMock(return_value=mock_result) + mock_executor_class.return_value = mock_executor + + config_dict = {"name": "test_plugin", "kind": "isolated_venv"} + task_data = { + "task_type": "load_and_run_hook", + "config": json.dumps(config_dict), + "script_path": "plugins", + "class_name": "test_plugin.TestPlugin", + "hook_type": "tool_pre_invoke", + "payload": {"name": "test_tool"}, + "context": { + "state": {"key": "value"}, + "global_context": {"request_id": "req-123", "user": "alice"}, + "metadata": {"custom": "data"}, + }, + } + + result = await process_task(task_data) + + assert result is not None + # Verify executor was called with proper context + call_args = mock_executor.execute_plugin.call_args + assert call_args is not None + + + +# Made with Bob From f7ff27e9541db7eaedeb0444ed6b3c48fd5ca9f8 Mon Sep 17 00:00:00 2001 From: habeck Date: Mon, 9 Mar 2026 18:08:05 -0400 Subject: [PATCH 14/60] enh: add venv cache support and associated unit tests. Signed-off-by: habeck --- cpex/framework/isolated/client.py | 164 ++++++++++- .../cpex/framework/isolated/test_client.py | 278 +++++++++++++++++- 2 files changed, 419 insertions(+), 23 deletions(-) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index da24dac8..9dc5b5e2 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -8,13 +8,16 @@ Module that contains plugin client code to serve venv isolated plugins. """ +import hashlib +import json import logging import os from pathlib import Path +import shutil import sys import venv -from typing_extensions import Any +from typing_extensions import Any, Optional from cpex.framework.base import Plugin from cpex.framework.constants import CONTEXT, HOOK_TYPE, PAYLOAD, PLUGIN_NAME @@ -37,12 +40,133 @@ def __init__(self, config: PluginConfig) -> None: self.implementation = "Python" self.comm = None self.script_path: str = config.config["script_path"] + self.cache_dir = Path.home() / ".cpex" / "venv_cache" + self.cache_dir.mkdir(parents=True, exist_ok=True) - async def create_venv(self, venv_path: str = ".venv") -> None: - """Create a new venv environment.""" + def _compute_requirements_hash(self, requirements_file: str) -> str: + """Compute SHA256 hash of requirements file content. + + Args: + requirements_file: Path to the requirements file + + Returns: + Hexadecimal hash string + """ + hasher = hashlib.sha256() + req_path = Path(requirements_file) + + if req_path.exists(): + with open(req_path, "rb") as f: + hasher.update(f.read()) + else: + # If no requirements file, use empty hash + hasher.update(b"") + + return hasher.hexdigest() + + def _get_cache_metadata_path(self, venv_path: str) -> Path: + """Get the path to the cache metadata file. + + Args: + venv_path: Path to the virtual environment + + Returns: + Path to the metadata file + """ + venv_name = Path(venv_path).name + return self.cache_dir / f"{venv_name}_metadata.json" + + def _is_venv_cache_valid(self, venv_path: str, requirements_file: str) -> bool: + """Check if cached venv is valid by comparing requirements hash. + + Args: + venv_path: Path to the virtual environment + requirements_file: Path to the requirements file + + Returns: + True if cache is valid, False otherwise + """ + venv_path_obj = Path(venv_path) + metadata_path = self._get_cache_metadata_path(venv_path) + + # Check if venv directory exists + if not venv_path_obj.exists(): + logger.debug(f"Venv path does not exist: {venv_path}") + return False + + # Check if metadata file exists + if not metadata_path.exists(): + logger.debug(f"Metadata file does not exist: {metadata_path}") + return False + + try: + # Load metadata + with open(metadata_path, "r") as f: + metadata = json.load(f) + + # Compute current requirements hash + current_hash = self._compute_requirements_hash(requirements_file) + + # Compare hashes + cached_hash = metadata.get("requirements_hash") + if cached_hash != current_hash: + logger.info(f"Requirements changed. Cached hash: {cached_hash}, Current hash: {current_hash}") + return False + + logger.info(f"Valid venv cache found for {venv_path}") + return True + + except (json.JSONDecodeError, KeyError) as e: + logger.warning(f"Error reading cache metadata: {e}") + return False + + def _save_cache_metadata(self, venv_path: str, requirements_file: str) -> None: + """Save cache metadata for the venv. + + Args: + venv_path: Path to the virtual environment + requirements_file: Path to the requirements file + """ + metadata_path = self._get_cache_metadata_path(venv_path) + requirements_hash = self._compute_requirements_hash(requirements_file) + + metadata = { + "venv_path": str(Path(venv_path).resolve()), + "requirements_file": str(Path(requirements_file).resolve()) if Path(requirements_file).exists() else None, + "requirements_hash": requirements_hash, + "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + } + + with open(metadata_path, "w") as f: + json.dump(metadata, f, indent=2) + + logger.info(f"Saved cache metadata to {metadata_path}") + + async def create_venv(self, venv_path: str = ".venv", requirements_file: Optional[str] = None, use_cache: bool = True) -> None: + """Create a new venv environment with caching support. + + Args: + venv_path: Path where the virtual environment should be created + requirements_file: Path to requirements file for cache validation + use_cache: Whether to use cached venv if available + """ + venv_path_obj = Path(venv_path) + + # Check if we can use cached venv + if use_cache and requirements_file and self._is_venv_cache_valid(venv_path, requirements_file): + logger.info(f"Using cached virtual environment at: {venv_path_obj.resolve()}") + print(f"✓ Using cached virtual environment at: {venv_path_obj.resolve()}") + return + + # If cache is invalid or not using cache, remove existing venv + if venv_path_obj.exists(): + logger.info(f"Removing existing venv at {venv_path}") + shutil.rmtree(venv_path_obj) + # Check Python version python_version = sys.version_info print(f"Current Python version: {python_version.major}.{python_version.minor}.{python_version.micro}") + # Create the EnvBuilder with common options builder = venv.EnvBuilder( system_site_packages=True, # Don't include system site-packages @@ -52,14 +176,20 @@ async def create_venv(self, venv_path: str = ".venv") -> None: with_pip=True, # Install pip in the venv prompt=None, # Use default prompt (directory name) ) + # Create the virtual environment - print(f"\nCreating virtual environment at: {os.path.abspath(venv_path)}") + print(f"\nCreating virtual environment at: {venv_path_obj.resolve()}") try: builder.create(venv_path) print("✓ Virtual environment created successfully!") print("\nTo activate the virtual environment:") print(f" source {venv_path}/bin/activate # On Unix/macOS") print(f" {venv_path}\\Scripts\\activate # On Windows") + + # Save cache metadata if requirements file is provided + if requirements_file: + self._save_cache_metadata(venv_path, requirements_file) + except Exception as e: print(f"✗ Error creating virtual environment: {e}") raise e @@ -67,15 +197,33 @@ async def create_venv(self, venv_path: str = ".venv") -> None: # Called by plugins/framework/loader/plugin.py load_and_instantiate_plugin() # The plugins/framework/manager.py class (PluginManager) loads and registers the plugin async def initialize(self) -> None: - """Initialize the plugin's venv environment.""" + """Initialize the plugin's venv environment with caching support.""" # ensure the config is validated path = Path(self.config.config.get("script_path")).resolve() if not os.path.exists(path): raise FileNotFoundError(f"script_path not found: {path}") - self.venv = await self.create_venv(self.config.config["venv_path"]) - self.comm = VenvProcessCommunicator(self.config.config["venv_path"]) - self.comm.install_requirements(self.config.config["requirements_file"]) + venv_path = self.config.config["venv_path"] + requirements_file = self.config.config["requirements_file"] + + # Create venv with caching support + self.venv = await self.create_venv( + venv_path=venv_path, + requirements_file=requirements_file, + use_cache=True + ) + + self.comm = VenvProcessCommunicator(venv_path) + + # Only install requirements if venv was newly created or cache was invalid + # Check if we need to install requirements + if not self._is_venv_cache_valid(venv_path, requirements_file): + logger.info("Installing requirements in new venv") + self.comm.install_requirements(requirements_file) + # Save metadata after successful installation + self._save_cache_metadata(venv_path, requirements_file) + else: + logger.info("Using cached venv, skipping requirements installation") async def invoke_hook(self, hook_type: str, payload: PluginPayload, context: PluginContext) -> PluginResult: """Invoke a plugin in the context of the active venv (self.comm)""" diff --git a/tests/unit/cpex/framework/isolated/test_client.py b/tests/unit/cpex/framework/isolated/test_client.py index e7863179..61ffc8c1 100644 --- a/tests/unit/cpex/framework/isolated/test_client.py +++ b/tests/unit/cpex/framework/isolated/test_client.py @@ -8,9 +8,10 @@ """ import asyncio +import json import sys from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch, mock_open import pytest @@ -31,20 +32,6 @@ def mock_config(self, tmp_path): script_path = "tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt" requirements_file = tmp_path / "requirements.txt" - # config_dict = { - # "name": "test_isolated_plugin", - # "kind": "isolated_venv", - # "description": "Test isolated plugin", - # "version": "1.0.0", - # "author": "Test Author", - # "hooks": ["tool_pre_invoke", "tool_post_invoke"], - # "config": { - # "venv_path": str(venv_path), - # "script_path": str(script_path), - # "requirements_file": str(requirements_file), - # "class_name": "test_plugin.TestPlugin", - # }, - # } config_dict = { "name": "test_plugin", "kind": "isolated_venv", @@ -367,5 +354,266 @@ def test_get_safe_config(self, plugin): config_dict = json.loads(safe_config) assert "name" in config_dict + def test_cache_dir_creation(self, plugin): + """Test that cache directory is created on plugin initialization.""" + assert plugin.cache_dir.exists() + assert plugin.cache_dir.is_dir() + assert plugin.cache_dir.name == "venv_cache" + + def test_compute_requirements_hash_with_file(self, plugin, tmp_path): + """Test computing hash of existing requirements file.""" + req_file = tmp_path / "requirements.txt" + req_file.write_text("pytest==7.0.0\nrequests==2.28.0\n") + + hash1 = plugin._compute_requirements_hash(str(req_file)) + assert isinstance(hash1, str) + assert len(hash1) == 64 # SHA256 produces 64 hex characters + + # Same content should produce same hash + hash2 = plugin._compute_requirements_hash(str(req_file)) + assert hash1 == hash2 + + def test_compute_requirements_hash_different_content(self, plugin, tmp_path): + """Test that different content produces different hashes.""" + req_file1 = tmp_path / "requirements1.txt" + req_file1.write_text("pytest==7.0.0\n") + + req_file2 = tmp_path / "requirements2.txt" + req_file2.write_text("pytest==8.0.0\n") + + hash1 = plugin._compute_requirements_hash(str(req_file1)) + hash2 = plugin._compute_requirements_hash(str(req_file2)) + + assert hash1 != hash2 + + def test_compute_requirements_hash_nonexistent_file(self, plugin, tmp_path): + """Test computing hash of non-existent file.""" + nonexistent = tmp_path / "nonexistent.txt" + hash_result = plugin._compute_requirements_hash(str(nonexistent)) + + # Should return hash of empty content + assert isinstance(hash_result, str) + assert len(hash_result) == 64 + + def test_get_cache_metadata_path(self, plugin, tmp_path): + """Test getting cache metadata path.""" + venv_path = tmp_path / ".venv" + metadata_path = plugin._get_cache_metadata_path(str(venv_path)) + + assert metadata_path.parent == plugin.cache_dir + assert metadata_path.name == ".venv_metadata.json" + assert isinstance(metadata_path, Path) + + def test_is_venv_cache_valid_no_venv(self, plugin, tmp_path): + """Test cache validation when venv doesn't exist.""" + venv_path = tmp_path / ".venv" + req_file = tmp_path / "requirements.txt" + req_file.write_text("pytest==7.0.0\n") + + result = plugin._is_venv_cache_valid(str(venv_path), str(req_file)) + assert result is False + + def test_is_venv_cache_valid_no_metadata(self, plugin, tmp_path): + """Test cache validation when metadata file doesn't exist.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + req_file = tmp_path / "requirements.txt" + req_file.write_text("pytest==7.0.0\n") + + result = plugin._is_venv_cache_valid(str(venv_path), str(req_file)) + assert result is False + + def test_is_venv_cache_valid_hash_mismatch(self, plugin, tmp_path): + """Test cache validation when requirements hash doesn't match.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + req_file = tmp_path / "requirements.txt" + req_file.write_text("pytest==7.0.0\n") + + # Create metadata with different hash + metadata_path = plugin._get_cache_metadata_path(str(venv_path)) + metadata = { + "venv_path": str(venv_path), + "requirements_file": str(req_file), + "requirements_hash": "different_hash", + "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + } + metadata_path.write_text(json.dumps(metadata)) + + result = plugin._is_venv_cache_valid(str(venv_path), str(req_file)) + assert result is False + + def test_is_venv_cache_valid_success(self, plugin, tmp_path): + """Test successful cache validation.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + req_file = tmp_path / "requirements.txt" + req_file.write_text("pytest==7.0.0\n") + + # Create metadata with correct hash + req_hash = plugin._compute_requirements_hash(str(req_file)) + metadata_path = plugin._get_cache_metadata_path(str(venv_path)) + metadata = { + "venv_path": str(venv_path), + "requirements_file": str(req_file), + "requirements_hash": req_hash, + "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + } + metadata_path.write_text(json.dumps(metadata)) + + result = plugin._is_venv_cache_valid(str(venv_path), str(req_file)) + assert result is True + + def test_is_venv_cache_valid_invalid_json(self, plugin, tmp_path): + """Test cache validation with invalid JSON metadata.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + req_file = tmp_path / "requirements.txt" + req_file.write_text("pytest==7.0.0\n") + + # Create invalid JSON metadata + metadata_path = plugin._get_cache_metadata_path(str(venv_path)) + metadata_path.write_text("invalid json {") + + result = plugin._is_venv_cache_valid(str(venv_path), str(req_file)) + assert result is False + + def test_save_cache_metadata(self, plugin, tmp_path): + """Test saving cache metadata.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + req_file = tmp_path / "requirements.txt" + req_file.write_text("pytest==7.0.0\n") + + plugin._save_cache_metadata(str(venv_path), str(req_file)) + + metadata_path = plugin._get_cache_metadata_path(str(venv_path)) + assert metadata_path.exists() + + with open(metadata_path) as f: + metadata = json.load(f) + + assert "venv_path" in metadata + assert "requirements_file" in metadata + assert "requirements_hash" in metadata + assert "python_version" in metadata + assert metadata["requirements_hash"] == plugin._compute_requirements_hash(str(req_file)) + + def test_save_cache_metadata_nonexistent_requirements(self, plugin, tmp_path): + """Test saving cache metadata with non-existent requirements file.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + req_file = tmp_path / "nonexistent.txt" + + plugin._save_cache_metadata(str(venv_path), str(req_file)) + + metadata_path = plugin._get_cache_metadata_path(str(venv_path)) + assert metadata_path.exists() + + with open(metadata_path) as f: + metadata = json.load(f) + + assert metadata["requirements_file"] is None + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.venv.EnvBuilder") + @patch("cpex.framework.isolated.client.shutil.rmtree") + async def test_create_venv_with_cache_valid(self, mock_rmtree, mock_builder_class, plugin, tmp_path): + """Test create_venv uses cache when valid.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + req_file = tmp_path / "requirements.txt" + req_file.write_text("pytest==7.0.0\n") + + # Setup valid cache + plugin._save_cache_metadata(str(venv_path), str(req_file)) + + await plugin.create_venv(str(venv_path), str(req_file), use_cache=True) + + # Should not create new venv or remove existing + mock_builder_class.assert_not_called() + mock_rmtree.assert_not_called() + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.venv.EnvBuilder") + @patch("cpex.framework.isolated.client.shutil.rmtree") + async def test_create_venv_with_cache_invalid(self, mock_rmtree, mock_builder_class, plugin, tmp_path): + """Test create_venv recreates when cache invalid.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + req_file = tmp_path / "requirements.txt" + req_file.write_text("pytest==7.0.0\n") + + # Setup invalid cache (wrong hash) + metadata_path = plugin._get_cache_metadata_path(str(venv_path)) + metadata = { + "venv_path": str(venv_path), + "requirements_file": str(req_file), + "requirements_hash": "wrong_hash", + "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + } + metadata_path.write_text(json.dumps(metadata)) + + mock_builder = MagicMock() + mock_builder_class.return_value = mock_builder + + await plugin.create_venv(str(venv_path), str(req_file), use_cache=True) + + # Should remove old venv and create new one + mock_rmtree.assert_called_once_with(venv_path) + mock_builder_class.assert_called_once() + mock_builder.create.assert_called_once() + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.venv.EnvBuilder") + async def test_create_venv_without_cache(self, mock_builder_class, plugin, tmp_path): + """Test create_venv without using cache.""" + venv_path = tmp_path / ".venv" + req_file = tmp_path / "requirements.txt" + req_file.write_text("pytest==7.0.0\n") + + mock_builder = MagicMock() + mock_builder_class.return_value = mock_builder + + await plugin.create_venv(str(venv_path), str(req_file), use_cache=False) + + # Should create new venv + mock_builder_class.assert_called_once() + mock_builder.create.assert_called_once() + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.VenvProcessCommunicator") + @patch.object(IsolatedVenvPlugin, "create_venv") + @patch.object(IsolatedVenvPlugin, "_is_venv_cache_valid") + async def test_initialize_with_valid_cache(self, mock_cache_valid, mock_create_venv, mock_comm_class, plugin): + """Test initialize with valid cache skips requirements installation.""" + mock_cache_valid.return_value = True + mock_create_venv.return_value = None + mock_comm = MagicMock() + mock_comm_class.return_value = mock_comm + + await plugin.initialize() + + # Should not install requirements when cache is valid + mock_comm.install_requirements.assert_not_called() + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.VenvProcessCommunicator") + @patch.object(IsolatedVenvPlugin, "create_venv") + @patch.object(IsolatedVenvPlugin, "_is_venv_cache_valid") + @patch.object(IsolatedVenvPlugin, "_save_cache_metadata") + async def test_initialize_with_invalid_cache(self, mock_save_metadata, mock_cache_valid, mock_create_venv, mock_comm_class, plugin): + """Test initialize with invalid cache installs requirements.""" + mock_cache_valid.return_value = False + mock_create_venv.return_value = None + mock_comm = MagicMock() + mock_comm_class.return_value = mock_comm + + await plugin.initialize() + + # Should install requirements when cache is invalid + mock_comm.install_requirements.assert_called_once() + mock_save_metadata.assert_called_once() + # Made with Bob From 6aaa9d1f5a5bbe017c96ea7fcb86d97d7e012870 Mon Sep 17 00:00:00 2001 From: habeck Date: Tue, 10 Mar 2026 09:31:16 -0400 Subject: [PATCH 15/60] chore: lint fix Signed-off-by: habeck --- cpex/framework/isolated/client.py | 72 +++++++++++++++---------------- 1 file changed, 35 insertions(+), 37 deletions(-) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index 9dc5b5e2..125859f3 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -12,10 +12,10 @@ import json import logging import os -from pathlib import Path import shutil import sys import venv +from pathlib import Path from typing_extensions import Any, Optional @@ -45,31 +45,31 @@ def __init__(self, config: PluginConfig) -> None: def _compute_requirements_hash(self, requirements_file: str) -> str: """Compute SHA256 hash of requirements file content. - + Args: requirements_file: Path to the requirements file - + Returns: Hexadecimal hash string """ hasher = hashlib.sha256() req_path = Path(requirements_file) - + if req_path.exists(): with open(req_path, "rb") as f: hasher.update(f.read()) else: # If no requirements file, use empty hash hasher.update(b"") - + return hasher.hexdigest() def _get_cache_metadata_path(self, venv_path: str) -> Path: """Get the path to the cache metadata file. - + Args: venv_path: Path to the virtual environment - + Returns: Path to the metadata file """ @@ -78,95 +78,97 @@ def _get_cache_metadata_path(self, venv_path: str) -> Path: def _is_venv_cache_valid(self, venv_path: str, requirements_file: str) -> bool: """Check if cached venv is valid by comparing requirements hash. - + Args: venv_path: Path to the virtual environment requirements_file: Path to the requirements file - + Returns: True if cache is valid, False otherwise """ venv_path_obj = Path(venv_path) metadata_path = self._get_cache_metadata_path(venv_path) - + # Check if venv directory exists if not venv_path_obj.exists(): logger.debug(f"Venv path does not exist: {venv_path}") return False - + # Check if metadata file exists if not metadata_path.exists(): logger.debug(f"Metadata file does not exist: {metadata_path}") return False - + try: # Load metadata with open(metadata_path, "r") as f: metadata = json.load(f) - + # Compute current requirements hash current_hash = self._compute_requirements_hash(requirements_file) - + # Compare hashes cached_hash = metadata.get("requirements_hash") if cached_hash != current_hash: logger.info(f"Requirements changed. Cached hash: {cached_hash}, Current hash: {current_hash}") return False - + logger.info(f"Valid venv cache found for {venv_path}") return True - + except (json.JSONDecodeError, KeyError) as e: logger.warning(f"Error reading cache metadata: {e}") return False def _save_cache_metadata(self, venv_path: str, requirements_file: str) -> None: """Save cache metadata for the venv. - + Args: venv_path: Path to the virtual environment requirements_file: Path to the requirements file """ metadata_path = self._get_cache_metadata_path(venv_path) requirements_hash = self._compute_requirements_hash(requirements_file) - + metadata = { "venv_path": str(Path(venv_path).resolve()), "requirements_file": str(Path(requirements_file).resolve()) if Path(requirements_file).exists() else None, "requirements_hash": requirements_hash, "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", } - + with open(metadata_path, "w") as f: json.dump(metadata, f, indent=2) - + logger.info(f"Saved cache metadata to {metadata_path}") - async def create_venv(self, venv_path: str = ".venv", requirements_file: Optional[str] = None, use_cache: bool = True) -> None: + async def create_venv( + self, venv_path: str = ".venv", requirements_file: Optional[str] = None, use_cache: bool = True + ) -> None: """Create a new venv environment with caching support. - + Args: venv_path: Path where the virtual environment should be created requirements_file: Path to requirements file for cache validation use_cache: Whether to use cached venv if available """ venv_path_obj = Path(venv_path) - + # Check if we can use cached venv if use_cache and requirements_file and self._is_venv_cache_valid(venv_path, requirements_file): logger.info(f"Using cached virtual environment at: {venv_path_obj.resolve()}") print(f"✓ Using cached virtual environment at: {venv_path_obj.resolve()}") return - + # If cache is invalid or not using cache, remove existing venv if venv_path_obj.exists(): logger.info(f"Removing existing venv at {venv_path}") shutil.rmtree(venv_path_obj) - + # Check Python version python_version = sys.version_info print(f"Current Python version: {python_version.major}.{python_version.minor}.{python_version.micro}") - + # Create the EnvBuilder with common options builder = venv.EnvBuilder( system_site_packages=True, # Don't include system site-packages @@ -176,7 +178,7 @@ async def create_venv(self, venv_path: str = ".venv", requirements_file: Optiona with_pip=True, # Install pip in the venv prompt=None, # Use default prompt (directory name) ) - + # Create the virtual environment print(f"\nCreating virtual environment at: {venv_path_obj.resolve()}") try: @@ -185,11 +187,11 @@ async def create_venv(self, venv_path: str = ".venv", requirements_file: Optiona print("\nTo activate the virtual environment:") print(f" source {venv_path}/bin/activate # On Unix/macOS") print(f" {venv_path}\\Scripts\\activate # On Windows") - + # Save cache metadata if requirements file is provided if requirements_file: self._save_cache_metadata(venv_path, requirements_file) - + except Exception as e: print(f"✗ Error creating virtual environment: {e}") raise e @@ -205,16 +207,12 @@ async def initialize(self) -> None: venv_path = self.config.config["venv_path"] requirements_file = self.config.config["requirements_file"] - + # Create venv with caching support - self.venv = await self.create_venv( - venv_path=venv_path, - requirements_file=requirements_file, - use_cache=True - ) - + self.venv = await self.create_venv(venv_path=venv_path, requirements_file=requirements_file, use_cache=True) + self.comm = VenvProcessCommunicator(venv_path) - + # Only install requirements if venv was newly created or cache was invalid # Check if we need to install requirements if not self._is_venv_cache_valid(venv_path, requirements_file): From 07704332656f48eba93892d08fb8cd0933d6889c Mon Sep 17 00:00:00 2001 From: habeck Date: Tue, 10 Mar 2026 09:33:41 -0400 Subject: [PATCH 16/60] chore: lint fix Signed-off-by: habeck --- cpex/framework/isolated/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index 125859f3..d202c0e5 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -101,7 +101,7 @@ def _is_venv_cache_valid(self, venv_path: str, requirements_file: str) -> bool: try: # Load metadata - with open(metadata_path, "r") as f: + with open(metadata_path, "r", encoding="utf8") as f: metadata = json.load(f) # Compute current requirements hash @@ -137,7 +137,7 @@ def _save_cache_metadata(self, venv_path: str, requirements_file: str) -> None: "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", } - with open(metadata_path, "w") as f: + with open(metadata_path, "w", encoding="utf8") as f: json.dump(metadata, f, indent=2) logger.info(f"Saved cache metadata to {metadata_path}") From 9bb405e40cde631d5975cd225151b3dcf5afd74f Mon Sep 17 00:00:00 2001 From: habeck Date: Tue, 10 Mar 2026 10:02:15 -0400 Subject: [PATCH 17/60] chore: add support for resource_pre_fetch, resource_post_fetch, agent_pre_invoke, and agent_post_invoke Signed-off-by: habeck --- cpex/framework/isolated/client.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index d202c0e5..51853e25 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -22,8 +22,10 @@ from cpex.framework.base import Plugin from cpex.framework.constants import CONTEXT, HOOK_TYPE, PAYLOAD, PLUGIN_NAME from cpex.framework.errors import PluginError, convert_exception_to_error +from cpex.framework.hooks.agents import AgentPostInvokeResult, AgentPreInvokeResult from cpex.framework.hooks.prompts import PromptPosthookResult, PromptPrehookResult from cpex.framework.hooks.registry import get_hook_registry +from cpex.framework.hooks.resources import ResourcePostFetchResult, ResourcePreFetchResult from cpex.framework.hooks.tools import ToolPostInvokeResult, ToolPreInvokeResult from cpex.framework.isolated.venv_comm import VenvProcessCommunicator from cpex.framework.models import PluginConfig, PluginContext, PluginErrorModel, PluginPayload, PluginResult @@ -287,6 +289,34 @@ async def invoke_hook(self, hook_type: str, payload: PluginPayload, context: Plu violation=result.get("violation"), metadata=result.get("metadata"), ) + if hook_type == "resource_pre_fetch": + result = ResourcePreFetchResult( + continue_processing=result.get("continue_processing"), + modified_payload=result.get("modified_payload"), + violation=result.get("violation"), + metadata=result.get("metadata"), + ) + if hook_type == "resource_post_fetch": + result = ResourcePostFetchResult( + continue_processing=result.get("continue_processing"), + modified_payload=result.get("modified_payload"), + violation=result.get("violation"), + metadata=result.get("metadata"), + ) + if hook_type == "agent_pre_invoke": + result = AgentPreInvokeResult( + continue_processing=result.get("continue_processing"), + modified_payload=result.get("modified_payload"), + violation=result.get("violation"), + metadata=result.get("metadata"), + ) + if hook_type == "agent_post_invoke": + result = AgentPostInvokeResult( + continue_processing=result.get("continue_processing"), + modified_payload=result.get("modified_payload"), + violation=result.get("violation"), + metadata=result.get("metadata"), + ) return result except PluginError as pe: logger.exception(pe) From 1551ab603a03e080468fe7860c884e6b685ea47a Mon Sep 17 00:00:00 2001 From: habeck Date: Tue, 10 Mar 2026 10:10:00 -0400 Subject: [PATCH 18/60] chore: add missing test fixtures Signed-off-by: habeck --- .../plugins/isolated/test_plugin/plugin.py | 144 ++++++++++++++++++ .../isolated/test_plugin/requirements.txt | 1 + 2 files changed, 145 insertions(+) create mode 100644 tests/unit/cpex/fixtures/plugins/isolated/test_plugin/plugin.py create mode 100644 tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt diff --git a/tests/unit/cpex/fixtures/plugins/isolated/test_plugin/plugin.py b/tests/unit/cpex/fixtures/plugins/isolated/test_plugin/plugin.py new file mode 100644 index 00000000..c1f341cb --- /dev/null +++ b/tests/unit/cpex/fixtures/plugins/isolated/test_plugin/plugin.py @@ -0,0 +1,144 @@ +"""A filter plugin. + +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: habeck + +This module loads configurations for plugins. +""" + +import logging + +# First-Party +from cpex.framework import ( + Plugin, + PluginConfig, + PluginContext, + PromptPosthookPayload, + PromptPosthookResult, + PromptPrehookPayload, + PromptPrehookResult, + ToolPostInvokePayload, + ToolPostInvokeResult, + ToolPreInvokePayload, + ToolPreInvokeResult, +) +from cpex.framework.hooks.agents import ( + AgentPostInvokePayload, + AgentPostInvokeResult, + AgentPreInvokePayload, + AgentPreInvokeResult, +) +from cpex.framework.hooks.resources import ( + ResourcePostFetchPayload, + ResourcePostFetchResult, + ResourcePreFetchPayload, + ResourcePreFetchResult, +) + +logger = logging.getLogger(__name__) + + +class TestPlugin(Plugin): + """A filter plugin.""" + + def __init__(self, config: PluginConfig): + """Entry init block for plugin. + + Args: + logger: logger that the skill can make use of + config: the skill configuration + """ + super().__init__(config) + + async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: + """The plugin hook run before a prompt is retrieved and rendered. + + Args: + payload: The prompt payload to be analyzed. + context: contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the prompt can proceed. + """ + logger.info("TestPlugin: prompt_pre_fetch") + return PromptPrehookResult(continue_processing=True) + + async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: + """Plugin hook run after a prompt is rendered. + + Args: + payload: The prompt payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the prompt can proceed. + """ + logger.info("TestPlugin: prompt_post_fetch") + return PromptPosthookResult(continue_processing=True) + + async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: + """Plugin hook run before a tool is invoked. + + Args: + payload: The tool payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the tool can proceed. + """ + logger.info("TestPlugin: tool_pre_invoke") + return ToolPreInvokeResult(continue_processing=True) + + async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: + """Plugin hook run after a tool is invoked. + + Args: + payload: The tool result payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the tool result should proceed. + """ + logger.info("TestPlugin: tool_post_invoke") + return ToolPostInvokeResult(continue_processing=True) + + async def resource_pre_fetch( + self, payload: ResourcePreFetchPayload, context: PluginContext + ) -> ResourcePreFetchResult: + """Plugin hook run before a resource is fetched. + Args: + payload: The resource payload to be analyzed. + context: Contextual information about the hook call. + """ + logger.info("TestPlugin: resource_pre_fetch") + return ResourcePreFetchResult(continue_processing=True) + + async def resource_post_fetch( + self, payload: ResourcePostFetchPayload, context: PluginContext + ) -> ResourcePostFetchResult: + """Plugin hook run after a resource is fetched. + Args: + payload: The resource payload to be analyzed. + context: Contextual information about the hook call. + """ + logger.info("TestPlugin: resource_post_fetch") + return ResourcePostFetchResult(continue_processing=True) + + async def agent_pre_invoke(self, payload: AgentPreInvokePayload, context: PluginContext) -> AgentPreInvokeResult: + """Plugin hook run before an agent is invoked. + Args: + payload: The agent payload to be analyzed. + context: Contextual information about the hook call. + """ + logger.info("TestPlugin: agent_pre_invoke") + return AgentPreInvokeResult(continue_processing=True) + + async def agent_post_invoke(self, payload: AgentPostInvokePayload, context: PluginContext) -> AgentPostInvokeResult: + """Plugin hook run after an agent is invoked. + Args: + payload: The agent payload to be analyzed. + context: Contextual information about the hook call. + """ + logger.info("TestPlugin: agent_post_invoke") + return AgentPostInvokeResult(continue_processing=True) diff --git a/tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt b/tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt new file mode 100644 index 00000000..e83eec53 --- /dev/null +++ b/tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt @@ -0,0 +1 @@ +cpex>=0.1.0.dev4 \ No newline at end of file From ad4aa60819b7263414098fa126568f3e654bd10f Mon Sep 17 00:00:00 2001 From: habeck Date: Tue, 10 Mar 2026 11:43:37 -0400 Subject: [PATCH 19/60] chore: refactor invoke_hook function in client.py and update associated tests Signed-off-by: habeck --- cpex/framework/isolated/client.py | 78 +++---------------- .../cpex/framework/isolated/test_client.py | 42 +++++++++- .../framework/isolated/test_integration.py | 24 ++---- 3 files changed, 58 insertions(+), 86 deletions(-) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index 51853e25..cca06a95 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -22,11 +22,7 @@ from cpex.framework.base import Plugin from cpex.framework.constants import CONTEXT, HOOK_TYPE, PAYLOAD, PLUGIN_NAME from cpex.framework.errors import PluginError, convert_exception_to_error -from cpex.framework.hooks.agents import AgentPostInvokeResult, AgentPreInvokeResult -from cpex.framework.hooks.prompts import PromptPosthookResult, PromptPrehookResult from cpex.framework.hooks.registry import get_hook_registry -from cpex.framework.hooks.resources import ResourcePostFetchResult, ResourcePreFetchResult -from cpex.framework.hooks.tools import ToolPostInvokeResult, ToolPreInvokeResult from cpex.framework.isolated.venv_comm import VenvProcessCommunicator from cpex.framework.models import PluginConfig, PluginContext, PluginErrorModel, PluginPayload, PluginResult @@ -243,10 +239,10 @@ async def invoke_hook(self, hook_type: str, payload: PluginPayload, context: Plu try: # Serialize payload and context to ensure they are JSON-serializable - serialized_payload = payload.model_dump(mode="json") if payload else None - serialized_context = context.model_dump(mode="json") if context else None + serialized_payload = payload.model_dump(mode="json") if payload is not None else None + serialized_context = context.model_dump(mode="json") if context is not None else None - # build up the task to send + # Build up the task to send task = { "task_type": "load_and_run_hook", "script_path": self.config.config["script_path"], @@ -257,67 +253,15 @@ async def invoke_hook(self, hook_type: str, payload: PluginPayload, context: Plu PAYLOAD: serialized_payload, CONTEXT: serialized_context, } - result: Any = self.comm.send_task(script_path="cpex/framework/isolated/worker.py", task_data=task) - # - # This is going to be tricky. Need to see what the response is and initialize the proper result object from the dict - # task_data - if hook_type == "tool_pre_invoke": - result = ToolPreInvokeResult( - continue_processing=result.get("continue_processing"), - modified_payload=result.get("modified_payload"), - violation=result.get("violation"), - metadata=result.get("metadata"), - ) - if hook_type == "tool_post_invoke": - result = ToolPostInvokeResult( - continue_processing=result.get("continue_processing"), - modified_payload=result.get("modified_payload"), - violation=result.get("violation"), - metadata=result.get("metadata"), - ) - if hook_type == "prompt_pre_fetch": - result = PromptPrehookResult( - continue_processing=result.get("continue_processing"), - modified_payload=result.get("modified_payload"), - violation=result.get("violation"), - metadata=result.get("metadata"), - ) - if hook_type == "prompt_post_fetch": - result = PromptPosthookResult( - continue_processing=result.get("continue_processing"), - modified_payload=result.get("modified_payload"), - violation=result.get("violation"), - metadata=result.get("metadata"), - ) - if hook_type == "resource_pre_fetch": - result = ResourcePreFetchResult( - continue_processing=result.get("continue_processing"), - modified_payload=result.get("modified_payload"), - violation=result.get("violation"), - metadata=result.get("metadata"), - ) - if hook_type == "resource_post_fetch": - result = ResourcePostFetchResult( - continue_processing=result.get("continue_processing"), - modified_payload=result.get("modified_payload"), - violation=result.get("violation"), - metadata=result.get("metadata"), - ) - if hook_type == "agent_pre_invoke": - result = AgentPreInvokeResult( - continue_processing=result.get("continue_processing"), - modified_payload=result.get("modified_payload"), - violation=result.get("violation"), - metadata=result.get("metadata"), - ) - if hook_type == "agent_post_invoke": - result = AgentPostInvokeResult( - continue_processing=result.get("continue_processing"), - modified_payload=result.get("modified_payload"), - violation=result.get("violation"), - metadata=result.get("metadata"), - ) + + result_dict: dict[str, Any] = self.comm.send_task( + script_path="cpex/framework/isolated/worker.py", task_data=task + ) + + # Use registry to instantiate the correct result type + result = registry.json_to_result(hook_type, result_dict) return result + except PluginError as pe: logger.exception(pe) raise diff --git a/tests/unit/cpex/framework/isolated/test_client.py b/tests/unit/cpex/framework/isolated/test_client.py index 61ffc8c1..8bd8956f 100644 --- a/tests/unit/cpex/framework/isolated/test_client.py +++ b/tests/unit/cpex/framework/isolated/test_client.py @@ -139,15 +139,22 @@ async def test_invoke_hook_tool_pre_invoke_success(self, mock_get_registry, plug mock_registry = MagicMock() mock_registry.get_result_type.return_value = ToolPreInvokeResult mock_get_registry.return_value = mock_registry - - # Setup communicator - mock_comm = MagicMock() response_data = { "continue_processing": True, "modified_payload": {"name": "test_tool", "args": {}}, "violation": None, "metadata": {}, } + + mock_registry.json_to_result = MagicMock() + mock_registry.json_to_result.return_value = ToolPreInvokeResult( + continue_processing=response_data.get("continue_processing"), + modified_payload=response_data.get("modified_payload"), + violation=response_data.get("violation"), + metadata=response_data.get("metadata"), + ) + # Setup communicator + mock_comm = MagicMock() mock_comm.send_task.return_value = response_data plugin.comm = mock_comm @@ -177,6 +184,13 @@ async def test_invoke_hook_tool_post_invoke_success(self, mock_get_registry, plu "metadata": {}, } mock_comm.send_task.return_value = response_data + mock_registry.json_to_result = MagicMock() + mock_registry.json_to_result.return_value = ToolPostInvokeResult( + continue_processing=response_data.get("continue_processing"), + modified_payload=response_data.get("modified_payload"), + violation=response_data.get("violation"), + metadata=response_data.get("metadata"), + ) plugin.comm = mock_comm from cpex.framework.hooks.tools import ToolPostInvokePayload @@ -194,6 +208,7 @@ async def test_invoke_hook_prompt_pre_fetch_success(self, mock_get_registry, plu """Test successful prompt_pre_fetch hook invocation.""" mock_registry = MagicMock() mock_registry.get_result_type.return_value = PromptPrehookResult + mock_registry.json_to_result = MagicMock() mock_get_registry.return_value = mock_registry mock_comm = MagicMock() @@ -204,6 +219,12 @@ async def test_invoke_hook_prompt_pre_fetch_success(self, mock_get_registry, plu "metadata": {}, } mock_comm.send_task.return_value = response_data + mock_registry.json_to_result.return_value = PromptPrehookResult( + continue_processing=response_data.get("continue_processing"), + modified_payload=response_data.get("modified_payload"), + violation=response_data.get("violation"), + metadata=response_data.get("metadata"), + ) plugin.comm = mock_comm from cpex.framework.hooks.prompts import PromptPrehookPayload @@ -230,6 +251,13 @@ async def test_invoke_hook_prompt_post_fetch_success(self, mock_get_registry, pl "violation": None, "metadata": {}, } + mock_registry.json_to_result = MagicMock() + mock_registry.json_to_result.return_value = PromptPosthookResult( + continue_processing=response_data.get("continue_processing"), + modified_payload=response_data.get("modified_payload"), + violation=response_data.get("violation"), + metadata=response_data.get("metadata"), + ) mock_comm.send_task.return_value = response_data plugin.comm = mock_comm @@ -248,6 +276,7 @@ async def test_invoke_hook_with_violation(self, mock_get_registry, plugin, plugi mock_registry = MagicMock() mock_registry.get_result_type.return_value = ToolPreInvokeResult mock_get_registry.return_value = mock_registry + mock_registry.json_to_result = MagicMock() mock_comm = MagicMock() response_data = { @@ -258,6 +287,13 @@ async def test_invoke_hook_with_violation(self, mock_get_registry, plugin, plugi } mock_comm.send_task.return_value = response_data plugin.comm = mock_comm + mock_registry.json_to_result.return_value = ToolPreInvokeResult( + continue_processing=response_data.get("continue_processing"), + modified_payload=response_data.get("modified_payload"), + violation=response_data.get("violation"), + metadata=response_data.get("metadata"), + ) + from cpex.framework.hooks.tools import ToolPreInvokePayload diff --git a/tests/unit/cpex/framework/isolated/test_integration.py b/tests/unit/cpex/framework/isolated/test_integration.py index f3a856f7..ca0cb4d9 100644 --- a/tests/unit/cpex/framework/isolated/test_integration.py +++ b/tests/unit/cpex/framework/isolated/test_integration.py @@ -89,22 +89,6 @@ async def test_isolated_plugin_full_lifecycle(self, mock_create_venv, mock_comm_ "metadata": {} } mock_comm_class.return_value = mock_comm - # manager = PluginManager("./tests/unit/cpex/fixtures/configs/isolated_plugin.yaml") - # Create plugin config - # config_dict = { - # "name": "test_plugin", - # "kind": "isolated_venv", - # "description": "Test plugin", - # "version": "1.0.0", - # "author": "Test", - # "hooks": ["tool_pre_invoke"], - # "config": { - # "class_name": "test_plugin.TestPlugin", - # "venv_path": str(tmp_path / ".venv"), - # "requirements_file": str(tmp_path / "requirements.txt"), - # "script_path": str(tmp_path / "plugins") - # } - # } config_dict = { "name": "test_plugin", @@ -130,6 +114,8 @@ async def test_isolated_plugin_full_lifecycle(self, mock_create_venv, mock_comm_ from cpex.framework.hooks.tools import ToolPreInvokeResult mock_reg = MagicMock() mock_reg.get_result_type.return_value = ToolPreInvokeResult + mock_reg.json_to_result = MagicMock() + mock_reg.json_to_result.return_value = ToolPreInvokeResult(continue_processing=True) mock_registry.return_value = mock_reg await plugin.initialize() @@ -367,8 +353,14 @@ async def test_isolated_plugin_violation_handling( with patch("cpex.framework.isolated.client.get_hook_registry") as mock_registry: from cpex.framework.hooks.tools import ToolPreInvokeResult + mock_reg = MagicMock() mock_reg.get_result_type.return_value = ToolPreInvokeResult + mock_reg.json_to_result = MagicMock() + mock_reg.json_to_result.return_value = ToolPreInvokeResult( + continue_processing=False, + violation={"reason": "Policy violation", "description":"severity high", "code": "PROHIBITED_CONTENT"}, + ) mock_registry.return_value = mock_reg payload = ToolPreInvokePayload(name="dangerous_tool", args={}) From f845801374fd4e03b55686c5d3e1f692fb517d61 Mon Sep 17 00:00:00 2001 From: habeck Date: Tue, 10 Mar 2026 15:43:27 -0400 Subject: [PATCH 20/60] enh: refactored the invoke_hook method in cpex/framework/isolated/client.py to run async Signed-off-by: habeck --- cpex/framework/isolated/client.py | 115 ++++++++++++++++++++++-------- 1 file changed, 85 insertions(+), 30 deletions(-) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index cca06a95..be79081f 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -8,6 +8,8 @@ Module that contains plugin client code to serve venv isolated plugins. """ +import asyncio +import functools import hashlib import json import logging @@ -221,50 +223,103 @@ async def initialize(self) -> None: else: logger.info("Using cached venv, skipping requirements installation") - async def invoke_hook(self, hook_type: str, payload: PluginPayload, context: PluginContext) -> PluginResult: - """Invoke a plugin in the context of the active venv (self.comm)""" + def _validate_hook_invocation(self, hook_type: str) -> type[PluginResult]: + """Validate hook type and communication channel. + + Args: + hook_type: The hook type to validate + + Returns: + The result type for the hook + + Raises: + PluginError: If validation fails + """ registry = get_hook_registry() result_type = registry.get_result_type(hook_type) if not result_type: raise PluginError( error=PluginErrorModel( - message=f"Hook type '{hook_type}' not registered in hook registry", plugin_name=self.name + message=f"Hook type '{hook_type}' not registered in hook registry", + plugin_name=self.name ) ) if not self.comm: - raise PluginError(error=PluginErrorModel(message="Plugin comm not initialized", plugin_name=self.name)) + raise PluginError( + error=PluginErrorModel( + message="Plugin comm not initialized", + plugin_name=self.name + ) + ) + + return result_type + def _build_hook_task( + self, + hook_type: str, + payload: PluginPayload, + context: PluginContext + ) -> dict[str, Any]: + """Build task dictionary for hook invocation. + + Args: + hook_type: The hook type to invoke + payload: The payload to send + context: The context to send + + Returns: + Task dictionary ready for transmission + """ + # Cache config lookups + script_path = self.config.config["script_path"] + class_name = self.config.config["class_name"] safe_config = self.config.get_safe_config() + # Serialize payload and context to ensure they are JSON-serializable + serialized_payload = payload.model_dump(mode="json") if payload is not None else None + serialized_context = context.model_dump(mode="json") if context is not None else None + + return { + "task_type": "load_and_run_hook", + "script_path": script_path, + "class_name": class_name, + "config": safe_config, + HOOK_TYPE: hook_type, + PLUGIN_NAME: self.name, + PAYLOAD: serialized_payload, + CONTEXT: serialized_context, + } + + async def invoke_hook(self, hook_type: str, payload: PluginPayload, context: PluginContext) -> PluginResult: + """Invoke a plugin in the context of the active venv (self.comm)""" try: - # Serialize payload and context to ensure they are JSON-serializable - serialized_payload = payload.model_dump(mode="json") if payload is not None else None - serialized_context = context.model_dump(mode="json") if context is not None else None - - # Build up the task to send - task = { - "task_type": "load_and_run_hook", - "script_path": self.config.config["script_path"], - "class_name": self.config.config["class_name"], - "config": safe_config, - HOOK_TYPE: hook_type, - PLUGIN_NAME: self.name, - PAYLOAD: serialized_payload, - CONTEXT: serialized_context, - } - - result_dict: dict[str, Any] = self.comm.send_task( - script_path="cpex/framework/isolated/worker.py", task_data=task + # Validate and get result type + self._validate_hook_invocation(hook_type) + + # Build and send task + task_data = self._build_hook_task(hook_type, payload, context) + loop = asyncio.get_event_loop() + result_dict: dict[str, Any] = await loop.run_in_executor( + None, + functools.partial(self.comm.send_task,script_path="cpex/framework/isolated/worker.py", + task_data=task_data) + ) + # Convert response to typed result + registry = get_hook_registry() + return registry.json_to_result(hook_type, result_dict) + + except PluginError: + logger.exception( + "Plugin error invoking hook '%s' for plugin '%s'", + hook_type, + self.name ) - - # Use registry to instantiate the correct result type - result = registry.json_to_result(hook_type, result_dict) - return result - - except PluginError as pe: - logger.exception(pe) raise except Exception as e: - logger.exception(e) + logger.exception( + "Unexpected error invoking hook '%s' for plugin '%s'", + hook_type, + self.name + ) raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) from e From fa91525707a0a2420a5696b25ec835b6fe520405 Mon Sep 17 00:00:00 2001 From: habeck Date: Tue, 10 Mar 2026 16:00:04 -0400 Subject: [PATCH 21/60] chore: lint fix Signed-off-by: habeck --- cpex/framework/isolated/client.py | 34 ++++++++----------------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index be79081f..398e9855 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -240,27 +240,16 @@ def _validate_hook_invocation(self, hook_type: str) -> type[PluginResult]: if not result_type: raise PluginError( error=PluginErrorModel( - message=f"Hook type '{hook_type}' not registered in hook registry", - plugin_name=self.name + message=f"Hook type '{hook_type}' not registered in hook registry", plugin_name=self.name ) ) if not self.comm: - raise PluginError( - error=PluginErrorModel( - message="Plugin comm not initialized", - plugin_name=self.name - ) - ) + raise PluginError(error=PluginErrorModel(message="Plugin comm not initialized", plugin_name=self.name)) return result_type - def _build_hook_task( - self, - hook_type: str, - payload: PluginPayload, - context: PluginContext - ) -> dict[str, Any]: + def _build_hook_task(self, hook_type: str, payload: PluginPayload, context: PluginContext) -> dict[str, Any]: """Build task dictionary for hook invocation. Args: @@ -302,24 +291,17 @@ async def invoke_hook(self, hook_type: str, payload: PluginPayload, context: Plu loop = asyncio.get_event_loop() result_dict: dict[str, Any] = await loop.run_in_executor( None, - functools.partial(self.comm.send_task,script_path="cpex/framework/isolated/worker.py", - task_data=task_data) + functools.partial( + self.comm.send_task, script_path="cpex/framework/isolated/worker.py", task_data=task_data + ), ) # Convert response to typed result registry = get_hook_registry() return registry.json_to_result(hook_type, result_dict) except PluginError: - logger.exception( - "Plugin error invoking hook '%s' for plugin '%s'", - hook_type, - self.name - ) + logger.exception("Plugin error invoking hook '%s' for plugin '%s'", hook_type, self.name) raise except Exception as e: - logger.exception( - "Unexpected error invoking hook '%s' for plugin '%s'", - hook_type, - self.name - ) + logger.exception("Unexpected error invoking hook '%s' for plugin '%s'", hook_type, self.name) raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) from e From ef057408703983db66616b2210a19a18dd7e42e8 Mon Sep 17 00:00:00 2001 From: habeck Date: Tue, 10 Mar 2026 16:12:14 -0400 Subject: [PATCH 22/60] chore: updated unit test test_worker to get coverage to 97%. Signed-off-by: habeck --- .../cpex/framework/isolated/test_worker.py | 170 +++++++++++++++++- 1 file changed, 169 insertions(+), 1 deletion(-) diff --git a/tests/unit/cpex/framework/isolated/test_worker.py b/tests/unit/cpex/framework/isolated/test_worker.py index 178183f7..b9d1e206 100644 --- a/tests/unit/cpex/framework/isolated/test_worker.py +++ b/tests/unit/cpex/framework/isolated/test_worker.py @@ -10,12 +10,13 @@ import asyncio import json import sys +from io import StringIO from pathlib import Path from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest -from cpex.framework.isolated.worker import get_environment_info, get_proper_config, process_task +from cpex.framework.isolated.worker import get_environment_info, get_proper_config, main, process_task class TestWorkerFunctions: @@ -290,5 +291,172 @@ async def test_process_task_with_metadata(self, mock_executor_class, mock_import assert call_args is not None +class TestMainFunction: + """Test suite for the main() function.""" + + @pytest.mark.asyncio + @patch("sys.stdin") + @patch("builtins.print") + @patch("cpex.framework.isolated.worker.process_task") + async def test_main_success_with_info_task(self, mock_process_task, mock_print, mock_stdin): + """Test main function with successful info task.""" + # Setup stdin with info task + task_data = {"task_type": "info"} + mock_stdin.read.return_value = json.dumps(task_data) + + # Setup process_task to return a mock result + mock_result = MagicMock() + mock_result.model_dump.return_value = { + "status": "success", + "environment": {"python_version": "3.10"}, + "message": "Environment info retrieved successfully", + } + mock_process_task.return_value = mock_result + + # Run main + await main() + + # Verify process_task was called with correct data + mock_process_task.assert_called_once_with(task_data) + + # Verify output was printed + mock_print.assert_called_once() + printed_output = mock_print.call_args[0][0] + output_data = json.loads(printed_output) + assert output_data["status"] == "success" + + @pytest.mark.asyncio + @patch("sys.stdin") + @patch("builtins.print") + @patch("cpex.framework.isolated.worker.process_task") + async def test_main_success_with_none_result(self, mock_process_task, mock_print, mock_stdin): + """Test main function when process_task returns None.""" + task_data = {"task_type": "unknown"} + mock_stdin.read.return_value = json.dumps(task_data) + + # process_task returns None for unknown task types + mock_process_task.return_value = None + + await main() + + mock_process_task.assert_called_once_with(task_data) + mock_print.assert_called_once() + printed_output = mock_print.call_args[0][0] + # Should print "null" for None + assert printed_output == "null" + + @pytest.mark.asyncio + @patch("sys.stdin") + @patch("builtins.print") + async def test_main_json_decode_error(self, mock_print, mock_stdin): + """Test main function with invalid JSON input.""" + # Setup stdin with invalid JSON + mock_stdin.read.return_value = "not valid json {{" + + await main() + + # Verify error response was printed + mock_print.assert_called_once() + printed_output = mock_print.call_args[0][0] + output_data = json.loads(printed_output) + assert output_data["status"] == "error" + assert output_data["message"] == "Invalid JSON input" + + @pytest.mark.asyncio + @patch("sys.stdin") + @patch("builtins.print") + @patch("cpex.framework.isolated.worker.process_task") + async def test_main_unexpected_exception(self, mock_process_task, mock_print, mock_stdin): + """Test main function with unexpected exception during processing.""" + task_data = {"task_type": "load_and_run_hook"} + mock_stdin.read.return_value = json.dumps(task_data) + + # Make process_task raise an exception + mock_process_task.side_effect = RuntimeError("Unexpected error occurred") + + await main() + + # Verify error response was printed + mock_print.assert_called_once() + printed_output = mock_print.call_args[0][0] + output_data = json.loads(printed_output) + assert output_data["status"] == "error" + assert "Unexpected error: Unexpected error occurred" in output_data["message"] + + @pytest.mark.asyncio + @patch("sys.stdin") + @patch("builtins.print") + @patch("cpex.framework.isolated.worker.process_task") + async def test_main_with_load_and_run_hook_task(self, mock_process_task, mock_print, mock_stdin): + """Test main function with load_and_run_hook task.""" + config_dict = {"name": "test_plugin", "kind": "isolated_venv"} + task_data = { + "task_type": "load_and_run_hook", + "config": json.dumps(config_dict), + "script_path": "plugins", + "class_name": "test_plugin.TestPlugin", + "hook_type": "tool_pre_invoke", + "payload": {"name": "test_tool"}, + "context": {"state": {}, "global_context": {}, "metadata": {}}, + } + mock_stdin.read.return_value = json.dumps(task_data) + + # Setup mock result + mock_result = MagicMock() + mock_result.model_dump.return_value = { + "continue_processing": True, + "payload": {"name": "test_tool", "modified": True}, + "violations": [], + } + mock_process_task.return_value = mock_result + + await main() + + mock_process_task.assert_called_once_with(task_data) + mock_print.assert_called_once() + printed_output = mock_print.call_args[0][0] + output_data = json.loads(printed_output) + assert output_data["continue_processing"] is True + + @pytest.mark.asyncio + @patch("sys.stdin") + @patch("builtins.print") + @patch("cpex.framework.isolated.worker.process_task") + async def test_main_with_empty_stdin(self, mock_process_task, mock_print, mock_stdin): + """Test main function with empty stdin.""" + mock_stdin.read.return_value = "" + + await main() + + # Should handle as JSON decode error + mock_print.assert_called_once() + printed_output = mock_print.call_args[0][0] + output_data = json.loads(printed_output) + assert output_data["status"] == "error" + assert output_data["message"] == "Invalid JSON input" + + @pytest.mark.asyncio + @patch("sys.stdin") + @patch("builtins.print") + @patch("cpex.framework.isolated.worker.process_task") + async def test_main_with_model_dump_exception(self, mock_process_task, mock_print, mock_stdin): + """Test main function when model_dump raises an exception.""" + task_data = {"task_type": "info"} + mock_stdin.read.return_value = json.dumps(task_data) + + # Setup mock result that raises exception on model_dump + mock_result = MagicMock() + mock_result.model_dump.side_effect = ValueError("Cannot serialize") + mock_process_task.return_value = mock_result + + await main() + + # Should catch the exception and return error + mock_print.assert_called_once() + printed_output = mock_print.call_args[0][0] + output_data = json.loads(printed_output) + assert output_data["status"] == "error" + assert "Unexpected error" in output_data["message"] + # Made with Bob From 278722828cb2fea974885b86476d167127abc9e1 Mon Sep 17 00:00:00 2001 From: habeck Date: Tue, 10 Mar 2026 16:38:19 -0400 Subject: [PATCH 23/60] doc: Update README.md detailing how to configure an isolated_venv plugin. Signed-off-by: habeck --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index 68eaeba9..4ed0e0c1 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,32 @@ server = ExternalPluginServer(plugins=[MyPlugin(config)]) server.run() ``` +## Isolated plugins + +Plugins can be run in a separate python virtual environment (venv) to prevent them from interfering with the host environment. + +```yaml + - name: "test_plugin" + kind: "isolated_venv" + version: "0.1.0" + hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] + tags: ["plugin"] + mode: "sequential" + priority: 150 + conditions: + # Apply to specific tools/servers + - server_ids: [] # Apply to all servers + tenant_ids: [] # Apply to all tenants + config: + # Plugin config dict passed to the plugin constructor + class_name: "test_plugin.plugin.TestPlugin" + venv_path: "plugins/test_plugin/.venv" + requirements_file: "plugins/test_plugin/requirements.txt" + # essentially the plugin folder hosting the plugin relative to the project root + script_path: "plugins" +``` + + ## Project Status CPEX is under active development as part of the [ContextForge](https://github.com/contextforge-org) ecosystem. The framework is designed to work across AI gateways, agent frameworks, LLM proxies, and tool servers. From 3c4720bc6b89f822519da96bfe8317224f2ae40e Mon Sep 17 00:00:00 2001 From: habeck Date: Tue, 10 Mar 2026 16:50:51 -0400 Subject: [PATCH 24/60] chore: update README.md Signed-off-by: habeck --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4ed0e0c1..2a993420 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,7 @@ server.run() ## Isolated plugins -Plugins can be run in a separate python virtual environment (venv) to prevent them from interfering with the host environment. +Native plugins can be run in a separate python virtual environment (venv) to prevent them from interfering with the host environment. Plugin specific packages are automatically installed based on the contents of the supplied requirements_file. ```yaml - name: "test_plugin" From 9bf418fca2338272d5f8aa007824b13ef76db71a Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 12 Mar 2026 11:11:34 -0400 Subject: [PATCH 25/60] enh: The optimization eliminates the overhead of: Forking a new Python process (~1.2ms per fork_exec) Initializing the Python interpreter Loading modules and dependencies Setting up the subprocess communication pipes Signed-off-by: habeck --- cpex/framework/isolated/client.py | 7 + cpex/framework/isolated/venv_comm.py | 215 ++++++++++++-- cpex/framework/isolated/worker.py | 78 ++++- .../cpex/framework/isolated/test_client.py | 19 ++ .../cpex/framework/isolated/test_venv_comm.py | 280 +++++++++++++++--- .../cpex/framework/isolated/test_worker.py | 116 +++++--- 6 files changed, 601 insertions(+), 114 deletions(-) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index 398e9855..187e3fa3 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -223,6 +223,13 @@ async def initialize(self) -> None: else: logger.info("Using cached venv, skipping requirements installation") + async def cleanup(self) -> None: + """Cleanup resources, including stopping the worker process.""" + if self.comm: + logger.info("Stopping worker process for plugin '%s'", self.name) + self.comm.stop_worker() + self.comm = None + def _validate_hook_invocation(self, hook_type: str) -> type[PluginResult]: """Validate hook type and communication channel. diff --git a/cpex/framework/isolated/venv_comm.py b/cpex/framework/isolated/venv_comm.py index 60c9933f..4a6bc455 100644 --- a/cpex/framework/isolated/venv_comm.py +++ b/cpex/framework/isolated/venv_comm.py @@ -11,8 +11,11 @@ import os import subprocess import sys +import threading +import uuid from pathlib import Path -from typing import Any +from queue import Empty, Queue +from typing import Any, Optional import orjson @@ -20,7 +23,7 @@ class VenvProcessCommunicator: - """Handles communication with child processes in different virtual environments.""" + """Handles communication with a long-running child process in a different virtual environment.""" def __init__(self, venv_path: str) -> None: """ @@ -31,6 +34,12 @@ def __init__(self, venv_path: str) -> None: """ self.venv_path = Path(venv_path) self.python_executable = self._get_python_executable() + self.process: Optional[subprocess.Popen] = None + self.reader_thread: Optional[threading.Thread] = None + self.stderr_thread: Optional[threading.Thread] = None + self.response_queues: dict[str, Queue] = {} + self.lock = threading.Lock() + self.running = False logger.info("cwd: %s", os.getcwd()) def _get_python_executable(self): @@ -57,47 +66,199 @@ def install_requirements(self, requirements_file: str) -> None: if rc != 0: raise Exception(f"Failed to install requirements from {requirements_file}") - def send_task(self, script_path: str, task_data: Any) -> Any: + def start_worker(self, script_path: str) -> None: """ - Send a task to child process and get response. + Start the long-running worker process. Args: - script_path (str): Path to the child script - task_data (dict): Data to send to child process - - Returns: - dict: Response from child process + script_path (str): Path to the worker script """ - process = None + if self.running: + logger.warning("Worker process already running") + return + try: - # Prepare input data as JSON - input_json = orjson.dumps(task_data).decode() # Start child process - process = subprocess.Popen( + self.process = subprocess.Popen( [self.python_executable, script_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - cwd=os.getcwd(), # Maintain current working directory + bufsize=1, # Line buffered + cwd=os.getcwd(), ) - # Send data and get response - stdout, stderr = process.communicate(input=input_json, timeout=30) + self.running = True + + # Start reader thread to handle responses + self.reader_thread = threading.Thread(target=self._read_responses, daemon=True) + self.reader_thread.start() + + # Start stderr reader thread to capture errors + self.stderr_thread = threading.Thread(target=self._read_stderr, daemon=True) + self.stderr_thread.start() + + logger.info("Worker process started with PID: %s", self.process.pid) + + except Exception as e: + self.running = False + raise RuntimeError(f"Failed to start worker process: {e}") + + def _read_stderr(self) -> None: + """Background thread to read and log stderr from worker process.""" + if not self.process or not self.process.stderr: + return + + while self.running and self.process and self.process.stderr: + try: + line = self.process.stderr.readline() + if not line: + break + # Log stderr output from worker + logger.debug("Worker stderr: %s", line.strip()) + except Exception as e: + logger.error("Error reading stderr: %s", e) + break + + def _read_responses(self) -> None: + """Background thread to read responses from worker process.""" + while self.running and self.process and self.process.stdout: + try: + line = self.process.stdout.readline() + if not line: + # Process has terminated + logger.warning("Worker process stdout closed") + break + + line = line.strip() + if not line: + # Empty line, skip + continue + + try: + response = json.loads(line) + request_id = response.get("request_id") + + if request_id: + with self.lock: + if request_id in self.response_queues: + self.response_queues[request_id].put(response) + logger.debug("Response queued for request_id: %s", request_id) + else: + logger.warning("Received response for unknown request_id: %s", request_id) + else: + logger.warning("Received response without request_id: %s", line[:100]) + + except json.JSONDecodeError as e: + logger.error("Failed to decode response: %s, line: %s", e, line[:200]) + + except Exception as e: + logger.exception("Error reading response: %s", e) + break + + self.running = False + logger.info("Response reader thread terminated") + + def send_task(self, script_path: str, task_data: Any, timeout: float = 30.0) -> Any: + """ + Send a task to the long-running worker process and get response. + + Args: + script_path (str): Path to the child script (used for worker initialization) + task_data (dict): Data to send to child process + timeout (float): Timeout in seconds for waiting for response + + Returns: + dict: Response from child process + """ + # Start worker if not running + if not self.running: + self.start_worker(script_path) + + # Generate unique request ID + request_id = str(uuid.uuid4()) + task_data["request_id"] = request_id + + # Create response queue for this request + response_queue: Queue = Queue() + with self.lock: + self.response_queues[request_id] = response_queue - if process.returncode != 0: - raise RuntimeError(f"Child process failed: {stderr}") + try: + # Send task to worker + input_json = orjson.dumps(task_data).decode() + if self.process and self.process.stdin: + self.process.stdin.write(input_json + "\n") + self.process.stdin.flush() + else: + raise RuntimeError("Worker process stdin not available") - # Parse response + # Wait for response try: - response = json.loads(stdout.strip()) + response = response_queue.get(timeout=timeout) + + # Check for errors in response + if response.get("status") == "error": + raise RuntimeError(f"Worker process error: {response.get('message')}") + + # Remove request_id from response before returning + response.pop("request_id", None) return response - except json.JSONDecodeError: - raise RuntimeError(f"Invalid JSON response from child: {stdout}") - except subprocess.TimeoutExpired: - if process: - process.kill() - raise RuntimeError("Child process timed out") + except Empty: + raise RuntimeError(f"Worker process timed out after {timeout} seconds") + + finally: + # Clean up response queue + with self.lock: + self.response_queues.pop(request_id, None) + + def stop_worker(self) -> None: + """Stop the long-running worker process.""" + if not self.running: + return + + self.running = False + + try: + if self.process: + # Send shutdown signal + if self.process.stdin: + try: + shutdown_task = {"task_type": "shutdown", "request_id": "shutdown"} + self.process.stdin.write(json.dumps(shutdown_task) + "\n") + self.process.stdin.flush() + except Exception as e: + logger.warning("Failed to send shutdown signal: %s", e) + + # Wait for process to terminate gracefully + try: + self.process.wait(timeout=5.0) + except subprocess.TimeoutExpired: + logger.warning("Worker process did not terminate gracefully, killing it") + self.process.kill() + self.process.wait() + + logger.info("Worker process stopped") + except Exception as e: - raise RuntimeError(f"Communication error: {e}") + logger.error("Error stopping worker process: %s", e) + + finally: + self.process = None + if self.reader_thread and self.reader_thread.is_alive(): + self.reader_thread.join(timeout=2.0) + self.reader_thread = None + if self.stderr_thread and self.stderr_thread.is_alive(): + self.stderr_thread.join(timeout=2.0) + self.stderr_thread = None + + def is_alive(self) -> bool: + """Check if the worker process is alive and running.""" + return self.running and self.process is not None and self.process.poll() is None + + def __del__(self): + """Cleanup when object is destroyed.""" + if hasattr(self, 'running'): + self.stop_worker() diff --git a/cpex/framework/isolated/worker.py b/cpex/framework/isolated/worker.py index 3d220dad..97d0b9e6 100644 --- a/cpex/framework/isolated/worker.py +++ b/cpex/framework/isolated/worker.py @@ -100,24 +100,70 @@ async def process_task(task_data): async def main(): - """Main function - read from stdin, process, write to stdout.""" + """Main function - continuously read from stdin, process tasks, write to stdout.""" + logger.info("Worker process started, waiting for tasks...") + try: - # Read input from parent process - input_data = sys.stdin.read() - task_data = json.loads(input_data) - - # Process the task - response = await process_task(task_data) - serializable_response = response.model_dump(mode="json") if response else None - # Send response back to parent - print(json.dumps(serializable_response)) - - except json.JSONDecodeError: - error_response = {"status": "error", "message": "Invalid JSON input"} - print(json.dumps(error_response)) + # Continuously read and process tasks + while True: + try: + # Read one line at a time + line = sys.stdin.readline() + + # Check for EOF + if not line: + logger.info("EOF received, shutting down worker") + break + + # Parse the task + task_data = json.loads(line.strip()) + request_id = task_data.get("request_id", "unknown") + + # Check for shutdown signal + if task_data.get("task_type") == "shutdown": + logger.info("Shutdown signal received") + response = {"status": "success", "message": "Shutting down", "request_id": request_id} + print(json.dumps(response), flush=True) + break + + # Process the task + response = await process_task(task_data) + + # Serialize response + if response: + serializable_response = response.model_dump(mode="json") + else: + serializable_response = {"status": "success"} + + # Add request_id to response + serializable_response["request_id"] = request_id + + # Send response back to parent (one line per response) + print(json.dumps(serializable_response), flush=True) + + except json.JSONDecodeError as e: + error_response = { + "status": "error", + "message": f"Invalid JSON input: {str(e)}", + "request_id": task_data.get("request_id", "unknown") if 'task_data' in locals() else "unknown" + } + print(json.dumps(error_response), flush=True) + + except Exception as e: + logger.error("Error processing task: %s", str(e)) + error_response = { + "status": "error", + "message": f"Unexpected error: {str(e)}", + "request_id": task_data.get("request_id", "unknown") if 'task_data' in locals() else "unknown" + } + print(json.dumps(error_response), flush=True) + + except KeyboardInterrupt: + logger.info("Worker interrupted") except Exception as e: - error_response = {"status": "error", "message": f"Unexpected error: {str(e)}"} - print(json.dumps(error_response)) + logger.exception("Fatal error in worker main loop") + finally: + logger.info("Worker process shutting down") if __name__ == "__main__": diff --git a/tests/unit/cpex/framework/isolated/test_client.py b/tests/unit/cpex/framework/isolated/test_client.py index 8bd8956f..8c4002e0 100644 --- a/tests/unit/cpex/framework/isolated/test_client.py +++ b/tests/unit/cpex/framework/isolated/test_client.py @@ -650,6 +650,25 @@ async def test_initialize_with_invalid_cache(self, mock_save_metadata, mock_cach # Should install requirements when cache is invalid mock_comm.install_requirements.assert_called_once() mock_save_metadata.assert_called_once() + @pytest.mark.asyncio + async def test_cleanup(self, plugin): + """Test cleanup method stops worker process.""" + mock_comm = MagicMock() + plugin.comm = mock_comm + + await plugin.cleanup() + + mock_comm.stop_worker.assert_called_once() + assert plugin.comm is None + + @pytest.mark.asyncio + async def test_cleanup_no_comm(self, plugin): + """Test cleanup when comm is None.""" + plugin.comm = None + + # Should not raise error + await plugin.cleanup() + # Made with Bob diff --git a/tests/unit/cpex/framework/isolated/test_venv_comm.py b/tests/unit/cpex/framework/isolated/test_venv_comm.py index b143d74c..891d80f0 100644 --- a/tests/unit/cpex/framework/isolated/test_venv_comm.py +++ b/tests/unit/cpex/framework/isolated/test_venv_comm.py @@ -123,62 +123,93 @@ def test_install_requirements_nonexistent_file(self, communicator): communicator.install_requirements("nonexistent_requirements.txt") @patch("subprocess.Popen") - def test_send_task_success(self, mock_popen, communicator): + @patch("threading.Thread") + @patch("cpex.framework.isolated.venv_comm.Queue") + def test_send_task_success(self, mock_queue_class, mock_thread, mock_popen, communicator): """Test successful task sending and response.""" task_data = {"task_type": "info", "data": "test"} - expected_response = {"status": "success", "result": "ok"} # Mock the process mock_process = MagicMock() - mock_process.communicate.return_value = (json.dumps(expected_response), "") - mock_process.returncode = 0 + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None mock_popen.return_value = mock_process + # Mock the thread + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + # Mock the Queue to return our response + mock_queue_instance = MagicMock() + mock_queue_instance.get.return_value = { + "status": "success", + "result": "ok", + "request_id": "test-id" + } + mock_queue_class.return_value = mock_queue_instance + + # Manually start the worker to set up the infrastructure + communicator.start_worker("test_script.py") + result = communicator.send_task("test_script.py", task_data) - assert result == expected_response - mock_popen.assert_called_once() - mock_process.communicate.assert_called_once() + # Request ID should be removed from response + assert result == {"status": "success", "result": "ok"} @patch("subprocess.Popen") - def test_send_task_process_failure(self, mock_popen, communicator): + @patch("threading.Thread") + @patch("cpex.framework.isolated.venv_comm.Queue") + def test_send_task_process_failure(self, mock_queue_class, mock_thread, mock_popen, communicator): """Test task sending with process failure.""" task_data = {"task_type": "test"} mock_process = MagicMock() - mock_process.communicate.return_value = ("", "Error occurred") - mock_process.returncode = 1 + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None mock_popen.return_value = mock_process - with pytest.raises(RuntimeError, match="Child process failed"): - communicator.send_task("test_script.py", task_data) - - @patch("subprocess.Popen") - def test_send_task_invalid_json_response(self, mock_popen, communicator): - """Test task sending with invalid JSON response.""" - task_data = {"task_type": "test"} + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance - mock_process = MagicMock() - mock_process.communicate.return_value = ("invalid json", "") - mock_process.returncode = 0 - mock_popen.return_value = mock_process + # Mock the Queue to return error response + mock_queue_instance = MagicMock() + mock_queue_instance.get.return_value = { + "status": "error", + "message": "Process failed", + "request_id": "test-id" + } + mock_queue_class.return_value = mock_queue_instance + + # Start worker + communicator.start_worker("test_script.py") - with pytest.raises(RuntimeError, match="Invalid JSON response"): + with pytest.raises(RuntimeError, match="Worker process error: Process failed"): communicator.send_task("test_script.py", task_data) @patch("subprocess.Popen") - def test_send_task_timeout(self, mock_popen, communicator): + @patch("threading.Thread") + def test_send_task_timeout(self, mock_thread, mock_popen, communicator): """Test task sending with timeout.""" task_data = {"task_type": "test"} mock_process = MagicMock() - mock_process.communicate.side_effect = subprocess.TimeoutExpired("cmd", 30) + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None mock_popen.return_value = mock_process - with pytest.raises(RuntimeError, match="Child process timed out"): - communicator.send_task("test_script.py", task_data) + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance - mock_process.kill.assert_called_once() + # Don't put anything in the queue to simulate timeout + + with pytest.raises(RuntimeError, match="Worker process timed out"): + communicator.send_task("test_script.py", task_data, timeout=0.1) @patch("subprocess.Popen") def test_send_task_communication_error(self, mock_popen, communicator): @@ -187,11 +218,13 @@ def test_send_task_communication_error(self, mock_popen, communicator): mock_popen.side_effect = OSError("Connection failed") - with pytest.raises(RuntimeError, match="Communication error"): + with pytest.raises(RuntimeError, match="Failed to start worker process"): communicator.send_task("test_script.py", task_data) @patch("subprocess.Popen") - def test_send_task_with_complex_data(self, mock_popen, communicator): + @patch("threading.Thread") + @patch("cpex.framework.isolated.venv_comm.Queue") + def test_send_task_with_complex_data(self, mock_queue_class, mock_thread, mock_popen, communicator): """Test sending task with complex nested data structures.""" task_data = { "task_type": "load_and_run_hook", @@ -199,32 +232,63 @@ def test_send_task_with_complex_data(self, mock_popen, communicator): "payload": {"args": {"key": "value"}}, "context": {"state": {}, "metadata": {}} } - expected_response = {"status": "success", "result": {"data": "processed"}} mock_process = MagicMock() - mock_process.communicate.return_value = (json.dumps(expected_response), "") - mock_process.returncode = 0 + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None mock_popen.return_value = mock_process + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + # Mock the Queue to return response + mock_queue_instance = MagicMock() + mock_queue_instance.get.return_value = { + "status": "success", + "result": {"data": "processed"}, + "request_id": "test-id" + } + mock_queue_class.return_value = mock_queue_instance + + # Start worker + communicator.start_worker("worker.py") + result = communicator.send_task("worker.py", task_data) - assert result == expected_response + assert result == {"status": "success", "result": {"data": "processed"}} # Verify the task was serialized properly call_args = mock_popen.call_args assert call_args is not None @patch("subprocess.Popen") + @patch("threading.Thread") + @patch("cpex.framework.isolated.venv_comm.Queue") @patch("os.getcwd") - def test_send_task_maintains_cwd(self, mock_getcwd, mock_popen, communicator): + def test_send_task_maintains_cwd(self, mock_getcwd, mock_queue_class, mock_thread, mock_popen, communicator): """Test that send_task maintains current working directory.""" mock_getcwd.return_value = "/test/path" task_data = {"task_type": "test"} mock_process = MagicMock() - mock_process.communicate.return_value = ('{"status": "ok"}', "") - mock_process.returncode = 0 + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None mock_popen.return_value = mock_process + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + # Mock the Queue to return response + mock_queue_instance = MagicMock() + mock_queue_instance.get.return_value = {"status": "ok", "request_id": "test-id"} + mock_queue_class.return_value = mock_queue_instance + + # Start worker + communicator.start_worker("test_script.py") + communicator.send_task("test_script.py", task_data) # Verify cwd was passed to Popen @@ -241,5 +305,149 @@ def test_venv_path_property(self, communicator, mock_venv_path): """Test that venv_path property is accessible.""" assert communicator.venv_path == mock_venv_path assert isinstance(communicator.venv_path, Path) + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_start_worker_success(self, mock_thread, mock_popen, communicator): + """Test successful worker process start.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.pid = 12345 + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + assert communicator.running is True + assert communicator.process is not None + mock_popen.assert_called_once() + # Should start two threads (stdout and stderr readers) + assert mock_thread.call_count == 2 + + @patch("subprocess.Popen") + def test_start_worker_already_running(self, mock_popen, communicator): + """Test starting worker when already running.""" + communicator.running = True + communicator.process = MagicMock() + + communicator.start_worker("test_script.py") + + # Should not create new process + mock_popen.assert_not_called() + + @patch("subprocess.Popen") + def test_start_worker_failure(self, mock_popen, communicator): + """Test worker start failure.""" + mock_popen.side_effect = OSError("Failed to start") + + with pytest.raises(RuntimeError, match="Failed to start worker process"): + communicator.start_worker("test_script.py") + + assert communicator.running is False + + def test_stop_worker_not_running(self, communicator): + """Test stopping worker when not running.""" + communicator.running = False + communicator.process = None + + # Should not raise error + communicator.stop_worker() + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_stop_worker_success(self, mock_thread, mock_popen, communicator): + """Test successful worker stop.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.wait.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread_instance.is_alive.return_value = False + mock_thread.return_value = mock_thread_instance + + # Start worker first + communicator.start_worker("test_script.py") + + # Stop worker + communicator.stop_worker() + + assert communicator.running is False + assert communicator.process is None + mock_process.wait.assert_called() + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_stop_worker_timeout(self, mock_thread, mock_popen, communicator): + """Test worker stop with timeout.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.wait.side_effect = subprocess.TimeoutExpired("cmd", 5) + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread_instance.is_alive.return_value = False + mock_thread.return_value = mock_thread_instance + + # Start worker first + communicator.start_worker("test_script.py") + + # Stop worker + communicator.stop_worker() + + # Should kill process after timeout + mock_process.kill.assert_called_once() + + def test_is_alive_not_running(self, communicator): + """Test is_alive when worker not running.""" + communicator.running = False + communicator.process = None + + assert communicator.is_alive() is False + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_is_alive_running(self, mock_thread, mock_popen, communicator): + """Test is_alive when worker is running.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + assert communicator.is_alive() is True + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_is_alive_process_terminated(self, mock_thread, mock_popen, communicator): + """Test is_alive when process has terminated.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = 1 # Process terminated + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + assert communicator.is_alive() is False + # Made with Bob diff --git a/tests/unit/cpex/framework/isolated/test_worker.py b/tests/unit/cpex/framework/isolated/test_worker.py index b9d1e206..0aa43678 100644 --- a/tests/unit/cpex/framework/isolated/test_worker.py +++ b/tests/unit/cpex/framework/isolated/test_worker.py @@ -300,9 +300,9 @@ class TestMainFunction: @patch("cpex.framework.isolated.worker.process_task") async def test_main_success_with_info_task(self, mock_process_task, mock_print, mock_stdin): """Test main function with successful info task.""" - # Setup stdin with info task - task_data = {"task_type": "info"} - mock_stdin.read.return_value = json.dumps(task_data) + # Setup stdin to return one task then EOF + task_data = {"task_type": "info", "request_id": "req-123"} + mock_stdin.readline.side_effect = [json.dumps(task_data) + "\n", ""] # EOF after first task # Setup process_task to return a mock result mock_result = MagicMock() @@ -317,13 +317,17 @@ async def test_main_success_with_info_task(self, mock_process_task, mock_print, await main() # Verify process_task was called with correct data - mock_process_task.assert_called_once_with(task_data) + mock_process_task.assert_called_once() + call_args = mock_process_task.call_args[0][0] + assert call_args["task_type"] == "info" + assert call_args["request_id"] == "req-123" - # Verify output was printed + # Verify output was printed with request_id mock_print.assert_called_once() printed_output = mock_print.call_args[0][0] output_data = json.loads(printed_output) assert output_data["status"] == "success" + assert output_data["request_id"] == "req-123" @pytest.mark.asyncio @patch("sys.stdin") @@ -331,36 +335,38 @@ async def test_main_success_with_info_task(self, mock_process_task, mock_print, @patch("cpex.framework.isolated.worker.process_task") async def test_main_success_with_none_result(self, mock_process_task, mock_print, mock_stdin): """Test main function when process_task returns None.""" - task_data = {"task_type": "unknown"} - mock_stdin.read.return_value = json.dumps(task_data) + task_data = {"task_type": "unknown", "request_id": "req-456"} + mock_stdin.readline.side_effect = [json.dumps(task_data) + "\n", ""] # process_task returns None for unknown task types mock_process_task.return_value = None await main() - mock_process_task.assert_called_once_with(task_data) + mock_process_task.assert_called_once() mock_print.assert_called_once() printed_output = mock_print.call_args[0][0] - # Should print "null" for None - assert printed_output == "null" + output_data = json.loads(printed_output) + # Should have success status and request_id + assert output_data["status"] == "success" + assert output_data["request_id"] == "req-456" @pytest.mark.asyncio @patch("sys.stdin") @patch("builtins.print") async def test_main_json_decode_error(self, mock_print, mock_stdin): """Test main function with invalid JSON input.""" - # Setup stdin with invalid JSON - mock_stdin.read.return_value = "not valid json {{" + # Setup stdin with invalid JSON then EOF + mock_stdin.readline.side_effect = ["not valid json {{", ""] await main() # Verify error response was printed - mock_print.assert_called_once() - printed_output = mock_print.call_args[0][0] + mock_print.assert_called() + printed_output = mock_print.call_args_list[0][0][0] output_data = json.loads(printed_output) assert output_data["status"] == "error" - assert output_data["message"] == "Invalid JSON input" + assert "Invalid JSON input" in output_data["message"] @pytest.mark.asyncio @patch("sys.stdin") @@ -368,8 +374,8 @@ async def test_main_json_decode_error(self, mock_print, mock_stdin): @patch("cpex.framework.isolated.worker.process_task") async def test_main_unexpected_exception(self, mock_process_task, mock_print, mock_stdin): """Test main function with unexpected exception during processing.""" - task_data = {"task_type": "load_and_run_hook"} - mock_stdin.read.return_value = json.dumps(task_data) + task_data = {"task_type": "load_and_run_hook", "request_id": "req-789"} + mock_stdin.readline.side_effect = [json.dumps(task_data) + "\n", ""] # Make process_task raise an exception mock_process_task.side_effect = RuntimeError("Unexpected error occurred") @@ -377,11 +383,12 @@ async def test_main_unexpected_exception(self, mock_process_task, mock_print, mo await main() # Verify error response was printed - mock_print.assert_called_once() - printed_output = mock_print.call_args[0][0] + mock_print.assert_called() + printed_output = mock_print.call_args_list[0][0][0] output_data = json.loads(printed_output) assert output_data["status"] == "error" assert "Unexpected error: Unexpected error occurred" in output_data["message"] + assert output_data["request_id"] == "req-789" @pytest.mark.asyncio @patch("sys.stdin") @@ -398,8 +405,9 @@ async def test_main_with_load_and_run_hook_task(self, mock_process_task, mock_pr "hook_type": "tool_pre_invoke", "payload": {"name": "test_tool"}, "context": {"state": {}, "global_context": {}, "metadata": {}}, + "request_id": "req-abc", } - mock_stdin.read.return_value = json.dumps(task_data) + mock_stdin.readline.side_effect = [json.dumps(task_data) + "\n", ""] # Setup mock result mock_result = MagicMock() @@ -412,28 +420,24 @@ async def test_main_with_load_and_run_hook_task(self, mock_process_task, mock_pr await main() - mock_process_task.assert_called_once_with(task_data) + mock_process_task.assert_called_once() mock_print.assert_called_once() printed_output = mock_print.call_args[0][0] output_data = json.loads(printed_output) assert output_data["continue_processing"] is True + assert output_data["request_id"] == "req-abc" @pytest.mark.asyncio @patch("sys.stdin") @patch("builtins.print") - @patch("cpex.framework.isolated.worker.process_task") - async def test_main_with_empty_stdin(self, mock_process_task, mock_print, mock_stdin): - """Test main function with empty stdin.""" - mock_stdin.read.return_value = "" + async def test_main_with_empty_line(self, mock_print, mock_stdin): + """Test main function with empty line (EOF).""" + mock_stdin.readline.return_value = "" await main() - # Should handle as JSON decode error - mock_print.assert_called_once() - printed_output = mock_print.call_args[0][0] - output_data = json.loads(printed_output) - assert output_data["status"] == "error" - assert output_data["message"] == "Invalid JSON input" + # Should exit gracefully without printing error + # (may not print anything if EOF is first thing read) @pytest.mark.asyncio @patch("sys.stdin") @@ -441,8 +445,8 @@ async def test_main_with_empty_stdin(self, mock_process_task, mock_print, mock_s @patch("cpex.framework.isolated.worker.process_task") async def test_main_with_model_dump_exception(self, mock_process_task, mock_print, mock_stdin): """Test main function when model_dump raises an exception.""" - task_data = {"task_type": "info"} - mock_stdin.read.return_value = json.dumps(task_data) + task_data = {"task_type": "info", "request_id": "req-error"} + mock_stdin.readline.side_effect = [json.dumps(task_data) + "\n", ""] # Setup mock result that raises exception on model_dump mock_result = MagicMock() @@ -452,11 +456,53 @@ async def test_main_with_model_dump_exception(self, mock_process_task, mock_prin await main() # Should catch the exception and return error - mock_print.assert_called_once() - printed_output = mock_print.call_args[0][0] + mock_print.assert_called() + printed_output = mock_print.call_args_list[0][0][0] output_data = json.loads(printed_output) assert output_data["status"] == "error" assert "Unexpected error" in output_data["message"] + @pytest.mark.asyncio + @patch("sys.stdin") + @patch("builtins.print") + async def test_main_with_shutdown_signal(self, mock_print, mock_stdin): + """Test main function with shutdown signal.""" + task_data = {"task_type": "shutdown", "request_id": "shutdown"} + mock_stdin.readline.side_effect = [json.dumps(task_data) + "\n", ""] + + await main() + + # Should print shutdown response and exit + mock_print.assert_called_once() + printed_output = mock_print.call_args[0][0] + output_data = json.loads(printed_output) + assert output_data["status"] == "success" + assert output_data["message"] == "Shutting down" + assert output_data["request_id"] == "shutdown" + + @pytest.mark.asyncio + @patch("sys.stdin") + @patch("builtins.print") + @patch("cpex.framework.isolated.worker.process_task") + async def test_main_multiple_tasks(self, mock_process_task, mock_print, mock_stdin): + """Test main function processing multiple tasks.""" + task1 = {"task_type": "info", "request_id": "req-1"} + task2 = {"task_type": "info", "request_id": "req-2"} + mock_stdin.readline.side_effect = [ + json.dumps(task1) + "\n", + json.dumps(task2) + "\n", + "" # EOF + ] + + mock_result = MagicMock() + mock_result.model_dump.return_value = {"status": "success"} + mock_process_task.return_value = mock_result + + await main() + + # Should process both tasks + assert mock_process_task.call_count == 2 + assert mock_print.call_count == 2 + # Made with Bob From bc55093dc041e07b806d83443dc475f1e1580fdf Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 12 Mar 2026 11:13:58 -0400 Subject: [PATCH 26/60] chore: lint fix Signed-off-by: habeck --- cpex/framework/isolated/venv_comm.py | 2 +- cpex/framework/isolated/worker.py | 28 ++++++++++++++-------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/cpex/framework/isolated/venv_comm.py b/cpex/framework/isolated/venv_comm.py index 4a6bc455..653d5e02 100644 --- a/cpex/framework/isolated/venv_comm.py +++ b/cpex/framework/isolated/venv_comm.py @@ -260,5 +260,5 @@ def is_alive(self) -> bool: def __del__(self): """Cleanup when object is destroyed.""" - if hasattr(self, 'running'): + if hasattr(self, "running"): self.stop_worker() diff --git a/cpex/framework/isolated/worker.py b/cpex/framework/isolated/worker.py index 97d0b9e6..24696991 100644 --- a/cpex/framework/isolated/worker.py +++ b/cpex/framework/isolated/worker.py @@ -102,65 +102,65 @@ async def process_task(task_data): async def main(): """Main function - continuously read from stdin, process tasks, write to stdout.""" logger.info("Worker process started, waiting for tasks...") - + try: # Continuously read and process tasks while True: try: # Read one line at a time line = sys.stdin.readline() - + # Check for EOF if not line: logger.info("EOF received, shutting down worker") break - + # Parse the task task_data = json.loads(line.strip()) request_id = task_data.get("request_id", "unknown") - + # Check for shutdown signal if task_data.get("task_type") == "shutdown": logger.info("Shutdown signal received") response = {"status": "success", "message": "Shutting down", "request_id": request_id} print(json.dumps(response), flush=True) break - + # Process the task response = await process_task(task_data) - + # Serialize response if response: serializable_response = response.model_dump(mode="json") else: serializable_response = {"status": "success"} - + # Add request_id to response serializable_response["request_id"] = request_id - + # Send response back to parent (one line per response) print(json.dumps(serializable_response), flush=True) - + except json.JSONDecodeError as e: error_response = { "status": "error", "message": f"Invalid JSON input: {str(e)}", - "request_id": task_data.get("request_id", "unknown") if 'task_data' in locals() else "unknown" + "request_id": task_data.get("request_id", "unknown") if "task_data" in locals() else "unknown", } print(json.dumps(error_response), flush=True) - + except Exception as e: logger.error("Error processing task: %s", str(e)) error_response = { "status": "error", "message": f"Unexpected error: {str(e)}", - "request_id": task_data.get("request_id", "unknown") if 'task_data' in locals() else "unknown" + "request_id": task_data.get("request_id", "unknown") if "task_data" in locals() else "unknown", } print(json.dumps(error_response), flush=True) - + except KeyboardInterrupt: logger.info("Worker interrupted") - except Exception as e: + except Exception: logger.exception("Fatal error in worker main loop") finally: logger.info("Worker process shutting down") From 97dfba979d029d665756e5250b9d872107599d57 Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 12 Mar 2026 18:44:16 -0400 Subject: [PATCH 27/60] fix: fail early plugin_path do not exist, computer .venv path automatically, update cli to support creating an isolated plugin. Signed-off-by: habeck --- README.md | 3 +- cpex/framework/isolated/client.py | 21 +- cpex/framework/isolated/worker.py | 2 - cpex/templates/isolated/cookiecutter.json | 8 + .../{{cookiecutter.plugin_slug}}/README.md | 10 + .../{{cookiecutter.plugin_slug}}/__init__.py | 7 + .../{{cookiecutter.plugin_slug}}/config.yaml | 39 +++ .../plugin-manifest.yaml | 9 + .../{{cookiecutter.plugin_slug}}/plugin.py | 90 ++++++ .../requirements.txt | 1 + cpex/tools/cli.py | 2 +- .../cpex/framework/isolated/test_client.py | 4 +- .../framework/isolated/test_integration.py | 20 +- .../cpex/framework/isolated/test_venv_comm.py | 297 ++++++++++++++++++ 14 files changed, 485 insertions(+), 28 deletions(-) create mode 100644 cpex/templates/isolated/cookiecutter.json create mode 100644 cpex/templates/isolated/{{cookiecutter.plugin_slug}}/README.md create mode 100644 cpex/templates/isolated/{{cookiecutter.plugin_slug}}/__init__.py create mode 100644 cpex/templates/isolated/{{cookiecutter.plugin_slug}}/config.yaml create mode 100644 cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml create mode 100644 cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin.py create mode 100644 cpex/templates/isolated/{{cookiecutter.plugin_slug}}/requirements.txt diff --git a/README.md b/README.md index 2a993420..b227e519 100644 --- a/README.md +++ b/README.md @@ -344,8 +344,7 @@ Native plugins can be run in a separate python virtual environment (venv) to pre config: # Plugin config dict passed to the plugin constructor class_name: "test_plugin.plugin.TestPlugin" - venv_path: "plugins/test_plugin/.venv" - requirements_file: "plugins/test_plugin/requirements.txt" + requirements_file: "requirements.txt" # essentially the plugin folder hosting the plugin relative to the project root script_path: "plugins" ``` diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index 187e3fa3..01ec6856 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -40,7 +40,13 @@ def __init__(self, config: PluginConfig) -> None: self.implementation = "Python" self.comm = None self.script_path: str = config.config["script_path"] - self.cache_dir = Path.home() / ".cpex" / "venv_cache" + path = Path(self.config.config.get("script_path")).resolve() + class_root = self.config.config.get("class_name").split('.')[0] + cache_root = path / class_root + self.plugin_path = cache_root + if not cache_root.exists(): + raise RuntimeError("plugin script_path does not exist") + self.cache_dir = cache_root / ".cpex" / "venv_cache" self.cache_dir.mkdir(parents=True, exist_ok=True) def _compute_requirements_hash(self, requirements_file: str) -> str: @@ -188,9 +194,6 @@ async def create_venv( print(f" source {venv_path}/bin/activate # On Unix/macOS") print(f" {venv_path}\\Scripts\\activate # On Windows") - # Save cache metadata if requirements file is provided - if requirements_file: - self._save_cache_metadata(venv_path, requirements_file) except Exception as e: print(f"✗ Error creating virtual environment: {e}") @@ -202,11 +205,11 @@ async def initialize(self) -> None: """Initialize the plugin's venv environment with caching support.""" # ensure the config is validated path = Path(self.config.config.get("script_path")).resolve() - if not os.path.exists(path): - raise FileNotFoundError(f"script_path not found: {path}") + if not os.path.exists(self.plugin_path): + raise FileNotFoundError(f"plugin path not found: {self.plugin_path}") - venv_path = self.config.config["venv_path"] - requirements_file = self.config.config["requirements_file"] + venv_path = self.plugin_path / ".venv" + requirements_file = self.plugin_path / self.config.config["requirements_file"] # Create venv with caching support self.venv = await self.create_venv(venv_path=venv_path, requirements_file=requirements_file, use_cache=True) @@ -223,6 +226,8 @@ async def initialize(self) -> None: else: logger.info("Using cached venv, skipping requirements installation") + + async def cleanup(self) -> None: """Cleanup resources, including stopping the worker process.""" if self.comm: diff --git a/cpex/framework/isolated/worker.py b/cpex/framework/isolated/worker.py index 24696991..a25fa1f6 100644 --- a/cpex/framework/isolated/worker.py +++ b/cpex/framework/isolated/worker.py @@ -88,11 +88,9 @@ async def process_task(task_data): executor = PluginExecutor(None, 30) # retrieve the context context = task_data.get("context") - # ^^ may need to json.loads(context) before passing it to PluginContext below vv plugin_context = PluginContext( state=context.get("state"), global_context=context.get("global_context"), metadata=context.get("metadata") ) - # global_context = context.get("global_context") result = await executor.execute_plugin( hook_ref, payload=task_data.get("payload"), local_context=plugin_context, violations_as_exceptions=False ) diff --git a/cpex/templates/isolated/cookiecutter.json b/cpex/templates/isolated/cookiecutter.json new file mode 100644 index 00000000..1016c1e9 --- /dev/null +++ b/cpex/templates/isolated/cookiecutter.json @@ -0,0 +1,8 @@ +{ + "plugin_name": "MyFilter", + "plugin_slug": "{{ cookiecutter.plugin_name|lower|replace(' ', '_')|replace('-', '_') }}", + "version": "0.1.0", + "author": "Your Name", + "email": "your@email.com", + "description": "A filter plugin" +} diff --git a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/README.md b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/README.md new file mode 100644 index 00000000..fb0a2a5c --- /dev/null +++ b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/README.md @@ -0,0 +1,10 @@ +# {{cookiecutter.plugin_name}} for ContextForge + +{{cookiecutter.description}}. + + +## Installation + +1. Copy .env.example .env +2. Enable plugins in `.env` +3. Add the plugin configuration to `plugins/config.yaml`: diff --git a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/__init__.py b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/__init__.py new file mode 100644 index 00000000..11905acf --- /dev/null +++ b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/__init__.py @@ -0,0 +1,7 @@ +"""ContextForge {{cookiecutter.plugin_name}} Plugin - {{cookiecutter.description}}. + +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: {{cookiecutter.author}} + +""" diff --git a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/config.yaml b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/config.yaml new file mode 100644 index 00000000..08959d22 --- /dev/null +++ b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/config.yaml @@ -0,0 +1,39 @@ +plugins: + - name: "{{ cookiecutter.plugin_name }}" + {% set class_parts = cookiecutter.plugin_name.replace(' ', '_').replace('-','_').split('_') -%} + {% if class_parts|length > 1 -%} + {% set class_name = class_parts|map('capitalize')|join -%} + {% else -%} + {% set class_name = class_parts|join -%} + {% endif -%} + kind: "isolated_venv" + description: "{{ cookiecutter.description }}" + version: "{{ cookiecutter.version }}" + author: "{{ cookiecutter.author }}" + hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] + tags: ["plugin"] + mode: "enforce" # enforce | permissive | disabled + priority: 150 + conditions: + # Apply to specific tools/servers + - server_ids: [] # Apply to all servers + tenant_ids: [] # Apply to all tenants + config: + # Plugin config dict passed to the plugin constructor + # Plugin config dict passed to the plugin constructor + class_name: "{{ cookiecutter.plugin_slug }}.plugin.{{ class_name }}" + requirements_file: "requirements.txt" + # essentially the plugin folder hosting the plugin + script_path: "{{ cookiecutter.plugin_slug }}" + +# Plugin directories to scan +plugin_dirs: + - "{{ cookiecutter.plugin_slug }}" + +# Global plugin settings +plugin_settings: + parallel_execution_within_band: true + plugin_timeout: 30 + fail_on_plugin_error: false + enable_plugin_api: true + plugin_health_check_interval: 60 diff --git a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml new file mode 100644 index 00000000..e943d5cd --- /dev/null +++ b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml @@ -0,0 +1,9 @@ +description: "{{cookiecutter.description}}" +author: "{{cookiecutter.author}}" +version: "{{cookiecutter.version}}" +available_hooks: + - "prompt_pre_hook" + - "prompt_post_hook" + - "tool_pre_hook" + - "tool_post_hook" +default_configs: diff --git a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin.py b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin.py new file mode 100644 index 00000000..5c2db6c6 --- /dev/null +++ b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin.py @@ -0,0 +1,90 @@ +"""{{ cookiecutter.description }}. + +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: {{ cookiecutter.author }} + +This module loads configurations for plugins. +""" + +# First-Party +from cpex.framework import ( + Plugin, + PluginConfig, + PluginContext, + PromptPosthookPayload, + PromptPosthookResult, + PromptPrehookPayload, + PromptPrehookResult, + ToolPostInvokePayload, + ToolPostInvokeResult, + ToolPreInvokePayload, + ToolPreInvokeResult, +) + + +{% set class_parts = cookiecutter.plugin_name.replace(' ', '_').replace('-','_').split('_') -%} +{% if class_parts|length > 1 -%} +{% set class_name = class_parts|map('capitalize')|join -%} +{% else -%} +{% set class_name = class_parts|join -%} +{% endif -%} +class {{ class_name }}(Plugin): + """{{ cookiecutter.description }}.""" + + def __init__(self, config: PluginConfig): + """Entry init block for plugin. + + Args: + logger: logger that the skill can make use of + config: the skill configuration + """ + super().__init__(config) + + async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: + """The plugin hook run before a prompt is retrieved and rendered. + + Args: + payload: The prompt payload to be analyzed. + context: contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the prompt can proceed. + """ + return PromptPrehookResult(continue_processing=True) + + async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: + """Plugin hook run after a prompt is rendered. + + Args: + payload: The prompt payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the prompt can proceed. + """ + return PromptPosthookResult(continue_processing=True) + + async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: + """Plugin hook run before a tool is invoked. + + Args: + payload: The tool payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the tool can proceed. + """ + return ToolPreInvokeResult(continue_processing=True) + + async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: + """Plugin hook run after a tool is invoked. + + Args: + payload: The tool result payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the tool result should proceed. + """ + return ToolPostInvokeResult(continue_processing=True) diff --git a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/requirements.txt b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/requirements.txt new file mode 100644 index 00000000..d35182aa --- /dev/null +++ b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/requirements.txt @@ -0,0 +1 @@ +cpex>=0.1.0.dev10 \ No newline at end of file diff --git a/cpex/tools/cli.py b/cpex/tools/cli.py index 0d4645ec..071ac01b 100644 --- a/cpex/tools/cli.py +++ b/cpex/tools/cli.py @@ -151,7 +151,7 @@ def bootstrap( Args: destination: The directory in which to bootstrap the plugin project. template_url: The URL to the plugins cookiecutter template. - template_type: Plugin template type (native or external). + template_type: Plugin template type (native, external or isolated). vcs_ref: The version control system tag/branch/commit to use for the template. no_input: Use defaults without prompting. dry_run: Run but do not make any changes. diff --git a/tests/unit/cpex/framework/isolated/test_client.py b/tests/unit/cpex/framework/isolated/test_client.py index 8c4002e0..1919408b 100644 --- a/tests/unit/cpex/framework/isolated/test_client.py +++ b/tests/unit/cpex/framework/isolated/test_client.py @@ -29,8 +29,8 @@ class TestIsolatedVenvPlugin: def mock_config(self, tmp_path): """Create a mock plugin configuration.""" venv_path = tmp_path / ".venv" - script_path = "tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt" - requirements_file = tmp_path / "requirements.txt" + script_path = "tests/unit/cpex/fixtures/plugins/isolated" + requirements_file = tmp_path / "test_plugin" / "requirements.txt" config_dict = { "name": "test_plugin", diff --git a/tests/unit/cpex/framework/isolated/test_integration.py b/tests/unit/cpex/framework/isolated/test_integration.py index ca0cb4d9..c1055dd0 100644 --- a/tests/unit/cpex/framework/isolated/test_integration.py +++ b/tests/unit/cpex/framework/isolated/test_integration.py @@ -44,8 +44,7 @@ def integration_config_path(self, tmp_path): hooks: ["tool_pre_invoke"] config: class_name: "test_plugin.TestPlugin" - venv_path: "xplugins/test_plugin/.venv" - requirements_file: "xplugins/test_plugin/requirements.txt" + requirements_file: "requirements.txt" script_path: "xplugins" """ config_file = tmp_path / "test_config.yaml" @@ -99,8 +98,7 @@ async def test_isolated_plugin_full_lifecycle(self, mock_create_venv, mock_comm_ "hooks": ["tool_pre_invoke"], "config": { "class_name": "test_plugin.TestPlugin", - "venv_path": str(tmp_path / ".venv"), - "requirements_file": "tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt", + "requirements_file": "requirements.txt", "script_path": "tests/unit/cpex/fixtures/plugins/isolated" } } @@ -143,9 +141,8 @@ async def test_isolated_plugin_error_handling(self, tmp_path): "hooks": ["tool_pre_invoke"], "config": { "class_name": "test_plugin.TestPlugin", - "venv_path": str(tmp_path / ".venv"), - "requirements_file": str(tmp_path / "requirements.txt"), - "script_path": str(tmp_path / "plugins") + "requirements_file": "requirements.txt", + "script_path": "tests/unit/cpex/fixtures/plugins/isolated" } } config = PluginConfig(**config_dict) @@ -182,8 +179,7 @@ async def test_isolated_plugin_with_multiple_hooks( "hooks": ["tool_pre_invoke", "tool_post_invoke", "prompt_pre_fetch", "prompt_post_fetch"], "config": { "class_name": "test_plugin.TestPlugin", - "venv_path": str(tmp_path / ".venv"), - "requirements_file": "tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt", + "requirements_file": "requirements.txt", "script_path": "tests/unit/cpex/fixtures/plugins/isolated" } } @@ -272,8 +268,7 @@ def capture_task(script_path, task_data): "hooks": ["tool_pre_invoke"], "config": { "class_name": "test_plugin.TestPlugin", - "venv_path": str(tmp_path / ".venv"), - "requirements_file": "tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt", + "requirements_file": "requirements.txt", "script_path": "tests/unit/cpex/fixtures/plugins/isolated" } } @@ -341,8 +336,7 @@ async def test_isolated_plugin_violation_handling( "hooks": ["tool_pre_invoke"], "config": { "class_name": "test_plugin.TestPlugin", - "venv_path": str(tmp_path / ".venv"), - "requirements_file": "tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt", + "requirements_file": "requirements.txt", "script_path": "tests/unit/cpex/fixtures/plugins/isolated" } } diff --git a/tests/unit/cpex/framework/isolated/test_venv_comm.py b/tests/unit/cpex/framework/isolated/test_venv_comm.py index 891d80f0..86b83f21 100644 --- a/tests/unit/cpex/framework/isolated/test_venv_comm.py +++ b/tests/unit/cpex/framework/isolated/test_venv_comm.py @@ -11,6 +11,7 @@ import subprocess import sys from pathlib import Path +from queue import Queue from unittest.mock import MagicMock, Mock, patch import pytest @@ -450,4 +451,300 @@ def test_is_alive_process_terminated(self, mock_thread, mock_popen, communicator assert communicator.is_alive() is False + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_read_stderr_with_output(self, mock_thread, mock_popen, communicator): + """Test _read_stderr method reads and logs stderr output.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + + # Mock stderr with some output + mock_stderr = MagicMock() + mock_stderr.readline.side_effect = [ + "Error line 1\n", + "Error line 2\n", + "", # Empty string signals end + ] + mock_process.stderr = mock_stderr + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + # Start worker to trigger stderr thread + communicator.start_worker("test_script.py") + + # Manually call _read_stderr to test it + communicator._read_stderr() + + # Verify readline was called + assert mock_stderr.readline.call_count >= 1 + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_read_stderr_with_exception(self, mock_thread, mock_popen, communicator): + """Test _read_stderr handles exceptions gracefully.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + + # Mock stderr that raises exception + mock_stderr = MagicMock() + mock_stderr.readline.side_effect = Exception("Read error") + mock_process.stderr = mock_stderr + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + # Should not raise exception + communicator._read_stderr() + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_read_stderr_no_process(self, mock_thread, mock_popen, communicator): + """Test _read_stderr returns early when no process.""" + # Don't start worker, just call _read_stderr + communicator._read_stderr() + # Should return without error + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_read_responses_with_valid_json(self, mock_thread, mock_popen, communicator): + """Test _read_responses processes valid JSON responses.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stderr = MagicMock() + + # Mock stdout with valid JSON responses + mock_stdout = MagicMock() + mock_stdout.readline.side_effect = [ + '{"status": "ok", "request_id": "test-123"}\n', + "", # Empty string signals end + ] + mock_process.stdout = mock_stdout + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + # Create a response queue for the request + communicator.response_queues["test-123"] = Queue() + + communicator.start_worker("test_script.py") + + # Manually call _read_responses + communicator._read_responses() + + # Verify the response was queued + assert not communicator.response_queues["test-123"].empty() + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_read_responses_with_empty_lines(self, mock_thread, mock_popen, communicator): + """Test _read_responses skips empty lines.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stderr = MagicMock() + + # Mock stdout with empty lines + mock_stdout = MagicMock() + mock_stdout.readline.side_effect = [ + "\n", + " \n", + '{"status": "ok", "request_id": "test-456"}\n', + "", + ] + mock_process.stdout = mock_stdout + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.response_queues["test-456"] = Queue() + communicator.start_worker("test_script.py") + communicator._read_responses() + + assert not communicator.response_queues["test-456"].empty() + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_read_responses_with_invalid_json(self, mock_thread, mock_popen, communicator): + """Test _read_responses handles invalid JSON gracefully.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stderr = MagicMock() + + # Mock stdout with invalid JSON + mock_stdout = MagicMock() + mock_stdout.readline.side_effect = [ + "not valid json\n", + '{"incomplete": \n', + "", + ] + mock_process.stdout = mock_stdout + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + # Should not raise exception + communicator._read_responses() + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_read_responses_without_request_id(self, mock_thread, mock_popen, communicator): + """Test _read_responses handles responses without request_id.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stderr = MagicMock() + + # Mock stdout with response missing request_id + mock_stdout = MagicMock() + mock_stdout.readline.side_effect = [ + '{"status": "ok", "data": "test"}\n', + "", + ] + mock_process.stdout = mock_stdout + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + # Should log warning but not crash + communicator._read_responses() + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_read_responses_unknown_request_id(self, mock_thread, mock_popen, communicator): + """Test _read_responses handles unknown request_id.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stderr = MagicMock() + + # Mock stdout with unknown request_id + mock_stdout = MagicMock() + mock_stdout.readline.side_effect = [ + '{"status": "ok", "request_id": "unknown-999"}\n', + "", + ] + mock_process.stdout = mock_stdout + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + # Should log warning but not crash + communicator._read_responses() + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_read_responses_with_exception(self, mock_thread, mock_popen, communicator): + """Test _read_responses handles exceptions during reading.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stderr = MagicMock() + + # Mock stdout that raises exception + mock_stdout = MagicMock() + mock_stdout.readline.side_effect = Exception("Read error") + mock_process.stdout = mock_stdout + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + # Should handle exception and set running to False + communicator._read_responses() + assert communicator.running is False + + @patch("subprocess.Popen") + @patch("threading.Thread") + @patch("cpex.framework.isolated.venv_comm.Queue") + def test_send_task_stdin_not_available(self, mock_queue_class, mock_thread, mock_popen, communicator): + """Test send_task when stdin is not available.""" + task_data = {"task_type": "test"} + + mock_process = MagicMock() + mock_process.stdin = None # stdin not available + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + mock_queue_instance = MagicMock() + mock_queue_class.return_value = mock_queue_instance + + communicator.start_worker("test_script.py") + + with pytest.raises(RuntimeError, match="Worker process stdin not available"): + communicator.send_task("test_script.py", task_data) + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_stop_worker_send_shutdown_exception(self, mock_thread, mock_popen, communicator): + """Test stop_worker handles exception when sending shutdown signal.""" + mock_process = MagicMock() + mock_stdin = MagicMock() + mock_stdin.write.side_effect = Exception("Write failed") + mock_process.stdin = mock_stdin + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.wait.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread_instance.is_alive.return_value = False + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + # Should handle exception gracefully + communicator.stop_worker() + + assert communicator.running is False + assert communicator.process is None + + def test_del_method(self, communicator): + """Test __del__ method calls stop_worker.""" + communicator.running = True + communicator.process = MagicMock() + + # Call __del__ directly + communicator.__del__() + + # Should have stopped the worker + assert communicator.running is False + + def test_del_method_no_running_attribute(self): + """Test __del__ handles missing running attribute.""" + # Create instance without proper initialization + comm = object.__new__(VenvProcessCommunicator) + + # Should not raise exception + comm.__del__() + + # Made with Bob From 6291a30c5e297782a54f781b384e5708655681ec Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 12 Mar 2026 18:57:20 -0400 Subject: [PATCH 28/60] chore: update test fixture Signed-off-by: habeck --- tests/unit/cpex/fixtures/configs/isolated_plugin.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/unit/cpex/fixtures/configs/isolated_plugin.yaml b/tests/unit/cpex/fixtures/configs/isolated_plugin.yaml index 3b803d31..a486ce72 100644 --- a/tests/unit/cpex/fixtures/configs/isolated_plugin.yaml +++ b/tests/unit/cpex/fixtures/configs/isolated_plugin.yaml @@ -28,8 +28,7 @@ plugins: config: # Plugin config dict passed to the plugin constructor class_name: "test_plugin.plugin.TestPlugin" - venv_path: "tests/unit/cpex/fixtures/plugins/isolated/test_plugin/.venv" - requirements_file: "tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt" + requirements_file: "requirements.txt" # essentially the plugin folder hosting the plugin script_path: "tests/unit/cpex/fixtures/plugins/isolated" From 45e13cbcbbad0f5fa3a28e5afe0da6a3ac5ac0d1 Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 12 Mar 2026 21:01:37 -0400 Subject: [PATCH 29/60] chore: lint fix Signed-off-by: habeck --- cpex/framework/isolated/client.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index 01ec6856..fb393599 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -41,7 +41,7 @@ def __init__(self, config: PluginConfig) -> None: self.comm = None self.script_path: str = config.config["script_path"] path = Path(self.config.config.get("script_path")).resolve() - class_root = self.config.config.get("class_name").split('.')[0] + class_root = self.config.config.get("class_name").split(".")[0] cache_root = path / class_root self.plugin_path = cache_root if not cache_root.exists(): @@ -194,7 +194,6 @@ async def create_venv( print(f" source {venv_path}/bin/activate # On Unix/macOS") print(f" {venv_path}\\Scripts\\activate # On Windows") - except Exception as e: print(f"✗ Error creating virtual environment: {e}") raise e @@ -226,8 +225,6 @@ async def initialize(self) -> None: else: logger.info("Using cached venv, skipping requirements installation") - - async def cleanup(self) -> None: """Cleanup resources, including stopping the worker process.""" if self.comm: From 418fb63a0cc80afec8f3334b6e219eaa5e1491c0 Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 12 Mar 2026 21:09:36 -0400 Subject: [PATCH 30/60] chore: remove unused var Signed-off-by: habeck --- cpex/framework/isolated/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index fb393599..5fe510d2 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -203,7 +203,6 @@ async def create_venv( async def initialize(self) -> None: """Initialize the plugin's venv environment with caching support.""" # ensure the config is validated - path = Path(self.config.config.get("script_path")).resolve() if not os.path.exists(self.plugin_path): raise FileNotFoundError(f"plugin path not found: {self.plugin_path}") From cf833447d92a13480f070567b5d035d2169f092d Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 26 Mar 2026 15:32:35 -0400 Subject: [PATCH 31/60] chore: Per PR review, do not include system site-backages, use symlinks, and remove self.venv assignment Signed-off-by: habeck --- cpex/framework/isolated/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index 5fe510d2..4df837e6 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -177,9 +177,9 @@ async def create_venv( # Create the EnvBuilder with common options builder = venv.EnvBuilder( - system_site_packages=True, # Don't include system site-packages + system_site_packages=False, # Don't include system site-packages clear=False, # Don't clear existing venv if it exists - symlinks=False, # Use symlinks (recommended on Unix-like systems) + symlinks=True, # Use symlinks (recommended on Unix-like systems) upgrade=False, # Don't upgrade existing venv with_pip=True, # Install pip in the venv prompt=None, # Use default prompt (directory name) @@ -210,7 +210,7 @@ async def initialize(self) -> None: requirements_file = self.plugin_path / self.config.config["requirements_file"] # Create venv with caching support - self.venv = await self.create_venv(venv_path=venv_path, requirements_file=requirements_file, use_cache=True) + await self.create_venv(venv_path=venv_path, requirements_file=requirements_file, use_cache=True) self.comm = VenvProcessCommunicator(venv_path) From 54713cdface37cf02ccd70ebabae4178c00fb20c Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 26 Mar 2026 15:44:34 -0400 Subject: [PATCH 32/60] chore: correct location in header Signed-off-by: habeck --- cpex/framework/isolated/worker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpex/framework/isolated/worker.py b/cpex/framework/isolated/worker.py index a25fa1f6..ace9775c 100644 --- a/cpex/framework/isolated/worker.py +++ b/cpex/framework/isolated/worker.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""Location: ./cpex/framework/isolated/server.py +"""Location: ./cpex/framework/isolated/worker.py Copyright 2025 SPDX-License-Identifier: Apache-2.0 Authors: Ted Habeck, Fred Araujo From cfa204d72ed3c076bf82400fb72d8a1a62ae9e21 Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 26 Mar 2026 16:10:32 -0400 Subject: [PATCH 33/60] fix: use try/catch rather than return code from subprocess.check_call per PR review. Signed-off-by: habeck --- cpex/framework/isolated/venv_comm.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cpex/framework/isolated/venv_comm.py b/cpex/framework/isolated/venv_comm.py index 653d5e02..ada64e0e 100644 --- a/cpex/framework/isolated/venv_comm.py +++ b/cpex/framework/isolated/venv_comm.py @@ -62,9 +62,10 @@ def install_requirements(self, requirements_file: str) -> None: """ requirements_path = Path(requirements_file) if requirements_path.exists(): - rc = subprocess.check_call([self.python_executable, "-m", "pip", "install", "-r", requirements_file]) - if rc != 0: - raise Exception(f"Failed to install requirements from {requirements_file}") + try: + subprocess.check_call([self.python_executable, "-m", "pip", "install", "-r", requirements_file]) + except Exception as e: + raise RuntimeError(f"Failed to install requirements from {requirements_file}") from e def start_worker(self, script_path: str) -> None: """ @@ -103,7 +104,7 @@ def start_worker(self, script_path: str) -> None: except Exception as e: self.running = False - raise RuntimeError(f"Failed to start worker process: {e}") + raise RuntimeError(f"Failed to start worker process: {e}") from e def _read_stderr(self) -> None: """Background thread to read and log stderr from worker process.""" From 17b9375f3d87ae1fd4c2245b8a7655ce7550d529 Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 26 Mar 2026 16:23:58 -0400 Subject: [PATCH 34/60] chore: update unit test for venv_comm.py Signed-off-by: habeck --- tests/unit/cpex/framework/isolated/test_venv_comm.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/cpex/framework/isolated/test_venv_comm.py b/tests/unit/cpex/framework/isolated/test_venv_comm.py index 86b83f21..27237206 100644 --- a/tests/unit/cpex/framework/isolated/test_venv_comm.py +++ b/tests/unit/cpex/framework/isolated/test_venv_comm.py @@ -113,9 +113,10 @@ def test_install_requirements_failure(self, mock_check_call, communicator, tmp_p requirements_file = tmp_path / "requirements.txt" requirements_file.write_text("invalid-package-name-xyz\n") - mock_check_call.return_value = 1 + # Simulate subprocess.check_call raising an exception + mock_check_call.side_effect = subprocess.CalledProcessError(1, "pip install") - with pytest.raises(Exception, match="Failed to install requirements"): + with pytest.raises(RuntimeError, match=f"Failed to install requirements from {requirements_file}"): communicator.install_requirements(str(requirements_file)) def test_install_requirements_nonexistent_file(self, communicator): From c9f809f382a7abb90eafbfb99baa9844aeacd040 Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 26 Mar 2026 16:56:08 -0400 Subject: [PATCH 35/60] chore: use orjson only per PR review Signed-off-by: habeck --- cpex/framework/isolated/venv_comm.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cpex/framework/isolated/venv_comm.py b/cpex/framework/isolated/venv_comm.py index ada64e0e..a014743a 100644 --- a/cpex/framework/isolated/venv_comm.py +++ b/cpex/framework/isolated/venv_comm.py @@ -6,7 +6,6 @@ Authors: Fred Araujo, Ted Habeck """ -import json import logging import os import subprocess @@ -138,7 +137,7 @@ def _read_responses(self) -> None: continue try: - response = json.loads(line) + response = orjson.loads(line) request_id = response.get("request_id") if request_id: @@ -151,7 +150,7 @@ def _read_responses(self) -> None: else: logger.warning("Received response without request_id: %s", line[:100]) - except json.JSONDecodeError as e: + except orjson.JSONDecodeError as e: logger.error("Failed to decode response: %s, line: %s", e, line[:200]) except Exception as e: @@ -228,7 +227,7 @@ def stop_worker(self) -> None: if self.process.stdin: try: shutdown_task = {"task_type": "shutdown", "request_id": "shutdown"} - self.process.stdin.write(json.dumps(shutdown_task) + "\n") + self.process.stdin.write(orjson.dumps(shutdown_task).decode() + "\n") self.process.stdin.flush() except Exception as e: logger.warning("Failed to send shutdown signal: %s", e) From f78b4be9d3dc7c50b723fd8ca32dc4d88dbef496 Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 26 Mar 2026 16:58:29 -0400 Subject: [PATCH 36/60] chore: just raise per PR rev iew Signed-off-by: habeck --- cpex/framework/isolated/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index 4df837e6..a218aacc 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -196,7 +196,7 @@ async def create_venv( except Exception as e: print(f"✗ Error creating virtual environment: {e}") - raise e + raise # Called by plugins/framework/loader/plugin.py load_and_instantiate_plugin() # The plugins/framework/manager.py class (PluginManager) loads and registers the plugin From ff09147907327f2dfab7c6ff72d8441303f1edce Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 26 Mar 2026 17:07:05 -0400 Subject: [PATCH 37/60] chore: do not use f-strings in logger calls. Signed-off-by: habeck --- cpex/framework/isolated/client.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index a218aacc..7bb2ad66 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -97,12 +97,12 @@ def _is_venv_cache_valid(self, venv_path: str, requirements_file: str) -> bool: # Check if venv directory exists if not venv_path_obj.exists(): - logger.debug(f"Venv path does not exist: {venv_path}") + logger.debug("Venv path does not exist: %s", venv_path) return False # Check if metadata file exists if not metadata_path.exists(): - logger.debug(f"Metadata file does not exist: {metadata_path}") + logger.debug("Metadata file does not exist: %s", metadata_path) return False try: @@ -116,14 +116,14 @@ def _is_venv_cache_valid(self, venv_path: str, requirements_file: str) -> bool: # Compare hashes cached_hash = metadata.get("requirements_hash") if cached_hash != current_hash: - logger.info(f"Requirements changed. Cached hash: {cached_hash}, Current hash: {current_hash}") + logger.info("Requirements changed. Cached hash: %s, Current hash: %s",cached_hash, current_hash) return False - logger.info(f"Valid venv cache found for {venv_path}") + logger.info("Valid venv cache found for %s", venv_path) return True except (json.JSONDecodeError, KeyError) as e: - logger.warning(f"Error reading cache metadata: {e}") + logger.warning("Error reading cache metadata: %s", str(e)) return False def _save_cache_metadata(self, venv_path: str, requirements_file: str) -> None: @@ -146,7 +146,7 @@ def _save_cache_metadata(self, venv_path: str, requirements_file: str) -> None: with open(metadata_path, "w", encoding="utf8") as f: json.dump(metadata, f, indent=2) - logger.info(f"Saved cache metadata to {metadata_path}") + logger.info("Saved cache metadata to %s", metadata_path) async def create_venv( self, venv_path: str = ".venv", requirements_file: Optional[str] = None, use_cache: bool = True @@ -162,13 +162,13 @@ async def create_venv( # Check if we can use cached venv if use_cache and requirements_file and self._is_venv_cache_valid(venv_path, requirements_file): - logger.info(f"Using cached virtual environment at: {venv_path_obj.resolve()}") + logger.info("Using cached virtual environment at: %s", venv_path_obj.resolve()) print(f"✓ Using cached virtual environment at: {venv_path_obj.resolve()}") return # If cache is invalid or not using cache, remove existing venv if venv_path_obj.exists(): - logger.info(f"Removing existing venv at {venv_path}") + logger.info("Removing existing venv at %s", venv_path) shutil.rmtree(venv_path_obj) # Check Python version From f8aabf7fc5031695b91f946c3e13076ca18b3f7e Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 26 Mar 2026 17:16:14 -0400 Subject: [PATCH 38/60] chore: pep 8 compliant method Signed-off-by: habeck --- cpex/framework/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cpex/framework/models.py b/cpex/framework/models.py index 04b26c4f..bda9c8c5 100644 --- a/cpex/framework/models.py +++ b/cpex/framework/models.py @@ -1309,13 +1309,13 @@ def get_safe_config(self) -> str: PluginConfig: A new PluginConfig instance with only data fields. """ # Get the JSON-safe dictionary representation - safe_data = self.toJSON() + safe_data = self.to_json() # Create a new PluginConfig instance from the safe data # This will run validators again, but the resulting object will be clean return orjson.dumps(safe_data).decode() - def toJSON(self) -> dict[str, Any]: + def to_json(self) -> dict[str, Any]: """Serialize the PluginConfig object to a JSON-compatible dictionary. This method converts the PluginConfig instance to a dictionary that can be From 4925b314fcf20a7fc5083638dd97f20b6b994717 Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 26 Mar 2026 17:20:14 -0400 Subject: [PATCH 39/60] chore: move import of re to top-level import Signed-off-by: habeck --- cpex/framework/models.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/cpex/framework/models.py b/cpex/framework/models.py index bda9c8c5..65a68d42 100644 --- a/cpex/framework/models.py +++ b/cpex/framework/models.py @@ -12,6 +12,7 @@ # Standard import logging import os +import re from enum import Enum, StrEnum from pathlib import Path from typing import Any, Generic, List, Optional, Self, TypeVar, Union @@ -1655,9 +1656,6 @@ def validate_pypi_package(cls, pypi_package: str | None) -> str | None: if not pypi_package.strip(): raise ValueError("PyPI package name cannot be empty or whitespace") - # Check for valid characters - import re - if not re.match(r"^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$", pypi_package): raise ValueError( f"Invalid PyPI package name '{pypi_package}'. " @@ -1690,8 +1688,6 @@ def validate_git_repository(cls, git_repository: str | None) -> str | None: raise ValueError("Git repository URL cannot be empty or whitespace") # Support common Git URL formats: https://, git://, ssh://, git@ - import re - git_url_pattern = re.compile( r"^(https?://|git://|git@)" r"[a-zA-Z0-9._-]+" r"(/|:)" r"[a-zA-Z0-9._/-]+" r"(\.git)?$" ) @@ -1729,8 +1725,6 @@ def validate_git_branch_tag_commit(cls, git_branch_tag_commit: str | None) -> st # Git refs can contain alphanumeric characters, hyphens, underscores, slashes, and periods # Commit hashes are typically 7-40 hex characters - import re - if not re.match(r"^[a-zA-Z0-9._/-]+$", git_branch_tag_commit): raise ValueError( f"Invalid Git branch/tag/commit '{git_branch_tag_commit}'. " @@ -1770,8 +1764,6 @@ def validate_version_constraint(cls, version_constraint: str | None) -> str | No raise ValueError("Version constraint cannot be empty or whitespace") # Validate semantic version constraint format (e.g., ">=1.0.0,<2.0.0", "~=1.2.3", "==1.0.0") - import re - # Pattern for version specifiers: operator + optional space + version number version_pattern = re.compile(r"^(==|!=|<=|>=|<|>|~=|===)\s*" r"\d+(\.\d+)*" r"([a-zA-Z0-9._-]*)?$") From 9e9913abeae465013721c2bdde06410db0c8e30f Mon Sep 17 00:00:00 2001 From: habeck Date: Fri, 27 Mar 2026 11:16:43 -0400 Subject: [PATCH 40/60] enh: use cached plugin if the config and module_path are unchanged (single plugin initialization call). Signed-off-by: habeck --- cpex/framework/isolated/client.py | 2 +- cpex/framework/isolated/worker.py | 79 ++++++++++++++----- .../cpex/framework/isolated/test_worker.py | 32 ++++---- 3 files changed, 78 insertions(+), 35 deletions(-) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index 7bb2ad66..39eff68c 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -116,7 +116,7 @@ def _is_venv_cache_valid(self, venv_path: str, requirements_file: str) -> bool: # Compare hashes cached_hash = metadata.get("requirements_hash") if cached_hash != current_hash: - logger.info("Requirements changed. Cached hash: %s, Current hash: %s",cached_hash, current_hash) + logger.info("Requirements changed. Cached hash: %s, Current hash: %s", cached_hash, current_hash) return False logger.info("Valid venv cache found for %s", venv_path) diff --git a/cpex/framework/isolated/worker.py b/cpex/framework/isolated/worker.py index ace9775c..aff27363 100644 --- a/cpex/framework/isolated/worker.py +++ b/cpex/framework/isolated/worker.py @@ -9,6 +9,7 @@ """ import asyncio +import hashlib import importlib.metadata import json import logging @@ -28,6 +29,39 @@ logger = logging.getLogger(__name__) +class TaskProcessor: + """ + A Caching task processor that only reloads the plugin if the config has changed. + """ + + config_hash: str + module_path_hash: str + hook_ref: HookRef | None + executor: PluginExecutor | None + + def __init__(self) -> None: + """Initialize defaults.""" + hasher = hashlib.sha256() + hasher.update(b"") + self.config_hash = hasher.hexdigest() + self.module_path_hash = self.config_hash + self.hook_ref = None + self.executor = None + + def compute_hash(self, json_config_or_module_path: str): + """Compute the hash of the supplied string""" + hasher = hashlib.sha256() + hasher.update(json_config_or_module_path.encode()) + return hasher.hexdigest() + + def initialize(self, hook_ref: HookRef, executor: PluginExecutor, json_config: str, module_path: str): + """Assign locals, and compute hashes.""" + self.hook_ref = hook_ref + self.executor = executor + self.config_hash = self.compute_hash(json_config_or_module_path=json_config) + self.module_path_hash = self.compute_hash(json_config_or_module_path=module_path) + + def get_environment_info(): """Get information about current Python environment.""" return { @@ -55,7 +89,7 @@ def get_proper_config(name, module_path): return None -async def process_task(task_data): +async def process_task(task_data, tp: TaskProcessor): """Process the task received from parent.""" task_type = task_data.get("task_type") @@ -71,28 +105,33 @@ async def process_task(task_data): json_config = task_data.get("config") config_raw = json.loads(json_config) module_path: str = task_data.get("script_path") - sys.path.append(str(Path(module_path).resolve())) - config = get_proper_config(config_raw.get("name"), module_path) - hook_type = task_data.get(HOOK_TYPE) - cls_name: str = task_data.get("class_name") - mod_name, n_cls_name = parse_class_name(cls_name) - module: ModuleType = importlib.import_module(mod_name) - # cool, we found the module, and verified it implemented the hook type. - class_ = getattr(module, n_cls_name) - plugin_type = cast(Type[Plugin], class_) - plugin = plugin_type(config) - await plugin.initialize() - # now invoke the hook - plugin_ref = PluginRef(plugin) - hook_ref = HookRef(hook_type, plugin_ref) - executor = PluginExecutor(None, 30) + if tp.module_path_hash != tp.compute_hash(module_path) or tp.config_hash != tp.compute_hash(json_config): + sys.path.append(str(Path(module_path).resolve())) + config = get_proper_config(config_raw.get("name"), module_path) + hook_type = task_data.get(HOOK_TYPE) + cls_name: str = task_data.get("class_name") + mod_name, n_cls_name = parse_class_name(cls_name) + module: ModuleType = importlib.import_module(mod_name) + # cool, we found the module, and verified it implemented the hook type. + class_ = getattr(module, n_cls_name) + plugin_type = cast(Type[Plugin], class_) + plugin = plugin_type(config) + await plugin.initialize() + # now invoke the hook + plugin_ref = PluginRef(plugin) + hook_ref = HookRef(hook_type, plugin_ref) + executor = PluginExecutor(None, 30) + tp.initialize(hook_ref=hook_ref, executor=executor, json_config=json_config, module_path=module_path) # retrieve the context context = task_data.get("context") plugin_context = PluginContext( state=context.get("state"), global_context=context.get("global_context"), metadata=context.get("metadata") ) - result = await executor.execute_plugin( - hook_ref, payload=task_data.get("payload"), local_context=plugin_context, violations_as_exceptions=False + result = await tp.executor.execute_plugin( + hookref=tp.hook_ref, + payload=task_data.get("payload"), + local_context=plugin_context, + violations_as_exceptions=False, ) return result @@ -102,6 +141,8 @@ async def main(): logger.info("Worker process started, waiting for tasks...") try: + # Cache the plugin so that it only has to be initialized once + tp = TaskProcessor() # Continuously read and process tasks while True: try: @@ -125,7 +166,7 @@ async def main(): break # Process the task - response = await process_task(task_data) + response = await process_task(task_data, tp) # Serialize response if response: diff --git a/tests/unit/cpex/framework/isolated/test_worker.py b/tests/unit/cpex/framework/isolated/test_worker.py index 0aa43678..ba687e23 100644 --- a/tests/unit/cpex/framework/isolated/test_worker.py +++ b/tests/unit/cpex/framework/isolated/test_worker.py @@ -16,7 +16,7 @@ import pytest -from cpex.framework.isolated.worker import get_environment_info, get_proper_config, main, process_task +from cpex.framework.isolated.worker import TaskProcessor, get_environment_info, get_proper_config, main, process_task class TestWorkerFunctions: @@ -81,9 +81,10 @@ def test_get_proper_config_no_plugins(self, mock_load_config): @pytest.mark.asyncio async def test_process_task_info(self): """Test processing info task.""" - task_data = {"task_type": "info"} - - result = await process_task(task_data) + config_dict = {"name": "test_plugin", "kind": "isolated_venv", "config": {}} + task_data = {"task_type": "info", "config": json.dumps(config_dict)} + tp = TaskProcessor() + result = await process_task(task_data, tp) assert result["status"] == "success" assert "environment" in result @@ -132,8 +133,8 @@ async def test_process_task_load_and_run_hook_success(self, mock_executor_class, "payload": {"name": "test_tool", "args": {}}, "context": {"state": {}, "global_context": {"request_id": "req-123"}, "metadata": {}}, } - - result = await process_task(task_data) + tp = TaskProcessor() + result = await process_task(task_data, tp=tp) assert result is not None mock_plugin_instance.initialize.assert_called_once() @@ -155,10 +156,10 @@ async def test_process_task_load_and_run_hook_no_config(self, mock_get_config): "payload": {}, "context": {"state": {}, "global_context": {}, "metadata": {}}, } - + tp = TaskProcessor() # Should raise an error or return None with pytest.raises((AttributeError, TypeError)): - await process_task(task_data) + await process_task(task_data, tp) @pytest.mark.asyncio @patch("cpex.framework.isolated.worker.get_proper_config") @@ -180,9 +181,9 @@ async def test_process_task_load_and_run_hook_import_error(self, mock_import, mo "payload": {}, "context": {"state": {}, "global_context": {}, "metadata": {}}, } - + tp = TaskProcessor() with pytest.raises(ImportError): - await process_task(task_data) + await process_task(task_data, tp) @pytest.mark.asyncio @patch("cpex.framework.isolated.worker.get_proper_config") @@ -214,6 +215,7 @@ async def test_process_task_with_different_hook_types(self, mock_executor_class, mock_executor_class.return_value = mock_executor hook_types = ["tool_pre_invoke", "tool_post_invoke", "prompt_pre_fetch", "prompt_post_fetch"] + tp = TaskProcessor() for hook_type in hook_types: config_dict = {"name": "test_plugin", "kind": "isolated_venv"} @@ -226,17 +228,16 @@ async def test_process_task_with_different_hook_types(self, mock_executor_class, "payload": {}, "context": {"state": {}, "global_context": {"request_id": "req-123"}, "metadata": {}}, } - - result = await process_task(task_data) + result = await process_task(task_data, tp) assert result is not None @pytest.mark.asyncio async def test_process_task_unknown_task_type(self): """Test processing task with unknown task type.""" task_data = {"task_type": "unknown_type"} - + tp = TaskProcessor() # Should return None or handle gracefully - result = await process_task(task_data) + result = await process_task(task_data, tp) assert result is None @pytest.mark.asyncio @@ -282,8 +283,9 @@ async def test_process_task_with_metadata(self, mock_executor_class, mock_import "metadata": {"custom": "data"}, }, } + tp = TaskProcessor() - result = await process_task(task_data) + result = await process_task(task_data, tp) assert result is not None # Verify executor was called with proper context From 14a0893e920e7b1e3b00a1943f0fdd169e5cb388 Mon Sep 17 00:00:00 2001 From: habeck Date: Fri, 27 Mar 2026 12:26:17 -0400 Subject: [PATCH 41/60] fix: TOCTOU race Signed-off-by: habeck --- cpex/framework/isolated/client.py | 12 ++++++------ tests/unit/cpex/framework/isolated/test_client.py | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index 39eff68c..98fad4aa 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -150,7 +150,7 @@ def _save_cache_metadata(self, venv_path: str, requirements_file: str) -> None: async def create_venv( self, venv_path: str = ".venv", requirements_file: Optional[str] = None, use_cache: bool = True - ) -> None: + ) -> bool: """Create a new venv environment with caching support. Args: @@ -164,7 +164,7 @@ async def create_venv( if use_cache and requirements_file and self._is_venv_cache_valid(venv_path, requirements_file): logger.info("Using cached virtual environment at: %s", venv_path_obj.resolve()) print(f"✓ Using cached virtual environment at: {venv_path_obj.resolve()}") - return + return False # If cache is invalid or not using cache, remove existing venv if venv_path_obj.exists(): @@ -193,7 +193,7 @@ async def create_venv( print("\nTo activate the virtual environment:") print(f" source {venv_path}/bin/activate # On Unix/macOS") print(f" {venv_path}\\Scripts\\activate # On Windows") - + return True except Exception as e: print(f"✗ Error creating virtual environment: {e}") raise @@ -210,14 +210,14 @@ async def initialize(self) -> None: requirements_file = self.plugin_path / self.config.config["requirements_file"] # Create venv with caching support - await self.create_venv(venv_path=venv_path, requirements_file=requirements_file, use_cache=True) + new_venv = await self.create_venv(venv_path=venv_path, requirements_file=requirements_file, use_cache=True) self.comm = VenvProcessCommunicator(venv_path) # Only install requirements if venv was newly created or cache was invalid # Check if we need to install requirements - if not self._is_venv_cache_valid(venv_path, requirements_file): - logger.info("Installing requirements in new venv") + if new_venv: + logger.info("Installing requirements in venv") self.comm.install_requirements(requirements_file) # Save metadata after successful installation self._save_cache_metadata(venv_path, requirements_file) diff --git a/tests/unit/cpex/framework/isolated/test_client.py b/tests/unit/cpex/framework/isolated/test_client.py index 1919408b..dc4a37d7 100644 --- a/tests/unit/cpex/framework/isolated/test_client.py +++ b/tests/unit/cpex/framework/isolated/test_client.py @@ -100,7 +100,7 @@ async def test_create_venv_failure(self, mock_builder_class, plugin, tmp_path): @patch.object(IsolatedVenvPlugin, "create_venv") async def test_initialize_success(self, mock_create_venv, mock_comm_class, plugin): """Test successful plugin initialization.""" - mock_create_venv.return_value = None + mock_create_venv.return_value = True mock_comm = MagicMock() mock_comm_class.return_value = mock_comm @@ -641,7 +641,7 @@ async def test_initialize_with_valid_cache(self, mock_cache_valid, mock_create_v async def test_initialize_with_invalid_cache(self, mock_save_metadata, mock_cache_valid, mock_create_venv, mock_comm_class, plugin): """Test initialize with invalid cache installs requirements.""" mock_cache_valid.return_value = False - mock_create_venv.return_value = None + mock_create_venv.return_value = True mock_comm = MagicMock() mock_comm_class.return_value = mock_comm From fd99a7068567e7b212ed29637ccaddf5f26471f9 Mon Sep 17 00:00:00 2001 From: habeck Date: Fri, 27 Mar 2026 15:42:32 -0400 Subject: [PATCH 42/60] sec: prevent directory traversal in module_path Signed-off-by: habeck --- cpex/framework/isolated/client.py | 2 +- cpex/framework/isolated/worker.py | 21 +++++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index 98fad4aa..2aaea90f 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -46,7 +46,7 @@ def __init__(self, config: PluginConfig) -> None: self.plugin_path = cache_root if not cache_root.exists(): raise RuntimeError("plugin script_path does not exist") - self.cache_dir = cache_root / ".cpex" / "venv_cache" + self.cache_dir: Path = cache_root / ".cpex" / "venv_cache" self.cache_dir.mkdir(parents=True, exist_ok=True) def _compute_requirements_hash(self, requirements_file: str) -> str: diff --git a/cpex/framework/isolated/worker.py b/cpex/framework/isolated/worker.py index aff27363..1c49a658 100644 --- a/cpex/framework/isolated/worker.py +++ b/cpex/framework/isolated/worker.py @@ -36,8 +36,8 @@ class TaskProcessor: config_hash: str module_path_hash: str - hook_ref: HookRef | None - executor: PluginExecutor | None + hook_ref: HookRef + executor: PluginExecutor def __init__(self) -> None: """Initialize defaults.""" @@ -45,8 +45,6 @@ def __init__(self) -> None: hasher.update(b"") self.config_hash = hasher.hexdigest() self.module_path_hash = self.config_hash - self.hook_ref = None - self.executor = None def compute_hash(self, json_config_or_module_path: str): """Compute the hash of the supplied string""" @@ -105,8 +103,19 @@ async def process_task(task_data, tp: TaskProcessor): json_config = task_data.get("config") config_raw = json.loads(json_config) module_path: str = task_data.get("script_path") + + # Security: Validate module_path to prevent directory traversal + if ".." in module_path or module_path.startswith("/"): + raise ValueError(f"Invalid module_path: '{module_path}' - path traversal not allowed") + if tp.module_path_hash != tp.compute_hash(module_path) or tp.config_hash != tp.compute_hash(json_config): - sys.path.append(str(Path(module_path).resolve())) + # pull the resolved plugin path and only add the module path if it has the same root + path = Path(module_path).resolve() + resolved_module_path = str(path) + if path.exists(): + sys.path.append(resolved_module_path) + else: + raise RuntimeError(f"plugin module_path '{resolved_module_path}' does not exist.") config = get_proper_config(config_raw.get("name"), module_path) hook_type = task_data.get(HOOK_TYPE) cls_name: str = task_data.get("class_name") @@ -128,7 +137,7 @@ async def process_task(task_data, tp: TaskProcessor): state=context.get("state"), global_context=context.get("global_context"), metadata=context.get("metadata") ) result = await tp.executor.execute_plugin( - hookref=tp.hook_ref, + hook_ref=tp.hook_ref, payload=task_data.get("payload"), local_context=plugin_context, violations_as_exceptions=False, From 87b4fd6926a0cab5b3dce8a8e827768e6004222d Mon Sep 17 00:00:00 2001 From: habeck Date: Fri, 27 Mar 2026 18:28:49 -0400 Subject: [PATCH 43/60] fix: use the system config file (PLUGINS_CONFIG_FILE) for syspath update (Consistent with how the PluginManager works). Signed-off-by: habeck --- cpex/framework/isolated/client.py | 14 +++++--- cpex/framework/isolated/venv_comm.py | 1 + cpex/framework/isolated/worker.py | 23 +++++++------ .../{{cookiecutter.plugin_slug}}/config.yaml | 2 -- .../cpex/framework/isolated/test_client.py | 5 +-- .../framework/isolated/test_integration.py | 5 +-- .../cpex/framework/isolated/test_worker.py | 32 +++++++++++-------- 7 files changed, 42 insertions(+), 40 deletions(-) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index 2aaea90f..66789bf8 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -17,6 +17,7 @@ import shutil import sys import venv +import yaml from pathlib import Path from typing_extensions import Any, Optional @@ -26,6 +27,7 @@ from cpex.framework.errors import PluginError, convert_exception_to_error from cpex.framework.hooks.registry import get_hook_registry from cpex.framework.isolated.venv_comm import VenvProcessCommunicator +from cpex.framework.loader.config import ConfigLoader from cpex.framework.models import PluginConfig, PluginContext, PluginErrorModel, PluginPayload, PluginResult logger = logging.getLogger(__name__) @@ -39,13 +41,16 @@ def __init__(self, config: PluginConfig) -> None: super().__init__(config) self.implementation = "Python" self.comm = None - self.script_path: str = config.config["script_path"] - path = Path(self.config.config.get("script_path")).resolve() + tmp = os.environ.get("PLUGINS_CONFIG_FILE","plugins/config.yaml") + plugin_loader_config = ConfigLoader.load_config(Path(tmp).resolve(), use_jinja=False) + self.plugin_dirs = plugin_loader_config.plugin_dirs + # use the first plugin dir specified in the plugin configuration file. + path = Path(self.plugin_dirs[0]).resolve() class_root = self.config.config.get("class_name").split(".")[0] cache_root = path / class_root self.plugin_path = cache_root if not cache_root.exists(): - raise RuntimeError("plugin script_path does not exist") + raise RuntimeError(f"plugin path does not exist: {str(cache_root)}") self.cache_dir: Path = cache_root / ".cpex" / "venv_cache" self.cache_dir.mkdir(parents=True, exist_ok=True) @@ -269,7 +274,6 @@ def _build_hook_task(self, hook_type: str, payload: PluginPayload, context: Plug Task dictionary ready for transmission """ # Cache config lookups - script_path = self.config.config["script_path"] class_name = self.config.config["class_name"] safe_config = self.config.get_safe_config() @@ -279,7 +283,7 @@ def _build_hook_task(self, hook_type: str, payload: PluginPayload, context: Plug return { "task_type": "load_and_run_hook", - "script_path": script_path, + "plugin_dirs": self.plugin_dirs, "class_name": class_name, "config": safe_config, HOOK_TYPE: hook_type, diff --git a/cpex/framework/isolated/venv_comm.py b/cpex/framework/isolated/venv_comm.py index a014743a..f5dbf69e 100644 --- a/cpex/framework/isolated/venv_comm.py +++ b/cpex/framework/isolated/venv_comm.py @@ -87,6 +87,7 @@ def start_worker(self, script_path: str) -> None: text=True, bufsize=1, # Line buffered cwd=os.getcwd(), + env={'PLUGINS_CONFIG_FILE': os.environ.get("PLUGINS_CONFIG_FILE", "plugins/config.yaml")} ) self.running = True diff --git a/cpex/framework/isolated/worker.py b/cpex/framework/isolated/worker.py index 1c49a658..a4386984 100644 --- a/cpex/framework/isolated/worker.py +++ b/cpex/framework/isolated/worker.py @@ -13,11 +13,12 @@ import importlib.metadata import json import logging +import os import platform import sys from pathlib import Path from types import ModuleType -from typing import Type, cast +from typing import List, Type, cast from cpex.framework.base import HookRef, Plugin, PluginRef from cpex.framework.constants import HOOK_TYPE @@ -70,11 +71,12 @@ def get_environment_info(): } -def get_proper_config(name, module_path): +def get_proper_config(name): """ Load a config which has all it's proper decorations """ - plugin_loader_config = ConfigLoader.load_config(Path(f"{module_path}/config.yaml").resolve(), use_jinja=False) + plugin_config_file = os.environ.get("PLUGINS_CONFIG_FILE", "plugins/config.yaml") + plugin_loader_config = ConfigLoader.load_config(Path(plugin_config_file).resolve(), use_jinja=False) plugins: list[dict] = [] config = None if plugin_loader_config.plugins: @@ -102,21 +104,18 @@ async def process_task(task_data, tp: TaskProcessor): # relative path from project root. json_config = task_data.get("config") config_raw = json.loads(json_config) - module_path: str = task_data.get("script_path") - - # Security: Validate module_path to prevent directory traversal - if ".." in module_path or module_path.startswith("/"): - raise ValueError(f"Invalid module_path: '{module_path}' - path traversal not allowed") - - if tp.module_path_hash != tp.compute_hash(module_path) or tp.config_hash != tp.compute_hash(json_config): - # pull the resolved plugin path and only add the module path if it has the same root + module_paths: List[str] = task_data.get("plugin_dirs") + for module_path in module_paths: path = Path(module_path).resolve() resolved_module_path = str(path) if path.exists(): sys.path.append(resolved_module_path) else: raise RuntimeError(f"plugin module_path '{resolved_module_path}' does not exist.") - config = get_proper_config(config_raw.get("name"), module_path) + + if tp.config_hash != tp.compute_hash(json_config): + # pull the resolved plugin path and only add the module path if it has the same root + config = get_proper_config(config_raw.get("name")) hook_type = task_data.get(HOOK_TYPE) cls_name: str = task_data.get("class_name") mod_name, n_cls_name = parse_class_name(cls_name) diff --git a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/config.yaml b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/config.yaml index 08959d22..cd793837 100644 --- a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/config.yaml +++ b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/config.yaml @@ -23,8 +23,6 @@ plugins: # Plugin config dict passed to the plugin constructor class_name: "{{ cookiecutter.plugin_slug }}.plugin.{{ class_name }}" requirements_file: "requirements.txt" - # essentially the plugin folder hosting the plugin - script_path: "{{ cookiecutter.plugin_slug }}" # Plugin directories to scan plugin_dirs: diff --git a/tests/unit/cpex/framework/isolated/test_client.py b/tests/unit/cpex/framework/isolated/test_client.py index dc4a37d7..d592e28c 100644 --- a/tests/unit/cpex/framework/isolated/test_client.py +++ b/tests/unit/cpex/framework/isolated/test_client.py @@ -29,7 +29,6 @@ class TestIsolatedVenvPlugin: def mock_config(self, tmp_path): """Create a mock plugin configuration.""" venv_path = tmp_path / ".venv" - script_path = "tests/unit/cpex/fixtures/plugins/isolated" requirements_file = tmp_path / "test_plugin" / "requirements.txt" config_dict = { @@ -43,7 +42,6 @@ def mock_config(self, tmp_path): "class_name": "test_plugin.TestPlugin", "venv_path": venv_path, "requirements_file": requirements_file, - "script_path": script_path } } @@ -63,11 +61,10 @@ def plugin_context(self): ) return plugin_context - def test_init(self, plugin, mock_config): + def test_init(self, plugin): """Test plugin initialization.""" assert plugin.name == "test_plugin" assert plugin.implementation == "Python" - assert plugin.script_path == mock_config.config["script_path"] assert plugin.comm is None @pytest.mark.asyncio diff --git a/tests/unit/cpex/framework/isolated/test_integration.py b/tests/unit/cpex/framework/isolated/test_integration.py index c1055dd0..917b1e83 100644 --- a/tests/unit/cpex/framework/isolated/test_integration.py +++ b/tests/unit/cpex/framework/isolated/test_integration.py @@ -67,10 +67,7 @@ async def test_plugin_manager_with_isolated_plugin( # Create manager manager = PluginManager(integration_config_path) - # This will fail because the config path doesn't exist in the test environment - # but we can test the structure - with pytest.raises(RuntimeError): - await manager.initialize() + await manager.initialize() @pytest.mark.asyncio @patch("cpex.framework.isolated.client.VenvProcessCommunicator") diff --git a/tests/unit/cpex/framework/isolated/test_worker.py b/tests/unit/cpex/framework/isolated/test_worker.py index ba687e23..df16976f 100644 --- a/tests/unit/cpex/framework/isolated/test_worker.py +++ b/tests/unit/cpex/framework/isolated/test_worker.py @@ -22,6 +22,14 @@ class TestWorkerFunctions: """Test suite for worker.py functions.""" + @pytest.fixture + def mock_plugin_dirs(self, tmp_path): + """ensure that the plugins directory exists""" + plugin_dirs = tmp_path / "plugins" + tmp = Path(plugin_dirs) + tmp.mkdir(parents=True, exist_ok=True) + return [str(plugin_dirs.resolve())] + def test_get_environment_info(self): """Test getting environment information.""" info = get_environment_info() @@ -48,7 +56,7 @@ def test_get_proper_config_found(self, mock_load_config): mock_config.plugins = [mock_plugin] mock_load_config.return_value = mock_config - result = get_proper_config("test_plugin", "plugins") + result = get_proper_config("test_plugin") assert result is not None assert result.name == "test_plugin" @@ -63,7 +71,7 @@ def test_get_proper_config_not_found(self, mock_load_config): mock_config.plugins = [mock_plugin] mock_load_config.return_value = mock_config - result = get_proper_config("test_plugin", "plugins") + result = get_proper_config("test_plugin") assert result is None @@ -74,7 +82,7 @@ def test_get_proper_config_no_plugins(self, mock_load_config): mock_config.plugins = None mock_load_config.return_value = mock_config - result = get_proper_config("test_plugin", "plugins") + result = get_proper_config("test_plugin") assert result is None @@ -95,7 +103,7 @@ async def test_process_task_info(self): @patch("cpex.framework.isolated.worker.get_proper_config") @patch("cpex.framework.isolated.worker.importlib.import_module") @patch("cpex.framework.isolated.worker.PluginExecutor") - async def test_process_task_load_and_run_hook_success(self, mock_executor_class, mock_import, mock_get_config): + async def test_process_task_load_and_run_hook_success(self, mock_executor_class, mock_import, mock_get_config, mock_plugin_dirs): """Test processing load_and_run_hook task successfully.""" # Setup mock config mock_config = MagicMock() @@ -127,7 +135,7 @@ async def test_process_task_load_and_run_hook_success(self, mock_executor_class, task_data = { "task_type": "load_and_run_hook", "config": json.dumps(config_dict), - "script_path": "plugins", + "plugin_dirs": mock_plugin_dirs, "class_name": "test_plugin.TestPlugin", "hook_type": "tool_pre_invoke", "payload": {"name": "test_tool", "args": {}}, @@ -150,7 +158,6 @@ async def test_process_task_load_and_run_hook_no_config(self, mock_get_config): task_data = { "task_type": "load_and_run_hook", "config": json.dumps(config_dict), - "script_path": "plugins", "class_name": "test_plugin.TestPlugin", "hook_type": "tool_pre_invoke", "payload": {}, @@ -164,7 +171,7 @@ async def test_process_task_load_and_run_hook_no_config(self, mock_get_config): @pytest.mark.asyncio @patch("cpex.framework.isolated.worker.get_proper_config") @patch("cpex.framework.isolated.worker.importlib.import_module") - async def test_process_task_load_and_run_hook_import_error(self, mock_import, mock_get_config): + async def test_process_task_load_and_run_hook_import_error(self, mock_import, mock_get_config, mock_plugin_dirs): """Test processing load_and_run_hook task with import error.""" mock_config = MagicMock() mock_get_config.return_value = mock_config @@ -175,8 +182,8 @@ async def test_process_task_load_and_run_hook_import_error(self, mock_import, mo task_data = { "task_type": "load_and_run_hook", "config": json.dumps(config_dict), - "script_path": "plugins", "class_name": "test_plugin.TestPlugin", + "plugin_dirs": mock_plugin_dirs, "hook_type": "tool_pre_invoke", "payload": {}, "context": {"state": {}, "global_context": {}, "metadata": {}}, @@ -189,7 +196,7 @@ async def test_process_task_load_and_run_hook_import_error(self, mock_import, mo @patch("cpex.framework.isolated.worker.get_proper_config") @patch("cpex.framework.isolated.worker.importlib.import_module") @patch("cpex.framework.isolated.worker.PluginExecutor") - async def test_process_task_with_different_hook_types(self, mock_executor_class, mock_import, mock_get_config): + async def test_process_task_with_different_hook_types(self, mock_executor_class, mock_import, mock_get_config, mock_plugin_dirs): """Test processing tasks with different hook types.""" # Setup mocks mock_config = MagicMock() @@ -222,7 +229,7 @@ async def test_process_task_with_different_hook_types(self, mock_executor_class, task_data = { "task_type": "load_and_run_hook", "config": json.dumps(config_dict), - "script_path": "plugins", + "plugin_dirs": mock_plugin_dirs, "class_name": "test_plugin.TestPlugin", "hook_type": hook_type, "payload": {}, @@ -244,7 +251,7 @@ async def test_process_task_unknown_task_type(self): @patch("cpex.framework.isolated.worker.get_proper_config") @patch("cpex.framework.isolated.worker.importlib.import_module") @patch("cpex.framework.isolated.worker.PluginExecutor") - async def test_process_task_with_metadata(self, mock_executor_class, mock_import, mock_get_config): + async def test_process_task_with_metadata(self, mock_executor_class, mock_import, mock_get_config, mock_plugin_dirs): """Test processing task with metadata in context.""" mock_config = MagicMock() mock_get_config.return_value = mock_config @@ -273,8 +280,8 @@ async def test_process_task_with_metadata(self, mock_executor_class, mock_import task_data = { "task_type": "load_and_run_hook", "config": json.dumps(config_dict), - "script_path": "plugins", "class_name": "test_plugin.TestPlugin", + "plugin_dirs": mock_plugin_dirs, "hook_type": "tool_pre_invoke", "payload": {"name": "test_tool"}, "context": { @@ -402,7 +409,6 @@ async def test_main_with_load_and_run_hook_task(self, mock_process_task, mock_pr task_data = { "task_type": "load_and_run_hook", "config": json.dumps(config_dict), - "script_path": "plugins", "class_name": "test_plugin.TestPlugin", "hook_type": "tool_pre_invoke", "payload": {"name": "test_tool"}, From 296f22e009de66b880c569793b22bc7c2c83c7b7 Mon Sep 17 00:00:00 2001 From: habeck Date: Fri, 27 Mar 2026 18:33:07 -0400 Subject: [PATCH 44/60] chore: lint fix Signed-off-by: habeck --- cpex/framework/isolated/client.py | 3 +-- cpex/framework/isolated/venv_comm.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index 66789bf8..5e7e1353 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -17,7 +17,6 @@ import shutil import sys import venv -import yaml from pathlib import Path from typing_extensions import Any, Optional @@ -41,7 +40,7 @@ def __init__(self, config: PluginConfig) -> None: super().__init__(config) self.implementation = "Python" self.comm = None - tmp = os.environ.get("PLUGINS_CONFIG_FILE","plugins/config.yaml") + tmp = os.environ.get("PLUGINS_CONFIG_FILE", "plugins/config.yaml") plugin_loader_config = ConfigLoader.load_config(Path(tmp).resolve(), use_jinja=False) self.plugin_dirs = plugin_loader_config.plugin_dirs # use the first plugin dir specified in the plugin configuration file. diff --git a/cpex/framework/isolated/venv_comm.py b/cpex/framework/isolated/venv_comm.py index f5dbf69e..0712c6f4 100644 --- a/cpex/framework/isolated/venv_comm.py +++ b/cpex/framework/isolated/venv_comm.py @@ -87,7 +87,7 @@ def start_worker(self, script_path: str) -> None: text=True, bufsize=1, # Line buffered cwd=os.getcwd(), - env={'PLUGINS_CONFIG_FILE': os.environ.get("PLUGINS_CONFIG_FILE", "plugins/config.yaml")} + env={"PLUGINS_CONFIG_FILE": os.environ.get("PLUGINS_CONFIG_FILE", "plugins/config.yaml")}, ) self.running = True From d8c64a317d7986b65a3a0db121a5407f26b19189 Mon Sep 17 00:00:00 2001 From: habeck Date: Fri, 27 Mar 2026 19:17:19 -0400 Subject: [PATCH 45/60] fix: return unsupported task type when no task_type match Signed-off-by: habeck --- cpex/framework/isolated/worker.py | 7 ++++++- tests/unit/cpex/framework/isolated/test_worker.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/cpex/framework/isolated/worker.py b/cpex/framework/isolated/worker.py index a4386984..6beb0a64 100644 --- a/cpex/framework/isolated/worker.py +++ b/cpex/framework/isolated/worker.py @@ -142,6 +142,11 @@ async def process_task(task_data, tp: TaskProcessor): violations_as_exceptions=False, ) return result + return { + "status": "error", + "message": "task type not supported.", + "request_id": task_data.get("request_id", "unknown") if "task_data" in locals() else "unknown", + } async def main(): @@ -179,7 +184,7 @@ async def main(): # Serialize response if response: serializable_response = response.model_dump(mode="json") - else: + else: # none case should be a failure rather than success. serializable_response = {"status": "success"} # Add request_id to response diff --git a/tests/unit/cpex/framework/isolated/test_worker.py b/tests/unit/cpex/framework/isolated/test_worker.py index df16976f..16911e54 100644 --- a/tests/unit/cpex/framework/isolated/test_worker.py +++ b/tests/unit/cpex/framework/isolated/test_worker.py @@ -245,7 +245,7 @@ async def test_process_task_unknown_task_type(self): tp = TaskProcessor() # Should return None or handle gracefully result = await process_task(task_data, tp) - assert result is None + assert result == {'message': 'task type not supported.', 'request_id': 'unknown', 'status': 'error'} @pytest.mark.asyncio @patch("cpex.framework.isolated.worker.get_proper_config") From d3f08af6054dadc4bd00080817458f837f821ba7 Mon Sep 17 00:00:00 2001 From: habeck Date: Mon, 30 Mar 2026 15:26:11 -0400 Subject: [PATCH 46/60] enh: updated isolated_venv manifest cookiecutter Signed-off-by: habeck --- .../plugin-manifest.yaml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml index e943d5cd..4614398f 100644 --- a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml +++ b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml @@ -1,4 +1,12 @@ +name: "{{ cookiecutter.plugin_name }}" +{% set class_parts = cookiecutter.plugin_name.replace(' ', '_').replace('-','_').split('_') -%} +{% if class_parts|length > 1 -%} +{% set class_name = class_parts|map('capitalize')|join -%} +{% else -%} +{% set class_name = class_parts|join -%} +{% endif -%} description: "{{cookiecutter.description}}" +kind: "isolated_venv" author: "{{cookiecutter.author}}" version: "{{cookiecutter.version}}" available_hooks: @@ -6,4 +14,10 @@ available_hooks: - "prompt_post_hook" - "tool_pre_hook" - "tool_post_hook" -default_configs: +default_config: + # Plugin config dict passed to the plugin constructor + class_name: "{{ cookiecutter.plugin_slug }}.plugin.{{ class_name }}" + requirements_file: "requirements.txt" +monorepo: + package_source: contextforge-plugins-python/{{ cookiecutter.plugin_slug }} +# package_info: \ No newline at end of file From 3d35f8ba5dc184a947691a98eac79d944a72d817 Mon Sep 17 00:00:00 2001 From: habeck Date: Tue, 7 Apr 2026 16:45:16 -0400 Subject: [PATCH 47/60] chore: Validate plugin_dirs entries against an allowlist Signed-off-by: habeck --- cpex/framework/isolated/worker.py | 7 ++++++- .../cpex/framework/isolated/test_worker.py | 18 +++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/cpex/framework/isolated/worker.py b/cpex/framework/isolated/worker.py index 6beb0a64..19439161 100644 --- a/cpex/framework/isolated/worker.py +++ b/cpex/framework/isolated/worker.py @@ -23,6 +23,7 @@ from cpex.framework.base import HookRef, Plugin, PluginRef from cpex.framework.constants import HOOK_TYPE from cpex.framework.loader.config import ConfigLoader +from cpex.framework.loader.plugin import ALLOWED_PLUGIN_DIRS from cpex.framework.manager import PluginExecutor from cpex.framework.models import PluginContext from cpex.framework.utils import parse_class_name @@ -109,7 +110,11 @@ async def process_task(task_data, tp: TaskProcessor): path = Path(module_path).resolve() resolved_module_path = str(path) if path.exists(): - sys.path.append(resolved_module_path) + if resolved_module_path not in sys.path: + if resolved_module_path.startswith(tuple(ALLOWED_PLUGIN_DIRS)): + sys.path.append(resolved_module_path) + else: + raise RuntimeError(f"plugin module_path '{resolved_module_path}' not in allowed plugin dirs.") else: raise RuntimeError(f"plugin module_path '{resolved_module_path}' does not exist.") diff --git a/tests/unit/cpex/framework/isolated/test_worker.py b/tests/unit/cpex/framework/isolated/test_worker.py index 16911e54..fc636131 100644 --- a/tests/unit/cpex/framework/isolated/test_worker.py +++ b/tests/unit/cpex/framework/isolated/test_worker.py @@ -7,10 +7,10 @@ Unit tests for worker.py functions. """ -import asyncio import json +import os +import shutil import sys -from io import StringIO from pathlib import Path from unittest.mock import AsyncMock, MagicMock, Mock, patch @@ -23,13 +23,18 @@ class TestWorkerFunctions: """Test suite for worker.py functions.""" @pytest.fixture - def mock_plugin_dirs(self, tmp_path): + def mock_plugin_dirs(self): """ensure that the plugins directory exists""" - plugin_dirs = tmp_path / "plugins" - tmp = Path(plugin_dirs) + plugin_dirs = Path(os.getcwd()) / "tmp" / "plugins" + tmp = plugin_dirs tmp.mkdir(parents=True, exist_ok=True) return [str(plugin_dirs.resolve())] + def cleanup_mock_plugin_dirs(self): + """Test cleanup for the mock plugin directories.""" + plugin_root = Path(os.getcwd()) / "tmp" + shutil.rmtree(plugin_root.resolve()) + def test_get_environment_info(self): """Test getting environment information.""" info = get_environment_info() @@ -147,6 +152,7 @@ async def test_process_task_load_and_run_hook_success(self, mock_executor_class, assert result is not None mock_plugin_instance.initialize.assert_called_once() mock_executor.execute_plugin.assert_called_once() + self.cleanup_mock_plugin_dirs() @pytest.mark.asyncio @patch("cpex.framework.isolated.worker.get_proper_config") @@ -237,6 +243,7 @@ async def test_process_task_with_different_hook_types(self, mock_executor_class, } result = await process_task(task_data, tp) assert result is not None + self.cleanup_mock_plugin_dirs() @pytest.mark.asyncio async def test_process_task_unknown_task_type(self): @@ -298,6 +305,7 @@ async def test_process_task_with_metadata(self, mock_executor_class, mock_import # Verify executor was called with proper context call_args = mock_executor.execute_plugin.call_args assert call_args is not None + self.cleanup_mock_plugin_dirs() class TestMainFunction: From 0719e9e2ee1c031b0189722775d92ea30018b2fc Mon Sep 17 00:00:00 2001 From: habeck Date: Wed, 8 Apr 2026 15:49:40 -0400 Subject: [PATCH 48/60] sec: prevent directory traversal, ensure requirements_file stays within plugin_path, replace print with logger. Signed-off-by: habeck --- cpex/framework/isolated/client.py | 43 ++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index 5e7e1353..410378e1 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -166,8 +166,7 @@ async def create_venv( # Check if we can use cached venv if use_cache and requirements_file and self._is_venv_cache_valid(venv_path, requirements_file): - logger.info("Using cached virtual environment at: %s", venv_path_obj.resolve()) - print(f"✓ Using cached virtual environment at: {venv_path_obj.resolve()}") + logger.info("✓ Using cached virtual environment at: %s", venv_path_obj.resolve()) return False # If cache is invalid or not using cache, remove existing venv @@ -177,7 +176,7 @@ async def create_venv( # Check Python version python_version = sys.version_info - print(f"Current Python version: {python_version.major}.{python_version.minor}.{python_version.micro}") + logger.info(f"Current Python version: {python_version.major}.{python_version.minor}.{python_version.micro}") # Create the EnvBuilder with common options builder = venv.EnvBuilder( @@ -190,16 +189,16 @@ async def create_venv( ) # Create the virtual environment - print(f"\nCreating virtual environment at: {venv_path_obj.resolve()}") + logger.info(f"\nCreating virtual environment at: {venv_path_obj.resolve()}") try: builder.create(venv_path) - print("✓ Virtual environment created successfully!") - print("\nTo activate the virtual environment:") - print(f" source {venv_path}/bin/activate # On Unix/macOS") - print(f" {venv_path}\\Scripts\\activate # On Windows") + logger.info("✓ Virtual environment created successfully!") + logger.info("\nTo activate the virtual environment:") + logger.info(f" source {venv_path}/bin/activate # On Unix/macOS") + logger.info(f" {venv_path}\\Scripts\\activate # On Windows") return True except Exception as e: - print(f"✗ Error creating virtual environment: {e}") + logger.error(f"✗ Error creating virtual environment: {e}") raise # Called by plugins/framework/loader/plugin.py load_and_instantiate_plugin() @@ -211,7 +210,31 @@ async def initialize(self) -> None: raise FileNotFoundError(f"plugin path not found: {self.plugin_path}") venv_path = self.plugin_path / ".venv" - requirements_file = self.plugin_path / self.config.config["requirements_file"] + + # Prevent directory traversal: ensure requirements_file stays within plugin_path + requirements_file_input = self.config.config["requirements_file"] + + # Handle both relative and absolute paths + if isinstance(requirements_file_input, Path): + requirements_file = requirements_file_input + else: + requirements_file = Path(requirements_file_input) + + # If it's a relative path, resolve it relative to plugin_path + if not requirements_file.is_absolute(): + requirements_file = (self.plugin_path / requirements_file).resolve() + else: + # If absolute, resolve it to normalize + requirements_file = requirements_file.resolve() + + # Validate that the resolved path is within plugin_path (security check) + try: + requirements_file.relative_to(self.plugin_path.resolve()) + except ValueError: + raise RuntimeError( + f"Invalid requirements_file path: {requirements_file_input}. " + f"Path must be within plugin directory: {self.plugin_path}" + ) # Create venv with caching support new_venv = await self.create_venv(venv_path=venv_path, requirements_file=requirements_file, use_cache=True) From 55a7675123977ac53cc45547a7a9da187e3782a8 Mon Sep 17 00:00:00 2001 From: habeck Date: Wed, 8 Apr 2026 16:08:55 -0400 Subject: [PATCH 49/60] chore: updated tests Signed-off-by: habeck --- .../cpex/framework/isolated/test_client.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/unit/cpex/framework/isolated/test_client.py b/tests/unit/cpex/framework/isolated/test_client.py index d592e28c..fb9bd045 100644 --- a/tests/unit/cpex/framework/isolated/test_client.py +++ b/tests/unit/cpex/framework/isolated/test_client.py @@ -28,8 +28,15 @@ class TestIsolatedVenvPlugin: @pytest.fixture def mock_config(self, tmp_path): """Create a mock plugin configuration.""" + # Create the test_plugin directory structure + plugin_dir = tmp_path / "test_plugin" + plugin_dir.mkdir(parents=True, exist_ok=True) + + # Create requirements.txt file + requirements_file = plugin_dir / "requirements.txt" + requirements_file.write_text("pytest>=7.0.0\n") + venv_path = tmp_path / ".venv" - requirements_file = tmp_path / "test_plugin" / "requirements.txt" config_dict = { "name": "test_plugin", @@ -41,16 +48,19 @@ def mock_config(self, tmp_path): "config": { "class_name": "test_plugin.TestPlugin", "venv_path": venv_path, - "requirements_file": requirements_file, + "requirements_file": "requirements.txt", # Use relative path } } return PluginConfig(**config_dict) @pytest.fixture - def plugin(self, mock_config): + def plugin(self, mock_config, tmp_path): """Create an IsolatedVenvPlugin instance.""" - return IsolatedVenvPlugin(mock_config) + plugin_instance = IsolatedVenvPlugin(mock_config) + # Override plugin_path to use tmp_path for testing + plugin_instance.plugin_path = tmp_path / "test_plugin" + return plugin_instance @pytest.fixture def plugin_context(self): From 60fd9682d40ffd6b26b6435b36da3043036efa49 Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 9 Apr 2026 14:20:19 -0400 Subject: [PATCH 50/60] 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 --- cpex/framework/isolated/client.py | 15 +- cpex/framework/loader/plugin.py | 9 +- cpex/framework/models.py | 12 +- .../cpex/framework/isolated/test_client.py | 2 +- .../framework/isolated/test_integration.py | 132 ++++++++++-------- 5 files changed, 87 insertions(+), 83 deletions(-) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index 410378e1..d52e6101 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -26,7 +26,6 @@ from cpex.framework.errors import PluginError, convert_exception_to_error from cpex.framework.hooks.registry import get_hook_registry from cpex.framework.isolated.venv_comm import VenvProcessCommunicator -from cpex.framework.loader.config import ConfigLoader from cpex.framework.models import PluginConfig, PluginContext, PluginErrorModel, PluginPayload, PluginResult logger = logging.getLogger(__name__) @@ -35,14 +34,12 @@ class IsolatedVenvPlugin(Plugin): """IsolatedVenvPlugin class.""" - def __init__(self, config: PluginConfig) -> None: + def __init__(self, config: PluginConfig, plugin_dirs) -> None: """Initialize the plugin's venv environment.""" super().__init__(config) self.implementation = "Python" self.comm = None - tmp = os.environ.get("PLUGINS_CONFIG_FILE", "plugins/config.yaml") - plugin_loader_config = ConfigLoader.load_config(Path(tmp).resolve(), use_jinja=False) - self.plugin_dirs = plugin_loader_config.plugin_dirs + self.plugin_dirs = plugin_dirs # use the first plugin dir specified in the plugin configuration file. path = Path(self.plugin_dirs[0]).resolve() class_root = self.config.config.get("class_name").split(".")[0] @@ -210,23 +207,23 @@ async def initialize(self) -> None: raise FileNotFoundError(f"plugin path not found: {self.plugin_path}") venv_path = self.plugin_path / ".venv" - + # Prevent directory traversal: ensure requirements_file stays within plugin_path requirements_file_input = self.config.config["requirements_file"] - + # Handle both relative and absolute paths if isinstance(requirements_file_input, Path): requirements_file = requirements_file_input else: requirements_file = Path(requirements_file_input) - + # If it's a relative path, resolve it relative to plugin_path if not requirements_file.is_absolute(): requirements_file = (self.plugin_path / requirements_file).resolve() else: # If absolute, resolve it to normalize requirements_file = requirements_file.resolve() - + # Validate that the resolved path is within plugin_path (security check) try: requirements_file.relative_to(self.plugin_path.resolve()) diff --git a/cpex/framework/loader/plugin.py b/cpex/framework/loader/plugin.py index 3a8627a9..c1a83170 100644 --- a/cpex/framework/loader/plugin.py +++ b/cpex/framework/loader/plugin.py @@ -53,6 +53,7 @@ def __init__(self) -> None: {} """ self._plugin_types: dict[str, Type[Plugin]] = {} + self.plugin_dirs: list[str] = [] def __get_plugin_type(self, kind: str) -> Type[Plugin]: """Import a plugin type from a python module. @@ -145,7 +146,7 @@ async def load_and_instantiate_plugin(self, config: PluginConfig) -> Plugin | No if config.kind == ISOLATED_VENV_PLUGIN_TYPE: from cpex.framework.isolated.client import IsolatedVenvPlugin # pylint: disable=import-outside-toplevel - plugin: Plugin = IsolatedVenvPlugin(config) + plugin: Plugin = IsolatedVenvPlugin(config, plugin_dirs=self.plugin_dirs.copy()) await plugin.initialize() return plugin @@ -167,8 +168,10 @@ def append_to_search_path(self, plugin_dirs: list[str]) -> None: """ for plugin_dir in plugin_dirs: resolved = str(Path(plugin_dir).resolve()) - if resolved not in sys.path and resolved.startswith(tuple(ALLOWED_PLUGIN_DIRS)): - sys.path.append(resolved) + if resolved.startswith(tuple(ALLOWED_PLUGIN_DIRS)): + self.plugin_dirs.append(plugin_dir) + if resolved not in sys.path: + sys.path.append(resolved) async def shutdown(self) -> None: """Shutdown and cleanup plugin loader. diff --git a/cpex/framework/models.py b/cpex/framework/models.py index 65a68d42..8216467b 100644 --- a/cpex/framework/models.py +++ b/cpex/framework/models.py @@ -1329,17 +1329,7 @@ def to_json(self) -> dict[str, Any]: """ # Get the base serialization from Pydantic data = self.model_dump(mode="json", exclude_none=False, exclude_unset=False) - - # Explicitly remove any validator methods or callables that might have been included - # These are the @model_validator decorated methods that should not be serialized - methods_to_exclude = { - "_migrate_legacy_modes", - "check_url_or_script_filled", - "check_config_and_external", - } - - # Filter out any methods or callables from the serialized data - return {k: v for k, v in data.items() if k not in methods_to_exclude and not callable(v)} + return data class PluginManifest(BaseModel): diff --git a/tests/unit/cpex/framework/isolated/test_client.py b/tests/unit/cpex/framework/isolated/test_client.py index fb9bd045..4ed49344 100644 --- a/tests/unit/cpex/framework/isolated/test_client.py +++ b/tests/unit/cpex/framework/isolated/test_client.py @@ -57,7 +57,7 @@ def mock_config(self, tmp_path): @pytest.fixture def plugin(self, mock_config, tmp_path): """Create an IsolatedVenvPlugin instance.""" - plugin_instance = IsolatedVenvPlugin(mock_config) + plugin_instance = IsolatedVenvPlugin(mock_config, plugin_dirs=[tmp_path]) # Override plugin_path to use tmp_path for testing plugin_instance.plugin_path = tmp_path / "test_plugin" return plugin_instance diff --git a/tests/unit/cpex/framework/isolated/test_integration.py b/tests/unit/cpex/framework/isolated/test_integration.py index 917b1e83..e0316ea4 100644 --- a/tests/unit/cpex/framework/isolated/test_integration.py +++ b/tests/unit/cpex/framework/isolated/test_integration.py @@ -13,11 +13,13 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest +import yaml from cpex.framework import GlobalContext, PluginManager from cpex.framework.hooks.tools import ToolPreInvokePayload from cpex.framework.isolated.client import IsolatedVenvPlugin -from cpex.framework.models import PluginConfig +from cpex.framework.loader.plugin import ALLOWED_PLUGIN_DIRS +from cpex.framework.models import Config, PluginConfig class TestIsolatedPluginIntegration: @@ -26,28 +28,22 @@ class TestIsolatedPluginIntegration: @pytest.fixture def integration_config_path(self, tmp_path): """Create a temporary config file for integration testing.""" - config_content = """ -plugin_dirs: - - "xplugins" -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - -plugins: - - name: "test_isolated_plugin" - kind: "isolated_venv" - description: "Test isolated plugin" - version: "1.0.0" - author: "Test" - hooks: ["tool_pre_invoke"] - config: - class_name: "test_plugin.TestPlugin" - requirements_file: "requirements.txt" - script_path: "xplugins" -""" - config_file = tmp_path / "test_config.yaml" + 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"], + config={ + "class_name": "test_plugin.TestPlugin", + "requirements_file": "requirements.txt" + })],plugin_dirs=[str((tmp_path / "xplugins").resolve())], + plugin_settings={ + "parallel_execution_within_band": True, + "plugin_timeout": 30, + "fail_on_plugin_error": False + }) + config_file = tmp_path / "xplugins" / "test_config.yaml" + class_root = tmp_path / "xplugins" / "test_plugin" + class_root.mkdir(parents=True, exist_ok=True) + dumped_cfg = cfg.model_dump(mode="json") + config_content = yaml.safe_dump(dumped_cfg, default_flow_style=False) config_file.write_text(config_content) return str(config_file) @@ -55,7 +51,7 @@ def integration_config_path(self, tmp_path): @patch("cpex.framework.isolated.client.VenvProcessCommunicator") @patch.object(IsolatedVenvPlugin, "create_venv") async def test_plugin_manager_with_isolated_plugin( - self, mock_create_venv, mock_comm_class, integration_config_path + self, mock_create_venv, mock_comm_class, integration_config_path, tmp_path ): """Test PluginManager loading and initializing an isolated plugin.""" # Setup mocks @@ -63,11 +59,11 @@ async def test_plugin_manager_with_isolated_plugin( mock_comm = MagicMock() mock_comm.install_requirements = MagicMock() mock_comm_class.return_value = mock_comm - - # Create manager - manager = PluginManager(integration_config_path) - - await manager.initialize() + with patch('cpex.framework.loader.plugin.ALLOWED_PLUGIN_DIRS', { str((tmp_path / "xplugins" ).resolve())}): + # Create manager + manager = PluginManager(integration_config_path) + + await manager.initialize() @pytest.mark.asyncio @patch("cpex.framework.isolated.client.VenvProcessCommunicator") @@ -96,35 +92,39 @@ async def test_isolated_plugin_full_lifecycle(self, mock_create_venv, mock_comm_ "config": { "class_name": "test_plugin.TestPlugin", "requirements_file": "requirements.txt", - "script_path": "tests/unit/cpex/fixtures/plugins/isolated" } } + resolved_plugin_path = (tmp_path / "xplugins" ).resolve() + plugin_root = resolved_plugin_path / "test_plugin" + plugin_root.mkdir(parents=True, exist_ok=True) + # resolved_plugin_path.mkdir(parents=True, exist_ok=True) + with patch('cpex.framework.loader.plugin.ALLOWED_PLUGIN_DIRS', { str(resolved_plugin_path) }): - config = PluginConfig(**config_dict) - - # Create and initialize plugin - plugin = IsolatedVenvPlugin(config) - - with patch("cpex.framework.isolated.client.get_hook_registry") as mock_registry: - from cpex.framework.hooks.tools import ToolPreInvokeResult - mock_reg = MagicMock() - mock_reg.get_result_type.return_value = ToolPreInvokeResult - mock_reg.json_to_result = MagicMock() - mock_reg.json_to_result.return_value = ToolPreInvokeResult(continue_processing=True) - mock_registry.return_value = mock_reg + config = PluginConfig(**config_dict) - await plugin.initialize() + # Create and initialize plugin + plugin = IsolatedVenvPlugin(config, plugin_dirs=[resolved_plugin_path]) - # Invoke hook - payload = ToolPreInvokePayload(name="test_tool", args={}) - global_ctx = GlobalContext(request_id="req-123") - from cpex.framework.models import PluginContext - context = PluginContext(global_context=global_ctx) - - result = await plugin.invoke_hook("tool_pre_invoke", payload, context) - - assert result is not None - assert result.continue_processing is True + with patch("cpex.framework.isolated.client.get_hook_registry") as mock_registry: + from cpex.framework.hooks.tools import ToolPreInvokeResult + mock_reg = MagicMock() + mock_reg.get_result_type.return_value = ToolPreInvokeResult + mock_reg.json_to_result = MagicMock() + mock_reg.json_to_result.return_value = ToolPreInvokeResult(continue_processing=True) + mock_registry.return_value = mock_reg + + await plugin.initialize() + + # Invoke hook + payload = ToolPreInvokePayload(name="test_tool", args={}) + global_ctx = GlobalContext(request_id="req-123") + from cpex.framework.models import PluginContext + context = PluginContext(global_context=global_ctx) + + result = await plugin.invoke_hook("tool_pre_invoke", payload, context) + + assert result is not None + assert result.continue_processing is True @pytest.mark.asyncio async def test_isolated_plugin_error_handling(self, tmp_path): @@ -139,11 +139,15 @@ async def test_isolated_plugin_error_handling(self, tmp_path): "config": { "class_name": "test_plugin.TestPlugin", "requirements_file": "requirements.txt", - "script_path": "tests/unit/cpex/fixtures/plugins/isolated" } } config = PluginConfig(**config_dict) - plugin = IsolatedVenvPlugin(config) + resolved_plugin_path = (tmp_path / "xplugins" ).resolve() + cache_root = resolved_plugin_path / "test_plugin" + cache_root.mkdir(parents=True, exist_ok=True) + # resolved_plugin_path.mkdir(parents=True, exist_ok=True) + + plugin = IsolatedVenvPlugin(config, plugin_dirs=[str(resolved_plugin_path)]) # Try to invoke hook without initialization from cpex.framework.errors import PluginError @@ -182,7 +186,11 @@ async def test_isolated_plugin_with_multiple_hooks( } config = PluginConfig(**config_dict) - plugin = IsolatedVenvPlugin(config) + resolved_plugin_path = (tmp_path / "xplugins" ).resolve() + cache_root = resolved_plugin_path / "test_plugin" + cache_root.mkdir(parents=True, exist_ok=True) + + plugin = IsolatedVenvPlugin(config, plugin_dirs=[str(resolved_plugin_path)]) await plugin.initialize() @@ -266,11 +274,14 @@ def capture_task(script_path, task_data): "config": { "class_name": "test_plugin.TestPlugin", "requirements_file": "requirements.txt", - "script_path": "tests/unit/cpex/fixtures/plugins/isolated" } } config = PluginConfig(**config_dict) - plugin = IsolatedVenvPlugin(config) + resolved_plugin_path = (tmp_path / "xplugins" ).resolve() + cache_root = resolved_plugin_path / "test_plugin" + cache_root.mkdir(parents=True, exist_ok=True) + + plugin = IsolatedVenvPlugin(config, plugin_dirs=[str(resolved_plugin_path)]) await plugin.initialize() @@ -334,11 +345,14 @@ async def test_isolated_plugin_violation_handling( "config": { "class_name": "test_plugin.TestPlugin", "requirements_file": "requirements.txt", - "script_path": "tests/unit/cpex/fixtures/plugins/isolated" } } config = PluginConfig(**config_dict) - plugin = IsolatedVenvPlugin(config) + resolved_plugin_path = (tmp_path / "xplugins" ).resolve() + cache_root = resolved_plugin_path / "test_plugin" + cache_root.mkdir(parents=True, exist_ok=True) + + plugin = IsolatedVenvPlugin(config, plugin_dirs=[str(resolved_plugin_path)]) await plugin.initialize() From 77dae5c171044c9e4f3c8d59e30e0d2406915c9f Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 9 Apr 2026 14:21:56 -0400 Subject: [PATCH 51/60] chore: maintain error context. Signed-off-by: habeck --- cpex/framework/isolated/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index d52e6101..a757127a 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -227,11 +227,11 @@ async def initialize(self) -> None: # Validate that the resolved path is within plugin_path (security check) try: requirements_file.relative_to(self.plugin_path.resolve()) - except ValueError: + except ValueError as ve: raise RuntimeError( f"Invalid requirements_file path: {requirements_file_input}. " f"Path must be within plugin directory: {self.plugin_path}" - ) + ) from ve # Create venv with caching support new_venv = await self.create_venv(venv_path=venv_path, requirements_file=requirements_file, use_cache=True) From e989bb42f361837dfbad76cb23f6cd115cdefe3d Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 9 Apr 2026 15:27:57 -0400 Subject: [PATCH 52/60] fix: remove worker dependency on plugins/config.yaml Signed-off-by: habeck --- cpex/framework/isolated/worker.py | 23 +------ .../cpex/framework/isolated/test_worker.py | 63 +------------------ 2 files changed, 3 insertions(+), 83 deletions(-) diff --git a/cpex/framework/isolated/worker.py b/cpex/framework/isolated/worker.py index 19439161..6a572711 100644 --- a/cpex/framework/isolated/worker.py +++ b/cpex/framework/isolated/worker.py @@ -25,7 +25,7 @@ from cpex.framework.loader.config import ConfigLoader from cpex.framework.loader.plugin import ALLOWED_PLUGIN_DIRS from cpex.framework.manager import PluginExecutor -from cpex.framework.models import PluginContext +from cpex.framework.models import PluginConfig, PluginContext from cpex.framework.utils import parse_class_name logger = logging.getLogger(__name__) @@ -71,25 +71,6 @@ def get_environment_info(): "installed_packages": [str(d) for d in importlib.metadata.entry_points()][:10], # First 10 packages } - -def get_proper_config(name): - """ - Load a config which has all it's proper decorations - """ - plugin_config_file = os.environ.get("PLUGINS_CONFIG_FILE", "plugins/config.yaml") - plugin_loader_config = ConfigLoader.load_config(Path(plugin_config_file).resolve(), use_jinja=False) - plugins: list[dict] = [] - config = None - if plugin_loader_config.plugins: - for plug in plugin_loader_config.plugins: - plugins.append(plug.model_dump()) - if plug.name == name: - # config = plug.model_dump() - config = plug - return config - return None - - async def process_task(task_data, tp: TaskProcessor): """Process the task received from parent.""" task_type = task_data.get("task_type") @@ -120,7 +101,7 @@ async def process_task(task_data, tp: TaskProcessor): if tp.config_hash != tp.compute_hash(json_config): # pull the resolved plugin path and only add the module path if it has the same root - config = get_proper_config(config_raw.get("name")) + config: PluginConfig = PluginConfig(**config_raw) hook_type = task_data.get(HOOK_TYPE) cls_name: str = task_data.get("class_name") mod_name, n_cls_name = parse_class_name(cls_name) diff --git a/tests/unit/cpex/framework/isolated/test_worker.py b/tests/unit/cpex/framework/isolated/test_worker.py index fc636131..a64ed341 100644 --- a/tests/unit/cpex/framework/isolated/test_worker.py +++ b/tests/unit/cpex/framework/isolated/test_worker.py @@ -16,7 +16,7 @@ import pytest -from cpex.framework.isolated.worker import TaskProcessor, get_environment_info, get_proper_config, main, process_task +from cpex.framework.isolated.worker import TaskProcessor, get_environment_info, main, process_task class TestWorkerFunctions: @@ -49,47 +49,6 @@ def test_get_environment_info(self): assert isinstance(info["installed_packages"], list) assert len(info["installed_packages"]) <= 10 # Limited to first 10 - @patch("cpex.framework.isolated.worker.ConfigLoader.load_config") - def test_get_proper_config_found(self, mock_load_config): - """Test getting proper config when plugin is found.""" - # Create mock plugin config - mock_plugin = MagicMock() - mock_plugin.name = "test_plugin" - mock_plugin.model_dump.return_value = {"name": "test_plugin", "kind": "isolated_venv", "config": {}} - - mock_config = MagicMock() - mock_config.plugins = [mock_plugin] - mock_load_config.return_value = mock_config - - result = get_proper_config("test_plugin") - - assert result is not None - assert result.name == "test_plugin" - - @patch("cpex.framework.isolated.worker.ConfigLoader.load_config") - def test_get_proper_config_not_found(self, mock_load_config): - """Test getting proper config when plugin is not found.""" - mock_plugin = MagicMock() - mock_plugin.name = "other_plugin" - - mock_config = MagicMock() - mock_config.plugins = [mock_plugin] - mock_load_config.return_value = mock_config - - result = get_proper_config("test_plugin") - - assert result is None - - @patch("cpex.framework.isolated.worker.ConfigLoader.load_config") - def test_get_proper_config_no_plugins(self, mock_load_config): - """Test getting proper config when no plugins exist.""" - mock_config = MagicMock() - mock_config.plugins = None - mock_load_config.return_value = mock_config - - result = get_proper_config("test_plugin") - - assert result is None @pytest.mark.asyncio async def test_process_task_info(self): @@ -154,26 +113,6 @@ async def test_process_task_load_and_run_hook_success(self, mock_executor_class, mock_executor.execute_plugin.assert_called_once() self.cleanup_mock_plugin_dirs() - @pytest.mark.asyncio - @patch("cpex.framework.isolated.worker.get_proper_config") - async def test_process_task_load_and_run_hook_no_config(self, mock_get_config): - """Test processing load_and_run_hook task when config not found.""" - mock_get_config.return_value = None - - config_dict = {"name": "test_plugin", "kind": "isolated_venv"} - task_data = { - "task_type": "load_and_run_hook", - "config": json.dumps(config_dict), - "class_name": "test_plugin.TestPlugin", - "hook_type": "tool_pre_invoke", - "payload": {}, - "context": {"state": {}, "global_context": {}, "metadata": {}}, - } - tp = TaskProcessor() - # Should raise an error or return None - with pytest.raises((AttributeError, TypeError)): - await process_task(task_data, tp) - @pytest.mark.asyncio @patch("cpex.framework.isolated.worker.get_proper_config") @patch("cpex.framework.isolated.worker.importlib.import_module") From c89aae7cef00e48e2db045b8fe56c0c92b6bb3f6 Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 9 Apr 2026 15:45:42 -0400 Subject: [PATCH 53/60] misc: removed unused import Signed-off-by: habeck --- cpex/framework/isolated/worker.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cpex/framework/isolated/worker.py b/cpex/framework/isolated/worker.py index 6a572711..cb56ae3d 100644 --- a/cpex/framework/isolated/worker.py +++ b/cpex/framework/isolated/worker.py @@ -22,7 +22,6 @@ from cpex.framework.base import HookRef, Plugin, PluginRef from cpex.framework.constants import HOOK_TYPE -from cpex.framework.loader.config import ConfigLoader from cpex.framework.loader.plugin import ALLOWED_PLUGIN_DIRS from cpex.framework.manager import PluginExecutor from cpex.framework.models import PluginConfig, PluginContext @@ -115,7 +114,7 @@ async def process_task(task_data, tp: TaskProcessor): plugin_ref = PluginRef(plugin) hook_ref = HookRef(hook_type, plugin_ref) executor = PluginExecutor(None, 30) - tp.initialize(hook_ref=hook_ref, executor=executor, json_config=json_config, module_path=module_path) + tp.initialize(hook_ref=hook_ref, executor=executor, json_config=json_config, module_path=resolved_module_path) # retrieve the context context = task_data.get("context") plugin_context = PluginContext( From eeeb2f56152a46fcdf9fa7cc150abb19a628be75 Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 9 Apr 2026 15:45:56 -0400 Subject: [PATCH 54/60] chore: lint fix Signed-off-by: habeck --- cpex/framework/isolated/worker.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cpex/framework/isolated/worker.py b/cpex/framework/isolated/worker.py index cb56ae3d..e813a446 100644 --- a/cpex/framework/isolated/worker.py +++ b/cpex/framework/isolated/worker.py @@ -13,7 +13,6 @@ import importlib.metadata import json import logging -import os import platform import sys from pathlib import Path @@ -70,6 +69,7 @@ def get_environment_info(): "installed_packages": [str(d) for d in importlib.metadata.entry_points()][:10], # First 10 packages } + async def process_task(task_data, tp: TaskProcessor): """Process the task received from parent.""" task_type = task_data.get("task_type") @@ -114,7 +114,9 @@ async def process_task(task_data, tp: TaskProcessor): plugin_ref = PluginRef(plugin) hook_ref = HookRef(hook_type, plugin_ref) executor = PluginExecutor(None, 30) - tp.initialize(hook_ref=hook_ref, executor=executor, json_config=json_config, module_path=resolved_module_path) + tp.initialize( + hook_ref=hook_ref, executor=executor, json_config=json_config, module_path=resolved_module_path + ) # retrieve the context context = task_data.get("context") plugin_context = PluginContext( From 95ac9fa81fa6ea25d24d57b139b6b653722ab13e Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 9 Apr 2026 16:04:20 -0400 Subject: [PATCH 55/60] fix: compute the hash of module paths list rather than the last one in the list Signed-off-by: habeck --- cpex/framework/isolated/worker.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cpex/framework/isolated/worker.py b/cpex/framework/isolated/worker.py index e813a446..5f867e16 100644 --- a/cpex/framework/isolated/worker.py +++ b/cpex/framework/isolated/worker.py @@ -86,10 +86,12 @@ async def process_task(task_data, tp: TaskProcessor): json_config = task_data.get("config") config_raw = json.loads(json_config) module_paths: List[str] = task_data.get("plugin_dirs") + resolved_paths: List[str] = [] for module_path in module_paths: path = Path(module_path).resolve() resolved_module_path = str(path) if path.exists(): + resolved_paths.append(resolved_module_path) if resolved_module_path not in sys.path: if resolved_module_path.startswith(tuple(ALLOWED_PLUGIN_DIRS)): sys.path.append(resolved_module_path) @@ -115,7 +117,7 @@ async def process_task(task_data, tp: TaskProcessor): hook_ref = HookRef(hook_type, plugin_ref) executor = PluginExecutor(None, 30) tp.initialize( - hook_ref=hook_ref, executor=executor, json_config=json_config, module_path=resolved_module_path + hook_ref=hook_ref, executor=executor, json_config=json_config, module_path=json.dumps(resolved_paths) ) # retrieve the context context = task_data.get("context") From caf90717eaf4c35c88cc745de59104787bf31308 Mon Sep 17 00:00:00 2001 From: habeck Date: Thu, 9 Apr 2026 16:14:50 -0400 Subject: [PATCH 56/60] fix: do not reference task_data on json decode error. Signed-off-by: habeck --- cpex/framework/isolated/worker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpex/framework/isolated/worker.py b/cpex/framework/isolated/worker.py index 5f867e16..a5a0e23d 100644 --- a/cpex/framework/isolated/worker.py +++ b/cpex/framework/isolated/worker.py @@ -186,7 +186,7 @@ async def main(): error_response = { "status": "error", "message": f"Invalid JSON input: {str(e)}", - "request_id": task_data.get("request_id", "unknown") if "task_data" in locals() else "unknown", + "request_id": "unknown", } print(json.dumps(error_response), flush=True) From 37a855062500289838ce6959c70e22be60083480 Mon Sep 17 00:00:00 2001 From: habeck Date: Fri, 10 Apr 2026 12:18:50 -0400 Subject: [PATCH 57/60] enh: Add a maximum line length check before parsing. Add model tests for PluginPackageInfo and PluginVersionRegistry Signed-off-by: habeck --- cpex/framework/isolated/client.py | 2 +- cpex/framework/isolated/venv_comm.py | 6 +- cpex/framework/isolated/worker.py | 25 +- cpex/framework/models.py | 2 + .../framework/isolated/test_integration.py | 4 +- .../cpex/framework/isolated/test_venv_comm.py | 178 +++++++ .../framework/test_models_package_version.py | 435 ++++++++++++++++++ 7 files changed, 643 insertions(+), 9 deletions(-) create mode 100644 tests/unit/cpex/framework/test_models_package_version.py diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index a757127a..f035a377 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -323,7 +323,7 @@ async def invoke_hook(self, hook_type: str, payload: PluginPayload, context: Plu result_dict: dict[str, Any] = await loop.run_in_executor( None, functools.partial( - self.comm.send_task, script_path="cpex/framework/isolated/worker.py", task_data=task_data + self.comm.send_task, script_path="cpex/framework/isolated/worker.py", task_data=task_data, max_content_size=self.config.max_content_size ), ) # Convert response to typed result diff --git a/cpex/framework/isolated/venv_comm.py b/cpex/framework/isolated/venv_comm.py index 0712c6f4..1c7f1de5 100644 --- a/cpex/framework/isolated/venv_comm.py +++ b/cpex/framework/isolated/venv_comm.py @@ -161,7 +161,7 @@ def _read_responses(self) -> None: self.running = False logger.info("Response reader thread terminated") - def send_task(self, script_path: str, task_data: Any, timeout: float = 30.0) -> Any: + def send_task(self, script_path: str, task_data: Any, timeout: float = 30.0, max_content_size: int = 10000000) -> Any: """ Send a task to the long-running worker process and get response. @@ -189,6 +189,10 @@ def send_task(self, script_path: str, task_data: Any, timeout: float = 30.0) -> try: # Send task to worker input_json = orjson.dumps(task_data).decode() + if len(input_json) > max_content_size: + # remove the request_id from the response queue and raise + self.response_queues.pop(request_id) + raise RuntimeError(f"task_data exceeds max_content_size. {len(input_json)}") if self.process and self.process.stdin: self.process.stdin.write(input_json + "\n") self.process.stdin.flush() diff --git a/cpex/framework/isolated/worker.py b/cpex/framework/isolated/worker.py index a5a0e23d..2aea8c55 100644 --- a/cpex/framework/isolated/worker.py +++ b/cpex/framework/isolated/worker.py @@ -38,6 +38,7 @@ class TaskProcessor: module_path_hash: str hook_ref: HookRef executor: PluginExecutor + plugin_config: PluginConfig | None = None def __init__(self) -> None: """Initialize defaults.""" @@ -52,12 +53,13 @@ def compute_hash(self, json_config_or_module_path: str): hasher.update(json_config_or_module_path.encode()) return hasher.hexdigest() - def initialize(self, hook_ref: HookRef, executor: PluginExecutor, json_config: str, module_path: str): + def initialize(self, hook_ref: HookRef, executor: PluginExecutor, json_config: str, module_path: str, plugin_config: PluginConfig): """Assign locals, and compute hashes.""" self.hook_ref = hook_ref self.executor = executor self.config_hash = self.compute_hash(json_config_or_module_path=json_config) self.module_path_hash = self.compute_hash(json_config_or_module_path=module_path) + self.plugin_config = plugin_config def get_environment_info(): @@ -117,7 +119,7 @@ async def process_task(task_data, tp: TaskProcessor): hook_ref = HookRef(hook_type, plugin_ref) executor = PluginExecutor(None, 30) tp.initialize( - hook_ref=hook_ref, executor=executor, json_config=json_config, module_path=json.dumps(resolved_paths) + hook_ref=hook_ref, executor=executor, json_config=json_config, module_path=json.dumps(resolved_paths), plugin_config=config ) # retrieve the context context = task_data.get("context") @@ -149,8 +151,11 @@ async def main(): while True: try: # Read one line at a time - line = sys.stdin.readline() - + if tp.plugin_config: + line = sys.stdin.readline(limit=int(tp.plugin_config.max_content_size)) + else: + # on the first read, the plugin_config has not yet been initialized so just read. + line = sys.stdin.readline() # Check for EOF if not line: logger.info("EOF received, shutting down worker") @@ -179,8 +184,18 @@ async def main(): # Add request_id to response serializable_response["request_id"] = request_id + serialized_response = json.dumps(serializable_response) # Send response back to parent (one line per response) - print(json.dumps(serializable_response), flush=True) + if tp.plugin_config: + if len(serialized_response) > tp.plugin_config.max_content_size: + logger.error("Serialized response exceeds max content size") + error_response = { + "status": "error", + "message": f"Serialized response exceeds max content size", + "request_id": request_id, + } + serialized_response = json.dumps(error_response) + print(serialized_response, flush=True) except json.JSONDecodeError as e: error_response = { diff --git a/cpex/framework/models.py b/cpex/framework/models.py index 8216467b..5a913a8e 100644 --- a/cpex/framework/models.py +++ b/cpex/framework/models.py @@ -1194,6 +1194,7 @@ class PluginConfig(BaseModel): config (dict[str, Any]): the plugin specific configurations. mcp (Optional[MCPClientConfig]): Client-side MCP configuration (gateway connecting to plugin). grpc (Optional[GRPCClientConfig]): Client-side gRPC configuration (gateway connecting to plugin). + max_content_size (Optional(int)): The maximum size of payload, context, """ name: str @@ -1207,6 +1208,7 @@ class PluginConfig(BaseModel): mode: PluginMode = PluginMode.SEQUENTIAL on_error: OnError = OnError.FAIL priority: int = 100 # Lower = higher priority + max_content_size: int = 10000000 @model_validator(mode="before") @classmethod diff --git a/tests/unit/cpex/framework/isolated/test_integration.py b/tests/unit/cpex/framework/isolated/test_integration.py index e0316ea4..69b9257e 100644 --- a/tests/unit/cpex/framework/isolated/test_integration.py +++ b/tests/unit/cpex/framework/isolated/test_integration.py @@ -251,7 +251,7 @@ async def test_isolated_plugin_context_propagation( # Capture the task data sent captured_task = None - def capture_task(script_path, task_data): + def capture_task(script_path, task_data, max_content_size): nonlocal captured_task captured_task = task_data return { @@ -274,7 +274,7 @@ def capture_task(script_path, task_data): "config": { "class_name": "test_plugin.TestPlugin", "requirements_file": "requirements.txt", - } + }, } config = PluginConfig(**config_dict) resolved_plugin_path = (tmp_path / "xplugins" ).resolve() diff --git a/tests/unit/cpex/framework/isolated/test_venv_comm.py b/tests/unit/cpex/framework/isolated/test_venv_comm.py index 27237206..81a1f155 100644 --- a/tests/unit/cpex/framework/isolated/test_venv_comm.py +++ b/tests/unit/cpex/framework/isolated/test_venv_comm.py @@ -747,5 +747,183 @@ def test_del_method_no_running_attribute(self): # Should not raise exception comm.__del__() + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_send_task_exceeds_max_content_size(self, mock_thread, mock_popen, communicator): + """Test send_task raises error when data exceeds max_content_size.""" + # Create a large task that will exceed the limit + large_data = "x" * 5000 + task_data = { + "task_type": "test", + "data": large_data + } + + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + # Set a very small max_content_size to trigger the error + with pytest.raises(RuntimeError, match="task_data exceeds max_content_size"): + communicator.send_task("test_script.py", task_data, max_content_size=100) + + # Verify the request_id was cleaned up from response_queues + assert len(communicator.response_queues) == 0 + + @patch("subprocess.Popen") + @patch("threading.Thread") + @patch("uuid.uuid4") + def test_send_task_at_max_content_size_boundary(self, mock_uuid, mock_thread, mock_popen, communicator): + """Test send_task works when data is exactly at the limit.""" + # Use a fixed UUID to make size calculation predictable + mock_uuid.return_value = Mock(hex="12345678123456781234567812345678") + mock_uuid.return_value.__str__ = Mock(return_value="12345678-1234-5678-1234-567812345678") + + task_data = {"task_type": "test", "data": "small"} + + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + # Mock the Queue to return response + with patch("cpex.framework.isolated.venv_comm.Queue") as mock_queue_class: + mock_queue_instance = MagicMock() + mock_queue_instance.get.return_value = { + "status": "success", + "result": "ok", + "request_id": "test-id" + } + mock_queue_class.return_value = mock_queue_instance + + communicator.start_worker("test_script.py") + + # Calculate the exact size of the serialized data with the mocked UUID + import orjson + test_data_copy = task_data.copy() + test_data_copy["request_id"] = "12345678-1234-5678-1234-567812345678" + serialized_size = len(orjson.dumps(test_data_copy).decode()) + + # Set max_content_size to exactly the serialized size + result = communicator.send_task("test_script.py", task_data, max_content_size=serialized_size) + + assert result == {"status": "success", "result": "ok"} + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_send_task_with_custom_max_content_size(self, mock_thread, mock_popen, communicator): + """Test send_task respects custom max_content_size parameter.""" + # Create task data that's moderately sized + task_data = { + "task_type": "test", + "data": "x" * 1000, + "metadata": {"key": "value"} + } + + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + with patch("cpex.framework.isolated.venv_comm.Queue") as mock_queue_class: + mock_queue_instance = MagicMock() + mock_queue_instance.get.return_value = { + "status": "success", + "result": "processed", + "request_id": "test-id" + } + mock_queue_class.return_value = mock_queue_instance + + communicator.start_worker("test_script.py") + + # Should succeed with large max_content_size + result = communicator.send_task("test_script.py", task_data, max_content_size=50000) + assert result == {"status": "success", "result": "processed"} + + # Should fail with small max_content_size + with pytest.raises(RuntimeError, match="task_data exceeds max_content_size"): + communicator.send_task("test_script.py", task_data, max_content_size=500) + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_send_task_default_max_content_size(self, mock_thread, mock_popen, communicator): + """Test send_task uses default max_content_size of 10MB.""" + # Create a task that's under 10MB + task_data = { + "task_type": "test", + "data": "x" * 100000 # 100KB + } + + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + with patch("cpex.framework.isolated.venv_comm.Queue") as mock_queue_class: + mock_queue_instance = MagicMock() + mock_queue_instance.get.return_value = { + "status": "success", + "result": "ok", + "request_id": "test-id" + } + mock_queue_class.return_value = mock_queue_instance + + communicator.start_worker("test_script.py") + + # Should succeed with default max_content_size (10MB) + result = communicator.send_task("test_script.py", task_data) + assert result == {"status": "success", "result": "ok"} + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_send_task_very_large_data_exceeds_default_limit(self, mock_thread, mock_popen, communicator): + """Test send_task fails when data exceeds default 10MB limit.""" + # Create a task that exceeds 10MB + task_data = { + "task_type": "test", + "data": "x" * 11000000 # ~11MB + } + + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + # Should fail with default max_content_size + with pytest.raises(RuntimeError, match="task_data exceeds max_content_size"): + communicator.send_task("test_script.py", task_data) + + # Verify cleanup happened + assert len(communicator.response_queues) == 0 + # Made with Bob diff --git a/tests/unit/cpex/framework/test_models_package_version.py b/tests/unit/cpex/framework/test_models_package_version.py new file mode 100644 index 00000000..0429127c --- /dev/null +++ b/tests/unit/cpex/framework/test_models_package_version.py @@ -0,0 +1,435 @@ +# -*- coding: utf-8 -*- +"""Additional unit tests for PluginPackageInfo and PluginVersionRegistry in cpex.framework.models. + +This module provides additional test coverage for edge cases and scenarios +not covered in the main test_plugin_models.py file. +""" + +# Third-Party +import pytest + +# First-Party +from cpex.framework.models import PluginPackageInfo, PluginVersionInfo, PluginVersionRegistry + + +class TestPluginPackageInfoEdgeCases: + """Additional edge case tests for PluginPackageInfo.""" + + def test_pypi_package_single_character(self): + """Single character PyPI package names should be valid.""" + pkg = PluginPackageInfo(pypi_package="a") + assert pkg.pypi_package == "a" + + def test_pypi_package_two_characters(self): + """Two character PyPI package names should be valid.""" + pkg = PluginPackageInfo(pypi_package="ab") + assert pkg.pypi_package == "ab" + + def test_pypi_package_max_length(self): + """PyPI package name at exactly 214 characters should be valid.""" + max_name = "a" * 214 + pkg = PluginPackageInfo(pypi_package=max_name) + assert pkg.pypi_package == max_name + assert len(pkg.pypi_package) == 214 + + def test_pypi_package_with_numbers_only(self): + """PyPI package names with only numbers should be valid.""" + pkg = PluginPackageInfo(pypi_package="123") + assert pkg.pypi_package == "123" + + def test_pypi_package_mixed_separators(self): + """PyPI package names with mixed valid separators should be valid.""" + pkg = PluginPackageInfo(pypi_package="my-package_name.version") + assert pkg.pypi_package == "my-package_name.version" + + def test_git_repository_without_git_extension(self): + """Git repository URLs without .git extension should be valid.""" + pkg = PluginPackageInfo(git_repository="https://github.com/user/repo") + assert pkg.git_repository == "https://github.com/user/repo" + + def test_git_repository_with_subdirectories(self): + """Git repository URLs with subdirectories should be valid.""" + pkg = PluginPackageInfo(git_repository="https://github.com/org/team/repo.git") + assert pkg.git_repository == "https://github.com/org/team/repo.git" + + def test_git_repository_ssh_with_port(self): + """SSH Git URLs with custom ports are not supported by the current validator.""" + # The current regex doesn't support ssh:// protocol with ports + with pytest.raises(ValueError, match="Invalid Git repository URL"): + PluginPackageInfo(git_repository="ssh://git@github.com:2222/user/repo.git") + + def test_git_branch_single_character(self): + """Single character branch names should be valid.""" + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="v" + ) + assert pkg.git_branch_tag_commit == "v" + + def test_git_branch_with_multiple_slashes(self): + """Branch names with multiple slashes should be valid.""" + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="feature/sub/branch" + ) + assert pkg.git_branch_tag_commit == "feature/sub/branch" + + def test_git_commit_short_hash(self): + """Short commit hashes (7 characters) should be valid.""" + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="abc1234" + ) + assert pkg.git_branch_tag_commit == "abc1234" + + def test_git_commit_full_hash(self): + """Full commit hashes (40 characters) should be valid.""" + full_hash = "a" * 40 + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit=full_hash + ) + assert pkg.git_branch_tag_commit == full_hash + + def test_version_constraint_with_spaces(self): + """Version constraints with spaces around operators should be valid.""" + pkg = PluginPackageInfo( + pypi_package="my-package", + version_constraint=">= 1.0.0, < 2.0.0" + ) + assert pkg.version_constraint == ">= 1.0.0, < 2.0.0" + + def test_version_constraint_triple_equals(self): + """Version constraints with === operator should be valid.""" + pkg = PluginPackageInfo( + pypi_package="my-package", + version_constraint="===1.0.0" + ) + assert pkg.version_constraint == "===1.0.0" + + def test_version_constraint_with_local_version(self): + """Version constraints with local version identifiers are not supported by current validator.""" + # The current regex doesn't support + in version constraints + with pytest.raises(ValueError, match="Invalid version constraint"): + PluginPackageInfo( + pypi_package="my-package", + version_constraint="==1.0.0+local.version" + ) + + def test_both_installation_methods_with_all_fields(self): + """Both installation methods with all optional fields should be valid.""" + pkg = PluginPackageInfo( + pypi_package="my-package", + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="v1.0.0", + version_constraint=">=1.0.0,<2.0.0" + ) + assert pkg.pypi_package == "my-package" + assert pkg.git_repository == "https://github.com/user/repo.git" + assert pkg.git_branch_tag_commit == "v1.0.0" + assert pkg.version_constraint == ">=1.0.0,<2.0.0" + + +class TestPluginVersionInfoEdgeCases: + """Additional edge case tests for PluginVersionInfo.""" + + def test_version_info_minimal_fields(self): + """PluginVersionInfo with only required fields should be valid.""" + info = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json" + ) + assert info.version == "1.0.0" + assert info.released == "2024-01-01" + assert info.manifest_file == "manifest.json" + assert info.breaking_changes is None + assert info.deprecated is False + assert info.changelog is None + + def test_version_info_all_fields(self): + """PluginVersionInfo with all fields should be valid.""" + info = PluginVersionInfo( + version="2.0.0", + released="2024-02-01", + breaking_changes=True, + deprecated=True, + manifest_file="manifest.json", + changelog="Major update with breaking changes", + min_max_framework_version="0.2.0,0.3.0" + ) + assert info.version == "2.0.0" + assert info.breaking_changes is True + assert info.deprecated is True + assert info.changelog == "Major update with breaking changes" + assert info.min_max_framework_version == "0.2.0,0.3.0" + + def test_version_info_prerelease_version(self): + """PluginVersionInfo with pre-release version should be valid.""" + info = PluginVersionInfo( + version="1.0.0-alpha.1", + released="2024-01-01", + manifest_file="manifest.json" + ) + assert info.version == "1.0.0-alpha.1" + + def test_version_info_dev_version(self): + """PluginVersionInfo with dev version should be valid.""" + info = PluginVersionInfo( + version="1.0.0.dev1", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0.dev1,0.1.0.dev10" + ) + assert info.version == "1.0.0.dev1" + + +class TestPluginVersionRegistryEdgeCases: + """Additional edge case tests for PluginVersionRegistry.""" + + def test_registry_with_only_prerelease(self): + """Registry with only pre-release versions should work correctly.""" + v1 = PluginVersionInfo( + version="1.0.0-alpha", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=None, + latest_prerelease=v1, + versions=[v1] + ) + + assert registry.get_version() is None + assert registry.latest_prerelease == v1 + + def test_registry_with_both_latest_and_prerelease(self): + """Registry with both latest and latest_prerelease should maintain both.""" + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + v2 = PluginVersionInfo( + version="1.1.0-beta", + released="2024-02-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=v1, + latest_prerelease=v2, + versions=[v1, v2] + ) + + assert registry.get_version() == v1 + assert registry.latest_prerelease == v2 + + def test_get_latest_compatible_with_single_version_in_range(self): + """get_latest_compatible with only one version in range should return it.""" + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=v1, + versions=[v1] + ) + + result = registry.get_latest_compatible("0.1.5") + assert result == v1 + + def test_get_latest_compatible_with_overlapping_ranges(self): + """get_latest_compatible with overlapping version ranges should return latest.""" + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.3.0" + ) + v2 = PluginVersionInfo( + version="1.5.0", + released="2024-02-01", + manifest_file="manifest.json", + min_max_framework_version="0.2.0,0.4.0" + ) + v3 = PluginVersionInfo( + version="2.0.0", + released="2024-03-01", + manifest_file="manifest.json", + min_max_framework_version="0.2.5,0.5.0" + ) + + registry = PluginVersionRegistry( + latest=v3, + versions=[v1, v2, v3] + ) + + # Framework 0.2.7 matches v1, v2, and v3 - should return v3 (latest) + result = registry.get_latest_compatible("0.2.7") + assert result == v3 + assert result.version == "2.0.0" + + def test_get_latest_compatible_with_non_overlapping_ranges(self): + """get_latest_compatible with non-overlapping ranges should return correct version.""" + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + v2 = PluginVersionInfo( + version="2.0.0", + released="2024-02-01", + manifest_file="manifest.json", + min_max_framework_version="0.3.0,0.4.0" + ) + + registry = PluginVersionRegistry( + latest=v2, + versions=[v1, v2] + ) + + # Framework 0.1.5 should match v1 + result = registry.get_latest_compatible("0.1.5") + assert result == v1 + + # Framework 0.3.5 should match v2 + result = registry.get_latest_compatible("0.3.5") + assert result == v2 + + # Framework 0.2.5 should match neither + result = registry.get_latest_compatible("0.2.5") + assert result is None + + def test_get_latest_compatible_with_malformed_version_in_list(self): + """get_latest_compatible should handle malformed versions in the list gracefully.""" + v1 = PluginVersionInfo( + version="not-a-version", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + v2 = PluginVersionInfo( + version="1.0.0", + released="2024-02-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=v2, + versions=[v1, v2] + ) + + # Should still find v2 even though v1 has invalid version + result = registry.get_latest_compatible("0.1.5") + # If sorting fails, it returns the first compatible version + assert result in [v1, v2] + + def test_get_latest_compatible_with_extra_whitespace_in_min_max(self): + """get_latest_compatible should handle extra whitespace in min_max_framework_version.""" + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version=" 0.1.0 , 0.2.0 " + ) + + registry = PluginVersionRegistry( + latest=v1, + versions=[v1] + ) + + result = registry.get_latest_compatible("0.1.5") + assert result == v1 + + def test_get_latest_compatible_with_three_part_min_max(self): + """get_latest_compatible should reject min_max with more than 2 parts.""" + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0,0.3.0" # Invalid: 3 parts + ) + + registry = PluginVersionRegistry( + latest=v1, + versions=[v1] + ) + + result = registry.get_latest_compatible("0.1.5") + assert result is None + + def test_get_latest_compatible_with_reversed_min_max(self): + """get_latest_compatible should handle reversed min/max (max < min).""" + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.2.0,0.1.0" # Reversed + ) + + registry = PluginVersionRegistry( + latest=v1, + versions=[v1] + ) + + # No version should match since max < min + result = registry.get_latest_compatible("0.1.5") + assert result is None + + def test_registry_versions_list_order_independence(self): + """Registry should work correctly regardless of versions list order.""" + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + v2 = PluginVersionInfo( + version="2.0.0", + released="2024-02-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + v3 = PluginVersionInfo( + version="1.5.0", + released="2024-01-15", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + # Test with different orderings + registry1 = PluginVersionRegistry( + latest=v2, + versions=[v1, v2, v3] + ) + + registry2 = PluginVersionRegistry( + latest=v2, + versions=[v3, v1, v2] + ) + + registry3 = PluginVersionRegistry( + latest=v2, + versions=[v2, v3, v1] + ) + + # All should return v2 as the latest compatible + result1 = registry1.get_latest_compatible("0.1.5") + result2 = registry2.get_latest_compatible("0.1.5") + result3 = registry3.get_latest_compatible("0.1.5") + + assert result1 == v2 + assert result2 == v2 + assert result3 == v2 + +# Made with Bob From 2e8a64e11b38bba610d1a0b194518ce4b9dfe0c5 Mon Sep 17 00:00:00 2001 From: habeck Date: Fri, 10 Apr 2026 13:21:37 -0400 Subject: [PATCH 58/60] chore: lint fix Signed-off-by: habeck --- cpex/framework/isolated/client.py | 5 ++++- cpex/framework/isolated/venv_comm.py | 4 +++- cpex/framework/isolated/worker.py | 17 ++++++++++++++--- cpex/framework/models.py | 2 +- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index f035a377..d49dd6dd 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -323,7 +323,10 @@ async def invoke_hook(self, hook_type: str, payload: PluginPayload, context: Plu result_dict: dict[str, Any] = await loop.run_in_executor( None, functools.partial( - self.comm.send_task, script_path="cpex/framework/isolated/worker.py", task_data=task_data, max_content_size=self.config.max_content_size + self.comm.send_task, + script_path="cpex/framework/isolated/worker.py", + task_data=task_data, + max_content_size=self.config.max_content_size, ), ) # Convert response to typed result diff --git a/cpex/framework/isolated/venv_comm.py b/cpex/framework/isolated/venv_comm.py index 1c7f1de5..4ef77163 100644 --- a/cpex/framework/isolated/venv_comm.py +++ b/cpex/framework/isolated/venv_comm.py @@ -161,7 +161,9 @@ def _read_responses(self) -> None: self.running = False logger.info("Response reader thread terminated") - def send_task(self, script_path: str, task_data: Any, timeout: float = 30.0, max_content_size: int = 10000000) -> Any: + def send_task( + self, script_path: str, task_data: Any, timeout: float = 30.0, max_content_size: int = 10000000 + ) -> Any: """ Send a task to the long-running worker process and get response. diff --git a/cpex/framework/isolated/worker.py b/cpex/framework/isolated/worker.py index 2aea8c55..3c7623fc 100644 --- a/cpex/framework/isolated/worker.py +++ b/cpex/framework/isolated/worker.py @@ -53,7 +53,14 @@ def compute_hash(self, json_config_or_module_path: str): hasher.update(json_config_or_module_path.encode()) return hasher.hexdigest() - def initialize(self, hook_ref: HookRef, executor: PluginExecutor, json_config: str, module_path: str, plugin_config: PluginConfig): + def initialize( + self, + hook_ref: HookRef, + executor: PluginExecutor, + json_config: str, + module_path: str, + plugin_config: PluginConfig, + ): """Assign locals, and compute hashes.""" self.hook_ref = hook_ref self.executor = executor @@ -119,7 +126,11 @@ async def process_task(task_data, tp: TaskProcessor): hook_ref = HookRef(hook_type, plugin_ref) executor = PluginExecutor(None, 30) tp.initialize( - hook_ref=hook_ref, executor=executor, json_config=json_config, module_path=json.dumps(resolved_paths), plugin_config=config + hook_ref=hook_ref, + executor=executor, + json_config=json_config, + module_path=json.dumps(resolved_paths), + plugin_config=config, ) # retrieve the context context = task_data.get("context") @@ -191,7 +202,7 @@ async def main(): logger.error("Serialized response exceeds max content size") error_response = { "status": "error", - "message": f"Serialized response exceeds max content size", + "message": "Serialized response exceeds max content size", "request_id": request_id, } serialized_response = json.dumps(error_response) diff --git a/cpex/framework/models.py b/cpex/framework/models.py index 5a913a8e..6e6de199 100644 --- a/cpex/framework/models.py +++ b/cpex/framework/models.py @@ -1194,7 +1194,7 @@ class PluginConfig(BaseModel): config (dict[str, Any]): the plugin specific configurations. mcp (Optional[MCPClientConfig]): Client-side MCP configuration (gateway connecting to plugin). grpc (Optional[GRPCClientConfig]): Client-side gRPC configuration (gateway connecting to plugin). - max_content_size (Optional(int)): The maximum size of payload, context, + max_content_size (Optional(int)): The maximum size of payload, context, """ name: str From 7b7573eaf8ba5761bcb89c7be6f82949f3d5919d Mon Sep 17 00:00:00 2001 From: habeck Date: Fri, 10 Apr 2026 13:24:59 -0400 Subject: [PATCH 59/60] chore: do not reference task_data in exception handler. Signed-off-by: habeck --- cpex/framework/isolated/worker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpex/framework/isolated/worker.py b/cpex/framework/isolated/worker.py index 3c7623fc..426b660f 100644 --- a/cpex/framework/isolated/worker.py +++ b/cpex/framework/isolated/worker.py @@ -221,7 +221,7 @@ async def main(): error_response = { "status": "error", "message": f"Unexpected error: {str(e)}", - "request_id": task_data.get("request_id", "unknown") if "task_data" in locals() else "unknown", + "request_id": "unknown", } print(json.dumps(error_response), flush=True) From 10da7b4717650225e05b3ffe56750988be8c0187 Mon Sep 17 00:00:00 2001 From: habeck Date: Fri, 10 Apr 2026 14:17:35 -0400 Subject: [PATCH 60/60] chore: update test Signed-off-by: habeck --- tests/unit/cpex/framework/isolated/test_worker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/cpex/framework/isolated/test_worker.py b/tests/unit/cpex/framework/isolated/test_worker.py index a64ed341..dd6d293c 100644 --- a/tests/unit/cpex/framework/isolated/test_worker.py +++ b/tests/unit/cpex/framework/isolated/test_worker.py @@ -344,7 +344,7 @@ async def test_main_unexpected_exception(self, mock_process_task, mock_print, mo output_data = json.loads(printed_output) assert output_data["status"] == "error" assert "Unexpected error: Unexpected error occurred" in output_data["message"] - assert output_data["request_id"] == "req-789" + assert output_data["request_id"] == "unknown" @pytest.mark.asyncio @patch("sys.stdin")