From a8a587de42b08f41e4250fd51ea160d57f98ea8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bogdan-Marius=20C=C4=83t=C4=83nu=C8=99?= <118976875+bogdanmariusc10@users.noreply.github.com> Date: Mon, 11 May 2026 19:04:50 +0300 Subject: [PATCH 1/8] fix: respect PLUGINS_LOG_LEVEL environment variable in all runtime.py files (#48) * fix: respect PLUGINS_LOG_LEVEL environment variable in all runtime.py files - Updated grpc/server/runtime.py to check PLUGINS_LOG_LEVEL env var before command-line arg - Updated mcp/server/runtime.py to add logging configuration with PLUGINS_LOG_LEVEL support - Updated unix/server/runtime.py to respect PLUGINS_LOG_LEVEL instead of hardcoded INFO level - All implementations now log to stderr and follow consistent pattern * refactor: use get_settings().log_level instead of hardcoded env var Address PR review feedback to use the settings infrastructure instead of directly accessing environment variables. This approach: - Keeps the env var name defined in one place (PluginsSettings model) - Gets .env file support for free - Stays consistent with how other settings are read in the codebase Changes: - grpc/server/runtime.py: Use get_settings().log_level with fallback to args - mcp/server/runtime.py: Use get_settings().log_level - unix/server/runtime.py: Use get_settings().log_level --------- Co-authored-by: Bogdan-Marius-Catanus --- cpex/framework/external/grpc/server/runtime.py | 8 ++++++-- cpex/framework/external/mcp/server/runtime.py | 13 ++++++++++++- cpex/framework/external/unix/server/runtime.py | 7 +++++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/cpex/framework/external/grpc/server/runtime.py b/cpex/framework/external/grpc/server/runtime.py index 8a133f90..233738d0 100644 --- a/cpex/framework/external/grpc/server/runtime.py +++ b/cpex/framework/external/grpc/server/runtime.py @@ -289,10 +289,14 @@ def main() -> None: args = parser.parse_args() - # Configure logging + # Configure logging - respect PLUGINS_LOG_LEVEL environment variable + settings = get_settings() + log_level_str = settings.log_level or args.log_level + log_level = getattr(logging, log_level_str.upper(), logging.INFO) logging.basicConfig( - level=getattr(logging, args.log_level), + level=log_level, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + stream=sys.stderr, ) # Run the server diff --git a/cpex/framework/external/mcp/server/runtime.py b/cpex/framework/external/mcp/server/runtime.py index 0c0dd60a..5bd592fc 100755 --- a/cpex/framework/external/mcp/server/runtime.py +++ b/cpex/framework/external/mcp/server/runtime.py @@ -79,7 +79,18 @@ MCP_SERVER_INSTRUCTIONS, MCP_SERVER_NAME, ) -from cpex.framework.settings import get_transport_settings + +# Configure logging - respect PLUGINS_LOG_LEVEL environment variable +from cpex.framework.settings import get_settings, get_transport_settings + +settings = get_settings() +log_level_str = settings.log_level +log_level = getattr(logging, log_level_str.upper(), logging.INFO) +logging.basicConfig( + level=log_level, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + stream=sys.stderr, +) logger = logging.getLogger(__name__) diff --git a/cpex/framework/external/unix/server/runtime.py b/cpex/framework/external/unix/server/runtime.py index 0856ed0c..fed4bbf8 100644 --- a/cpex/framework/external/unix/server/runtime.py +++ b/cpex/framework/external/unix/server/runtime.py @@ -33,9 +33,12 @@ from cpex.framework.external.unix.server.server import run_server from cpex.framework.settings import get_settings -# Configure logging +# Configure logging - respect PLUGINS_LOG_LEVEL environment variable +settings = get_settings() +log_level_str = settings.log_level +log_level = getattr(logging, log_level_str.upper(), logging.INFO) logging.basicConfig( - level=logging.INFO, + level=log_level, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", stream=sys.stderr, # Log to stderr to keep stdout clean for coordination ) From 5ed4b3dc8ede58666be13288f7aee958d8cb5218 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Mon, 18 May 2026 00:12:22 -0400 Subject: [PATCH 2/8] chore: update references to new git project name Signed-off-by: Frederico Araujo --- CHANGELOG.md | 4 ++-- CONTRIBUTING.md | 10 +++++----- README.md | 6 +++--- cpex/tools/cli.py | 2 +- docs/content/docs/vision.md | 14 +++++++------- docs/hugo.toml | 4 ++-- pyproject.toml | 2 +- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0185621..7a606879 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,5 +21,5 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - Initial release -[Unreleased]: https://github.com/contextforge-org/contextforge-plugins-framework/compare/0.1.0...HEAD -[0.1.0]: https://github.com/contextforge-org/contextforge-plugins-framework/releases/tag/0.1.0 \ No newline at end of file +[Unreleased]: https://github.com/contextforge-org/cpex/compare/0.1.0...HEAD +[0.1.0]: https://github.com/contextforge-org/cpex/releases/tag/0.1.0 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3eb88b3..7ce07a9b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,10 +5,10 @@ Our project welcomes external contributions. If you have an itch, please feel free to scratch it. -To contribute code or documentation, please submit a [pull request](https://github.com/contextforge-org/contextforge-plugins-framework/pulls). +To contribute code or documentation, please submit a [pull request](https://github.com/contextforge-org/cpex/pulls). A good way to familiarize yourself with the codebase and contribution process is -to look for and tackle low-hanging fruit in the [issue tracker](https://github.com/contextforge-org/contextforge-plugins-framework/issues). +to look for and tackle low-hanging fruit in the [issue tracker](https://github.com/contextforge-org/cpex/issues). Before embarking on a more ambitious contribution, please quickly [get in touch](#communication) with us. **Note: We appreciate your effort, and want to avoid a situation where a contribution @@ -17,14 +17,14 @@ cannot be accepted at all!** ### Proposing new features -If you would like to implement a new feature, please [raise an issue](https://github.com/contextforge-org/contextforge-plugins-framework/issues) +If you would like to implement a new feature, please [raise an issue](https://github.com/contextforge-org/cpex/issues) before sending a pull request so the feature can be discussed. This is to avoid you wasting your valuable time working on a feature that the project developers are not interested in accepting into the code base. ### Fixing bugs -If you would like to fix a bug, please [raise an issue](https://github.com/contextforge-org/contextforge-plugins-framework/issues) before sending a +If you would like to fix a bug, please [raise an issue](https://github.com/contextforge-org/cpex/issues) before sending a pull request so it can be tracked. ### Merge approval @@ -70,7 +70,7 @@ git commit -s ## Communication -Please feel free to connect with us through the [issue tracker](https://github.com/contextforge-org/contextforge-plugins-framework/issues). +Please feel free to connect with us through the [issue tracker](https://github.com/contextforge-org/cpex/issues). ## Setup diff --git a/README.md b/README.md index 09ad0460..2725ad15 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@
- ContextForge Plugin Extensibility Framework (CPEX) logo + ContextForge Plugin Extensibility Framework (CPEX) logo
# CPEX — ContextForge Plugin Extensibility Framework A composable enforcement framework for AI agents and toolchains. -[![CI](https://github.com/contextforge-org/contextforge-plugins-framework/actions/workflows/ci.yml/badge.svg)](https://github.com/contextforge-org/contextforge-plugins-framework/actions/workflows/ci.yml) +[![CI](https://github.com/contextforge-org/cpex/actions/workflows/ci.yml/badge.svg)](https://github.com/contextforge-org/cpex/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/) [![PyPI](https://img.shields.io/pypi/v/cpex.svg?color=blue)](https://pypi.org/project/cpex) -> [**Read the project vision**](https://contextforge-org.github.io/contextforge-plugins-framework/docs/vision/) to learn why hooks, plugins, and policy are the path to agent security. +> [**Read the project vision**](https://contextforge-org.github.io/cpex/docs/vision/) to learn why hooks, plugins, and policy are the path to agent security. ## What's CPEX? diff --git a/cpex/tools/cli.py b/cpex/tools/cli.py index 071ac01b..43599d33 100644 --- a/cpex/tools/cli.py +++ b/cpex/tools/cli.py @@ -44,7 +44,7 @@ # Configuration defaults # --------------------------------------------------------------------------- LOCAL_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" -DEFAULT_TEMPLATE_URL = "https://github.com/contextforge-org/contextforge-plugins-framework.git" +DEFAULT_TEMPLATE_URL = "https://github.com/contextforge-org/cpex.git" DEFAULT_AUTHOR_NAME = "" DEFAULT_AUTHOR_EMAIL = "" DEFAULT_PROJECT_DIR = Path("./.") diff --git a/docs/content/docs/vision.md b/docs/content/docs/vision.md index 6bcd8e2a..645a7d72 100644 --- a/docs/content/docs/vision.md +++ b/docs/content/docs/vision.md @@ -17,7 +17,7 @@ Hooks are standardized interception points placed at every boundary where an age This architecture deploys identically across the stack, inside LLM proxies, agent frameworks, and gateways. Each layer runs its own plugins. Prompt injection detection at the proxy. Tool authorization at the gateway. Data loss prevention at the agent. -![CPEX hooks deployed across the agent stack](/contextforge-plugins-framework/images/distributed_hooks_control_plane.png) +![CPEX hooks deployed across the agent stack](/cpex/images/distributed_hooks_control_plane.png) --- @@ -31,7 +31,7 @@ Enforcement is a three-layer problem. | **CMF** (Common Message Format) | What you evaluate. A protocol-agnostic context envelope carrying identity, security labels, delegation chains, and content. | | **APL** (Attribute Policy Language) | How you define policy. Declarative, attribute-based rules with explicit effects. | -![Hooks, CMF, and APL form a unified enforcement stack](/contextforge-plugins-framework/images/overview_vision.png) +![Hooks, CMF, and APL form a unified enforcement stack](/cpex/images/overview_vision.png) Hooks make enforcement **possible**. Policy makes it **usable**. Context makes it **correct**. @@ -41,7 +41,7 @@ Hooks make enforcement **possible**. Policy makes it **usable**. Context makes i Different policy types require different enforcement points. CPEX provides hooks at every layer, from soft stylistic policies enforced at the prompt level to hard compliance requirements enforced at infrastructure boundaries. -![Policy spectrum: each policy type maps to a different enforcement point](/contextforge-plugins-framework/images/policy_spectrum.png) +![Policy spectrum: each policy type maps to a different enforcement point](/cpex/images/policy_spectrum.png) --- @@ -49,7 +49,7 @@ Different policy types require different enforcement points. CPEX provides hooks An application or framework invokes a hook at a critical operation boundary. The plugin manager dispatches registered plugins (sequentially, concurrently, or fire-and-forget) and returns a result. Plugins can **allow** execution to continue, **block** it with a violation, or **modify** the payload using copy-on-write isolation. -![Plugin execution model: agent → middleware → hook → manager → plugins](/contextforge-plugins-framework/images/integration_execution_model.png) +![Plugin execution model: agent → middleware → hook → manager → plugins](/cpex/images/integration_execution_model.png) The plugin manager handles registration, ordering, timeouts, error isolation, and payload chaining. You get a deterministic enforcement pipeline with no surprises. @@ -67,7 +67,7 @@ CPEX is under active development. The current Python framework is production-rea - **Plugin catalog.** Discovery, versioning, and installation of plugins from registries. Multiple instances from a single manifest, managed through the CLI. -See the [GitHub milestones](https://github.com/contextforge-org/contextforge-plugins-framework/milestones) and [open issues](https://github.com/contextforge-org/contextforge-plugins-framework/issues) for details. +See the [GitHub milestones](https://github.com/contextforge-org/cpex/milestones) and [open issues](https://github.com/contextforge-org/cpex/issues) for details. --- @@ -84,7 +84,7 @@ See the [GitHub milestones](https://github.com/contextforge-org/contextforge-plu CPEX is part of the [ContextForge](https://github.com/contextforge-org) ecosystem. -- [CPEX Plugin Framework](https://github.com/contextforge-org/contextforge-plugins-framework) (this project) -- [Contributing Guide](https://github.com/contextforge-org/contextforge-plugins-framework/blob/main/CONTRIBUTING.md) +- [CPEX Plugin Framework](https://github.com/contextforge-org/cpex) (this project) +- [Contributing Guide](https://github.com/contextforge-org/cpex/blob/main/CONTRIBUTING.md) Contributions, feedback, and plugin ideas are welcome. Open an issue or submit a pull request. diff --git a/docs/hugo.toml b/docs/hugo.toml index 69375173..d48991eb 100644 --- a/docs/hugo.toml +++ b/docs/hugo.toml @@ -1,4 +1,4 @@ -baseURL = "https://contextforge-org.github.io/contextforge-plugins-framework/" +baseURL = "https://contextforge-org.github.io/cpex/" languageCode = "en-us" title = "CPEX Documentation" theme = "hugo-book" @@ -7,7 +7,7 @@ theme = "hugo-book" BookTheme = "auto" BookToC = true BookSection = "docs" - BookRepo = "https://github.com/contextforge-org/contextforge-plugins-framework" + BookRepo = "https://github.com/contextforge-org/cpex" BookSearch = true BookComments = false BookEditLink = '{{ .Site.Params.BookRepo }}/edit/main/docs/{{ .Path }}' diff --git a/pyproject.toml b/pyproject.toml index d540f418..8c53c4f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ cpex = "cpex.tools.cli:main" [project.urls] -Repository = "https://github.com/contextforge-org/contextforge-plugins-framework" +Repository = "https://github.com/contextforge-org/cpex" [project.optional-dependencies] From 8f3738b2c82f20c0b56a1bc17755f62328702383 Mon Sep 17 00:00:00 2001 From: prakhar-singh1928 Date: Fri, 29 May 2026 14:18:11 +0100 Subject: [PATCH 3/8] fix: implement __eq__ and __ne__ for CopyOnWriteDict (#55) * fix: implement __eq__ and __ne__ for CopyOnWriteDict Fixes equality comparison bug where CopyOnWriteDict compared equal to {} even when containing data. This caused apply_policy() to incorrectly drop valid payload modifications when plugins removed all arguments. Changes: - Add __eq__ and __ne__ methods to CopyOnWriteDict - Add 13 comprehensive equality unit tests - Add policy regression tests for empty args scenario - Add end-to-end integration tests Signed-off-by: prakhar-singh1928 * fix: added length check for performance Signed-off-by: prakhar-singh1928 * fix: restore deleted assertion and add performance optimization - Restored missing 'assert a not in keys' in test_iteration_order_with_deletions - Added fast-path length check in CopyOnWriteDict.__eq__() for better performance - Performance optimization is safe: if lengths differ, mappings cannot be equal Signed-off-by: prakhar-singh1928 * fix: linted memory.py, added assertion to test. --------- Signed-off-by: prakhar-singh1928 Co-authored-by: Teryl Taylor --- cpex/framework/memory.py | 43 ++++++++ pyproject.toml | 4 + tests/unit/cpex/framework/test_memory.py | 98 ++++++++++++++++++ tests/unit/cpex/framework/test_policies.py | 114 +++++++++++++++++++++ 4 files changed, 259 insertions(+) diff --git a/cpex/framework/memory.py b/cpex/framework/memory.py index dadfbcee..ec2dc8d6 100644 --- a/cpex/framework/memory.py +++ b/cpex/framework/memory.py @@ -14,6 +14,7 @@ import copy import logging import weakref +from collections.abc import Mapping from typing import Any, Iterator, Optional, TypeVar # Third-Party @@ -173,6 +174,48 @@ def __repr__(self) -> str: """ return f"CopyOnWriteDict({dict(self.items())})" + __hash__ = None + + def __eq__(self, other: Any) -> bool: + """ + Compare equality with another mapping. + + Compares the materialized logical mapping (original + modifications - deletions) + rather than the empty base dict storage. + + Args: + other: The object to compare with. + + Returns: + True if other is a Mapping with the same key-value pairs, False otherwise. + Returns NotImplemented for non-Mapping types to allow other.__eq__ to handle it. + """ + if not isinstance(other, Mapping): + return NotImplemented + + # Fast-path: if lengths differ, mappings cannot be equal + if len(self) != len(other): + return False + + # Compare materialized items + return dict(self.items()) == dict(other.items()) + + def __ne__(self, other: Any) -> bool: + """ + Compare inequality with another mapping. + + Args: + other: The object to compare with. + + Returns: + True if not equal, False if equal. + Returns NotImplemented for non-Mapping types. + """ + eq = self.__eq__(other) + if eq is NotImplemented: + return NotImplemented + return not eq + def get(self, key: Any, default: Optional[Any] = None) -> Any: """ Get an item with a default fallback. diff --git a/pyproject.toml b/pyproject.toml index 8c53c4f5..e947d1ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -146,6 +146,10 @@ preview = true fixable = ["ALL"] unfixable = [] +[tool.ruff.lint.pylint] +# Relaxed from the default of 5; existing code has wider try clauses (max observed 38). +max-statements-in-try = 50 + # Ignore D1 (docstring checks) and Pylint checks in tests and other non-production code [tool.ruff.lint.per-file-ignores] "tests/**/*.py" = ["D1", "PL"] diff --git a/tests/unit/cpex/framework/test_memory.py b/tests/unit/cpex/framework/test_memory.py index 070a0785..30ff6221 100644 --- a/tests/unit/cpex/framework/test_memory.py +++ b/tests/unit/cpex/framework/test_memory.py @@ -819,6 +819,104 @@ def test_iter_skips_deleted_keys_in_modifications(self): assert set(keys) == {"b", "c"} assert "a" not in keys + def test_equality_with_empty_dict(self): + """CopyOnWriteDict with data should not equal empty dict.""" + cow = CopyOnWriteDict({"a": 1, "b": 2}) + assert cow != {} + assert {} != cow + assert not (cow == {}) + assert not ({} == cow) + + def test_equality_with_matching_dict(self): + """CopyOnWriteDict should equal dict with same key-value pairs.""" + original = {"a": 1, "b": 2, "c": 3} + cow = CopyOnWriteDict(original) + assert cow == {"a": 1, "b": 2, "c": 3} + assert {"a": 1, "b": 2, "c": 3} == cow + + def test_equality_with_different_dict(self): + """CopyOnWriteDict should not equal dict with different content.""" + cow = CopyOnWriteDict({"a": 1, "b": 2}) + assert cow != {"a": 1, "b": 3} + assert cow != {"a": 1} + assert cow != {"a": 1, "b": 2, "c": 3} + # Same length, different keys + assert cow != {"a": 1, "c": 2} + + def test_equality_after_modifications(self): + """Equality should reflect modifications.""" + cow = CopyOnWriteDict({"a": 1, "b": 2}) + cow["c"] = 3 + assert cow == {"a": 1, "b": 2, "c": 3} + assert cow != {"a": 1, "b": 2} + + def test_equality_after_deletions(self): + """Equality should reflect deletions.""" + cow = CopyOnWriteDict({"a": 1, "b": 2, "c": 3}) + del cow["b"] + assert cow == {"a": 1, "c": 3} + assert cow != {"a": 1, "b": 2, "c": 3} + + def test_equality_after_override(self): + """Equality should reflect overridden values.""" + cow = CopyOnWriteDict({"a": 1, "b": 2}) + cow["a"] = 10 + assert cow == {"a": 10, "b": 2} + assert cow != {"a": 1, "b": 2} + + def test_equality_with_another_copyonwritedict(self): + """Two CopyOnWriteDict instances with same content should be equal.""" + cow1 = CopyOnWriteDict({"a": 1, "b": 2}) + cow2 = CopyOnWriteDict({"a": 1, "b": 2}) + assert cow1 == cow2 + assert cow2 == cow1 + + def test_equality_empty_copyonwritedict(self): + """Empty CopyOnWriteDict should equal empty dict.""" + cow = CopyOnWriteDict({}) + assert cow == {} + assert {} == cow + + def test_equality_with_non_mapping_returns_notimplemented(self): + """Equality with non-Mapping types should return NotImplemented.""" + cow = CopyOnWriteDict({"a": 1}) + # These should not raise, Python will handle NotImplemented + assert cow != "not a dict" + assert cow != 123 + assert cow != ["a", "list"] + assert cow != None + + def test_inequality_operator(self): + """Test __ne__ operator works correctly.""" + cow = CopyOnWriteDict({"a": 1, "b": 2}) + assert cow != {} + assert cow != {"a": 1} + assert not (cow != {"a": 1, "b": 2}) + + def test_copyonwritedict_is_unhashable(self): + """CopyOnWriteDict should remain unhashable like dict.""" + cow = CopyOnWriteDict({"a": 1}) + with pytest.raises(TypeError): + hash(cow) + + def test_equality_wxo_args_scenario(self): + """Regression test for the WXO args bug scenario.""" + # This is the exact scenario from the bug report + cow = CopyOnWriteDict({ + "wxo_connection_id": "", + "wxo_auth": "fake-token", + "wxo_environment_id": "draft", + }) + + # These were the failing assertions in the bug + assert cow != {} + assert {} != cow + assert cow == { + "wxo_connection_id": "", + "wxo_auth": "fake-token", + "wxo_environment_id": "draft", + } + class TestCopyOnWriteFunction: """Test suite for copyonwrite() factory function.""" diff --git a/tests/unit/cpex/framework/test_policies.py b/tests/unit/cpex/framework/test_policies.py index ef674566..7726c49d 100644 --- a/tests/unit/cpex/framework/test_policies.py +++ b/tests/unit/cpex/framework/test_policies.py @@ -172,6 +172,65 @@ class PayloadWithModel(PluginPayload): assert result is not None assert result.nested.x == 99 # type: ignore[union-attr] + def test_copyonwritedict_args_empty_modification_preserved(self): + """Regression test for bug where CopyOnWriteDict equality caused + apply_policy to drop valid empty args modification. + + When a plugin receives args as CopyOnWriteDict with data and returns + an empty dict, apply_policy should treat this as a valid modification. + Previously, CopyOnWriteDict.__eq__ was not implemented, causing the + comparison to use dict's default equality which compared the empty + base storage, incorrectly returning True for CopyOnWriteDict({...}) == {}. + """ + from cpex.framework.memory import CopyOnWriteDict + + policy = HookPayloadPolicy(writable_fields=frozenset({"args"})) + + # Simulate plugin receiving payload with CopyOnWriteDict args + original = SamplePayload( + name="test", + args=CopyOnWriteDict({ + "wxo_connection_id": "", + "wxo_auth": "fake-token", + "wxo_environment_id": "draft", + }), + secret="s", + ) + + # Plugin strips all args, returning empty dict + modified = SamplePayload(name="test", args={}, secret="s") + + result = apply_policy(original, modified, policy) + + # The modification should be preserved, not dropped + assert result is not None, "apply_policy should not return None when args changed from {...} to {}" + assert result.args == {} # type: ignore[union-attr] + assert result.name == "test" # type: ignore[union-attr] + assert result.secret == "s" # type: ignore[union-attr] + + def test_copyonwritedict_args_partial_modification_preserved(self): + """Test that partial arg removal is also preserved correctly.""" + from cpex.framework.memory import CopyOnWriteDict + + policy = HookPayloadPolicy(writable_fields=frozenset({"args"})) + + original = SamplePayload( + name="test", + args=CopyOnWriteDict({ + "wxo_auth": "token", + "real_arg": "value", + }), + secret="s", + ) + + # Plugin removes only wxo_auth, keeping real_arg + modified = SamplePayload(name="test", args={"real_arg": "value"}, secret="s") + + result = apply_policy(original, modified, policy) + + assert result is not None + assert result.args == {"real_arg": "value"} # type: ignore[union-attr] + class TestPluginPayloadFrozen: """Tests for frozen PluginPayload base class.""" @@ -752,6 +811,61 @@ async def tool_pre_invoke(self, payload, context): assert result.modified_payload.secret == "safe" # Policy filtered this out + @pytest.mark.asyncio + async def test_tool_pre_invoke_empty_args_modification_preserved_through_executor(self): + """Regression test for the tool_pre_invoke executor path. + + A plugin receives CoW-wrapped args containing only specific fields, + strips them all, and returns a payload with args={}. The executor should + preserve that empty args modification instead of dropping it as + "unchanged". + """ + from cpex.framework.base import HookRef, Plugin, PluginRef + from cpex.framework.hooks.policies import HookPayloadPolicy + from cpex.framework.hooks.tools import ToolPreInvokePayload + from cpex.framework.manager import PluginExecutor + from cpex.framework.memory import CopyOnWriteDict + from cpex.framework.models import GlobalContext, PluginConfig, PluginResult + + seen_arg_types = [] + + class StripWxoArgsPlugin(Plugin): + async def tool_pre_invoke(self, payload, context): + seen_arg_types.append(type(payload.args)) + cleaned_args = {k: v for k, v in payload.args.items() if not k.startswith("wxo_")} + modified = payload.model_copy(update={"args": cleaned_args}) + return PluginResult(continue_processing=True, modified_payload=modified) + + policies = { + "tool_pre_invoke": HookPayloadPolicy(writable_fields=frozenset({"args"})), + } + executor = PluginExecutor(hook_policies=policies) + + config = PluginConfig(name="stripper", kind="test.Plugin", version="1.0", hooks=["tool_pre_invoke"]) + plugin = StripWxoArgsPlugin(config) + hook_ref = HookRef("tool_pre_invoke", PluginRef(plugin)) + + payload = ToolPreInvokePayload( + name="list_all_secrets", + args={ + "wxo_connection_id": "", + "wxo_auth": "fake-token", + "wxo_environment_id": "draft", + }, + ) + global_ctx = GlobalContext(request_id="tool-pre-empty-args") + + result, _ = await executor.execute([hook_ref], payload, global_ctx, hook_type="tool_pre_invoke") + + assert seen_arg_types == [CopyOnWriteDict] + assert result.modified_payload is not None + assert result.modified_payload == ToolPreInvokePayload(name="list_all_secrets", args={}) + assert payload.args == { + "wxo_connection_id": "", + "wxo_auth": "fake-token", + "wxo_environment_id": "draft", + } + class TestMultiPluginDictChain: """Tests for multi-plugin chains where an earlier plugin returns a dict payload.""" From af53214cca601df0f158fb68746278d57973840b Mon Sep 17 00:00:00 2001 From: Teryl Taylor Date: Fri, 29 May 2026 07:46:36 -0600 Subject: [PATCH 4/8] chore: bump version to 0.1.1.dev1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e947d1ff..5498a853 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cpex" -version = "0.1.0" +version = "0.1.1.dev1" description = "CPEX - ContextForge Plugin Extensibility Framework" classifiers = [ "Topic :: Scientific/Engineering :: Artificial Intelligence", From c94b1a7c55ef453bdee63e71fda2992a4b0a4761 Mon Sep 17 00:00:00 2001 From: tedhabeck Date: Mon, 1 Jun 2026 15:20:27 -0400 Subject: [PATCH 5/8] feat: plugin bundling, catalog, installation and versioning (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: provde better examples for PluginPackageInfo constructor Signed-off-by: habeck * enh: add PluginVersionInfo and PluginVersionRegistry models w/unit tests Signed-off-by: habeck * chore: lint fix Signed-off-by: habeck * chore: lint fix Signed-off-by: habeck * chore: unit tests and fixtures for plugin isolation via venv. Signed-off-by: habeck * enh: refactored the invoke_hook method in cpex/framework/isolated/client.py to run async Signed-off-by: habeck * chore: lint fix Signed-off-by: habeck * chore: updated unit test test_worker to get coverage to 97%. Signed-off-by: habeck * 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 * fix: fail early plugin_path do not exist, computer .venv path automatically, update cli to support creating an isolated plugin. Signed-off-by: habeck * chore: lint fix Signed-off-by: habeck * fix: use the system config file (PLUGINS_CONFIG_FILE) for syspath update (Consistent with how the PluginManager works). Signed-off-by: habeck * chore: lint fix Signed-off-by: habeck * chore: Validate plugin_dirs entries against an allowlist Signed-off-by: habeck * 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 * chore: lint fix Signed-off-by: habeck * enh: Add a maximum line length check before parsing. Add model tests for PluginPackageInfo and PluginVersionRegistry Signed-off-by: habeck * enh: model updates for PluginManifest, InstalledPluginInfo, and InstalledPluginRegistry Signed-off-by: habeck * enh: example values for git monorepo installation Signed-off-by: habeck * enh: add ConfigSaver class to ConfigLoader Signed-off-by: habeck * enh: plugin installation catalog Signed-off-by: habeck * enh: add support to enable installation of a plugin using the cli from a git monorepo, or pypi. Signed-off-by: habeck * chore: doc string fix Signed-off-by: habeck * chore: lint fix Signed-off-by: habeck * chore: remove duplicate code Signed-off-by: habeck * chore: test coverage improvements, remove duplicate code Signed-off-by: habeck * chore: replace cargo with search for pyproject. Signed-off-by: habeck * chore: lint fixes Signed-off-by: habeck * enh: use pygithub apis rather than github rest apis, as they provide automatic backoff when github response with too many requests. Signed-off-by: habeck * enh: add support for uninstall of plugin Signed-off-by: habeck * chore: lint-fix Signed-off-by: habeck * fix: use the manifest from the local catalog to pull the kind value of the package to be removed and use that to remove all matching kind entries from plugins/config.yaml unless kind is external or isolated_venv in which case check if the plugin name is a substring of the plugin name. Signed-off-by: habeck * fix: when installing a plugin via mono-repo or pipi, the cache_root will not exist under the plugins dir, create the directory if it does not exist on plugin startup for venv plugins. Signed-off-by: habeck * enh: enable package install from both pypi and test-pypi, fix: properly resolve location of requirements.txt while performing pypi installs. Signed-off-by: habeck * chore: stub for local installation Signed-off-by: habeck * chore: lint fix Signed-off-by: habeck * doc: add README for tools Signed-off-by: habeck * enh: use cached repo object Signed-off-by: habeck * chore: use rich emoji Signed-off-by: habeck * ptf: workaround for version mis-match of cpex dependency in plugin Signed-off-by: habeck * fix: download install targets to a temp folder to avoid installing to incorrect venv. Signed-off-by: habeck * enh: pass the plugin install path to the update method, as isolated_venv plugins are not installed in the current venv. Signed-off-by: habeck * enh: the catalog now returns the install path for isolated_venv plugins Signed-off-by: habeck * chore: unit test updates Signed-off-by: habeck * misc: type fix Signed-off-by: habeck * enh: the plugin self installs into the isolated_venv via requirements.txt Signed-off-by: habeck * chore: increase coverage above 90% Signed-off-by: habeck * chore: update min_max_framework_version Signed-off-by: habeck * enh: isolated venv cookiecutter update for install flow Signed-off-by: habeck * enh: catalog now properly persists all plugin-manifest*.yaml files Signed-off-by: habeck * enh: upgrade pip before installing requirements Signed-off-by: habeck * enh: allow the developer provided version registry values to persist, overriding only if they are not present. Improved install path resolution for isolated_venv plugins Signed-off-by: habeck * enh: only update the catalog when not installing from test-pypi or pypi. Correctly determine the install path for the plugin registry for monorepo installs and isolated_venv plugins. Signed-off-by: habeck * enh: refactor to reduce duplicate code, fix uninstall for isolated_venv, add install support for type local, Signed-off-by: habeck * chore: properly format info Signed-off-by: habeck * chore: update README.md Signed-off-by: habeck * chore: Add a, "before you begin" section detailing the required .env variable. Signed-off-by: habeck * fix: P0 fix — tarfile/zip path traversal Signed-off-by: habeck * enh: add remove_venv method to IsolatedVenvPlugin for uninstall cleanup. Signed-off-by: habeck * fix: priority 1 items Signed-off-by: habeck * fix: p2 item 17 search() case-insensitive match broken Signed-off-by: habeck * chore: add tests for _ver method. Signed-off-by: habeck * fix: version registry update cleanup Signed-off-by: habeck * chore: lint fix Signed-off-by: habeck * chore: add missing doc string, and tests for _ver method. Signed-off-by: habeck * chore: lint fix Signed-off-by: habeck * fix: review p2 moderate 21 - list function now uses console.print Signed-off-by: habeck * chore: fix failing unit test, address non-atomic registry write. Signed-off-by: habeck * chore: lint fix Signed-off-by: habeck * chore: claude can't tell the difference between if "rc is False" and "if rc". Signed-off-by: habeck * fix: cpex/framework/models.py — register_plugin (line 2392): replaced the unconditional append with a filter-then-append. Any existing entry with the same name is removed before the new one is added, so a reinstall upgrades the entry rather than creating a duplicate. One save() call, same atomicity as before. Signed-off-by: habeck * fix: P2 Issue 20 Implementation Complete: Exit Code Handling Signed-off-by: habeck * chore: lint fix Signed-off-by: habeck * fix: list function shadows built-in Signed-off-by: habeck * chore: logic tweak Signed-off-by: habeck * fix: p2 issue 23 Signed-off-by: habeck * fix: P2 issue 19 error handling for corrupted JSON registry Signed-off-by: habeck * chore: lint fix Signed-off-by: habeck * fix: P2 issue 24 - Registry file path triplicated Signed-off-by: habeck * chore: lint-fix Signed-off-by: habeck * fix: P2 issue 25 Signed-off-by: habeck * fix: update worker to call cpex.framework.utils.import_module rather than importlib.import_module directly. Signed-off-by: habeck * enh: add package integrity verification Signed-off-by: habeck * chore: lint fix Signed-off-by: habeck * chore: missed commit Signed-off-by: habeck * fix: if the plugins/config.yaml plugins array is empty, initialize it with the appropriate default from catalog settings. Signed-off-by: habeck * chore: lint fix Signed-off-by: habeck * chore: version to '0.1.0 minimum' Signed-off-by: habeck --------- Signed-off-by: habeck --- .env.example | 28 + .gitignore | 2 +- cpex/framework/isolated/client.py | 38 +- cpex/framework/isolated/venv_comm.py | 8 + cpex/framework/isolated/worker.py | 25 +- cpex/framework/loader/config.py | 19 + cpex/framework/models.py | 322 +- cpex/framework/utils.py | 41 + .../requirements.txt | 8 +- cpex/tools/README.md | 207 + cpex/tools/catalog.py | 1822 +++++++++ cpex/tools/cli.py | 710 +++- cpex/tools/integrity.py | 301 ++ cpex/tools/plugin_registry.py | 138 + cpex/tools/settings.py | 68 + docs/content/docs/package-integrity.md | 323 ++ pyproject.toml | 5 +- .../unit/cpex/framework/isolated/conftest.py | 54 +- .../cpex/framework/isolated/test_client.py | 19 + .../cpex/framework/isolated/test_venv_comm.py | 487 ++- .../cpex/framework/isolated/test_worker.py | 32 +- tests/unit/cpex/tools/test_catalog.py | 3625 +++++++++++++++++ tests/unit/cpex/tools/test_cli.py | 1618 +++++++- tests/unit/cpex/tools/test_integrity.py | 421 ++ 24 files changed, 10198 insertions(+), 123 deletions(-) create mode 100644 cpex/tools/README.md create mode 100644 cpex/tools/catalog.py create mode 100644 cpex/tools/integrity.py create mode 100644 cpex/tools/plugin_registry.py create mode 100644 cpex/tools/settings.py create mode 100644 docs/content/docs/package-integrity.md create mode 100644 tests/unit/cpex/tools/test_catalog.py create mode 100644 tests/unit/cpex/tools/test_integrity.py diff --git a/.env.example b/.env.example index b235ca31..c755e137 100644 --- a/.env.example +++ b/.env.example @@ -6,9 +6,25 @@ # `allow`, `deny` # PLUGINS_DEFAULT_HOOK_POLICY=allow +# Path to plugins folder +# PLUGINS_FOLDER=plugins # Path to main plugins configuration file # PLUGINS_CONFIG_FILE=plugins/config.yaml +### Plugin installation +# Comma Separated Values used by install with --type monorepo +# PLUGINS_REPO_URLS="https://github.com/ibm/cpex-plugins" + +# registry path +# PLUGIN_REGISTRY_FOLDER=data + +# Github API +# PLUGINS_GITHUB_API=api.github.com + +# PLUGINS_GITHUB_TOKEN= +### end Plugin installation + + # Logging level for plugin framework components # PLUGINS_LOG_LEVEL=INFO @@ -148,3 +164,15 @@ # PLUGINS_GRPC_SERVER_SSL_ENABLED= + +### Package Integrity Verification +# Enable SHA256 hash verification for PyPI packages (default: True) +# When enabled, downloaded packages are verified against hashes from PyPI's JSON API +# Recommended: Keep enabled for security +# PLUGINS_VERIFY_PACKAGE_INTEGRITY=True + +# Strict integrity mode (default: False) +# When True: Fail installation if package hashes are unavailable +# When False: Warn but continue if hashes are unavailable +# Recommended: False for development, True for production +# PLUGINS_STRICT_INTEGRITY_MODE=False diff --git a/.gitignore b/.gitignore index beb86085..59e72586 100644 --- a/.gitignore +++ b/.gitignore @@ -250,4 +250,4 @@ db_path/ tmp/ .continue - +plugin-catalog \ No newline at end of file diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py index d49dd6dd..c61a3981 100644 --- a/cpex/framework/isolated/client.py +++ b/cpex/framework/isolated/client.py @@ -27,6 +27,7 @@ from cpex.framework.hooks.registry import get_hook_registry from cpex.framework.isolated.venv_comm import VenvProcessCommunicator from cpex.framework.models import PluginConfig, PluginContext, PluginErrorModel, PluginPayload, PluginResult +from cpex.framework.utils import find_package_path logger = logging.getLogger(__name__) @@ -43,10 +44,10 @@ def __init__(self, config: PluginConfig, plugin_dirs) -> None: # 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 + cache_root: Path = path / class_root + self.plugin_path: Path = cache_root if not cache_root.exists(): - raise RuntimeError(f"plugin path does not exist: {str(cache_root)}") + cache_root.mkdir(parents=True, exist_ok=True) self.cache_dir: Path = cache_root / ".cpex" / "venv_cache" self.cache_dir.mkdir(parents=True, exist_ok=True) @@ -217,21 +218,17 @@ async def initialize(self) -> None: 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 to find the package location where plugin-manifest.yaml resides + # Fall back to self.plugin_path if package is not installed (e.g., in tests) try: - requirements_file.relative_to(self.plugin_path.resolve()) - 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 + package_path = find_package_path(self.config.name) + logger.debug("Found installed package %s at %s", self.config.name, package_path) + except RuntimeError: + # Package not installed (e.g., in test environment), use plugin_path + package_path = self.plugin_path + logger.debug("Package %s not installed, using plugin_path: %s", self.config.name, package_path) + + requirements_file = package_path / requirements_file_input # Create venv with caching support new_venv = await self.create_venv(venv_path=venv_path, requirements_file=requirements_file, use_cache=True) @@ -339,3 +336,10 @@ async def invoke_hook(self, hook_type: str, payload: PluginPayload, context: Plu except Exception as 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 + + def remove_venv(self): + """ + Remove the virtual environment associated with the plugin. + """ + shutil.rmtree(self.plugin_path.joinpath(".cpex")) + shutil.rmtree(self.plugin_path.joinpath(".venv")) diff --git a/cpex/framework/isolated/venv_comm.py b/cpex/framework/isolated/venv_comm.py index 4ef77163..c7325236 100644 --- a/cpex/framework/isolated/venv_comm.py +++ b/cpex/framework/isolated/venv_comm.py @@ -53,6 +53,13 @@ def _get_python_executable(self): return str(python_exe) + def upgrade_pip(self) -> None: + """Upgrade pip in the target venv.""" + try: + subprocess.check_call([self.python_executable, "-m", "pip", "install", "--upgrade", "pip"]) + except Exception as e: + raise RuntimeError("Failed to upgrade pip") from e + def install_requirements(self, requirements_file: str) -> None: """ Install Python requirements from a file in the target venv. @@ -62,6 +69,7 @@ def install_requirements(self, requirements_file: str) -> None: requirements_path = Path(requirements_file) if requirements_path.exists(): try: + self.upgrade_pip() 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 diff --git a/cpex/framework/isolated/worker.py b/cpex/framework/isolated/worker.py index 426b660f..74deee05 100644 --- a/cpex/framework/isolated/worker.py +++ b/cpex/framework/isolated/worker.py @@ -24,7 +24,7 @@ from cpex.framework.loader.plugin import ALLOWED_PLUGIN_DIRS from cpex.framework.manager import PluginExecutor from cpex.framework.models import PluginConfig, PluginContext -from cpex.framework.utils import parse_class_name +from cpex.framework.utils import import_module, parse_class_name logger = logging.getLogger(__name__) @@ -115,7 +115,7 @@ async def process_task(task_data, tp: TaskProcessor): 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) + module: ModuleType = 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_) @@ -162,7 +162,7 @@ async def main(): while True: try: # Read one line at a time - if tp.plugin_config: + if tp.plugin_config and "max_content_size" in 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. @@ -198,14 +198,17 @@ async def main(): serialized_response = json.dumps(serializable_response) # Send response back to parent (one line per response) 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": "Serialized response exceeds max content size", - "request_id": request_id, - } - serialized_response = json.dumps(error_response) + # workaround until cpex is updated beyond dev11 + # cpex is a dependency of the plugin and as such it's PluginConfig does not contain the max_content_size yet. + if "max_content_size" in 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": "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: diff --git a/cpex/framework/loader/config.py b/cpex/framework/loader/config.py index 93a99bfc..a7b1eb70 100644 --- a/cpex/framework/loader/config.py +++ b/cpex/framework/loader/config.py @@ -79,3 +79,22 @@ def load_config(config: str, use_jinja: bool = True) -> Config: except FileNotFoundError: # Graceful fallback for tests and minimal environments without plugin config return Config(plugins=[], plugin_dirs=[]) + + +class ConfigSaver: + """ + A configuration saver + """ + + @staticmethod + def save_config(config: Config, config_path: str) -> None: + """ + Save the supplied configuration data to the filesystem + """ + try: + updated_content = yaml.safe_dump(config.model_dump(mode="json"), default_flow_style=False) + with open(os.path.normpath(config_path), "w", encoding="utf-8") as file: + file.write(updated_content) + file.flush() + except OSError as ose: + raise RuntimeError(f"Error saving PluginConfig to {config_path}") from ose diff --git a/cpex/framework/models.py b/cpex/framework/models.py index efda1d50..e85ffcf3 100644 --- a/cpex/framework/models.py +++ b/cpex/framework/models.py @@ -11,9 +11,11 @@ # Standard import asyncio +import contextlib import logging import os import re +import tempfile from datetime import datetime from enum import Enum, StrEnum from pathlib import Path @@ -1393,27 +1395,265 @@ 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) + return data +class Monorepo(BaseModel): + """Monorepo model. + Attributes: + repo_url (str): The URL of the git monorepo. e.g. https://github.ibm.com/habeck/contextforge-plugins-python + package_source (str): The URL of a specifc plugin folder in the git monorepo + e.g. pii_filter + The cpex cli injects the value when it scans the repo. + """ + + repo_url: str + package_source: str + package_folder: str + + +class PyPiRepo(BaseModel): + """PyPi model. + Attributes: + name (str): The name of the pypi package. + """ + + pypi_package: str + 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("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 + + +class GitRepo(BaseModel): + """Git repository model. + Attributes: + git_repository: The URL of the git repository. + git_branch_tag_commit: The branch, tag or commit of the git repository. + """ + + git_repository: str = Field( + title="URL", + description='The URL of the git repository. (e.g., "https://github.com/example/plugin.git")', + ) + git_branch_tag_commit: Optional[str] = Field( + title="Branch, tag or commit", + description="The branch, tag or commit of the git repository.", + ) + + @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@ + 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 + 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 + + class PluginManifest(BaseModel): """Plugin manifest. Attributes: + name (str): The name of the plugin. + kind (str): The class name (for native plugins) | external | isolated_venv description (str): A description of the plugin. author (str): The author of the plugin. version (str): version of the plugin. tags (list[str]): a list of tags for making the plugin searchable. available_hooks (list[str]): a list of the hook points where the plugin is callable. default_config (dict[str, Any]): the default configurations. + monorepo (Monorepo): A git monorepo where the plugin originates (Initialized by cepx cli during plugin installation) + package_info: (PyPiRepo): The package name and version constraint of the package (Initialized by cepx cli during plugin installation) + local: The path to the locally installed plugin (Initialized by cepx cli during plugin installation) + git_repo: GitRepo: The git repo where the plugin originates (Initialized by cepx cli during plugin installation) """ + name: str + kind: str description: str author: str version: str tags: list[str] available_hooks: list[str] default_config: dict[str, Any] + monorepo: Optional[Monorepo] = None + package_info: Optional[PyPiRepo] = None + local: Optional[str] = None + git_repo: Optional[GitRepo] = None + + def suggest_instance_name(self) -> str: + """Suggest a name for the plugin instance. + Returns: + str: A suggested name for the plugin instance. + """ + return self.name.lower().replace(" ", "-") + + def create_instance_config( + self, instance_name: str, mode: PluginMode, priority: int = 100, config: Optional[dict[str, Any]] = None + ) -> PluginConfig: + """Create a plugin instance config. + Returns: + PluginConfig: A plugin instance config. + """ + new_config = self.default_config.copy() + if config is not None: + new_config.update(config) + return PluginConfig( + name=instance_name, + kind=self.kind, + mode=mode, + priority=priority, + description=self.description, + author=self.author, + version=self.version, + tags=self.tags, + hooks=self.available_hooks, + config=new_config, + ) class PluginErrorModel(BaseModel): @@ -1850,8 +2090,8 @@ class PluginPackageInfo(BaseModel): Examples: >>> pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git", - ... git_branch_tag_commit="v1.0.0", - ... version_constraint=">=1.0.0") + git_branch_tag_commit="v1.0.0", + version_constraint=">=1.0.0") >>> pkg2 = PluginPackageInfo(pypi_package="my-package", version_constraint=">=1.0.0") """ @@ -2050,7 +2290,7 @@ class PluginVersionInfo(BaseModel): deprecated: bool = False manifest_file: str changelog: Optional[str] = None - min_max_framework_version: Optional[str] = "0.1.0.dev4,0.1.0.dev4" + min_max_framework_version: Optional[str] = "0.1.0,0.1.0" class PluginVersionRegistry(BaseModel): @@ -2126,4 +2366,80 @@ class PluginInstallationType(StrEnum): BUNDLED = "bundled" # Pre-installed with framework PYPI = "pypi" # Installed from PyPI GIT = "git" # Installed from Git repo + MONOREPO = "monorepo" # Installed from git monorepo LOCAL = "local" # Installed from local path + + +class InstalledPluginInfo(BaseModel): + """Plugin installation information.""" + + name: str + kind: str + version: Optional[str] = None + installation_type: PluginInstallationType + installation_path: str + installed_at: str + installed_by: str + package_source: Optional[str] = None + editable: bool = False + + +class InstalledPluginRegistry(BaseModel): + """Installed plugin registry.""" + + plugins: List[InstalledPluginInfo] = [] + + def register_plugin(self, plugin: InstalledPluginInfo) -> None: + """Register a plugin in the registry. + + If a plugin with the same name is already registered, its entry is + replaced so the registry reflects the most-recent install. + """ + self.plugins = [p for p in self.plugins if p.name != plugin.name] + self.plugins.append(plugin) + self.save() + + def unregister_plugin(self, plugin_name: str) -> bool: + """Unregister a plugin from the registry. + + Args: + plugin_name: The name of the plugin to unregister. + + Returns: + True if the plugin was found and removed, False otherwise. + """ + initial_count = len(self.plugins) + self.plugins = [p for p in self.plugins if p.name != plugin_name] + + if len(self.plugins) < initial_count: + self.save() + return True + return False + + def save(self) -> None: + """Serialize the registry to disk atomically.""" + from cpex.tools.settings import get_plugin_registry_path + + target = get_plugin_registry_path() + folder = target.parent + data = orjson.dumps(self.model_dump(), option=orjson.OPT_INDENT_2) + + tmp = tempfile.NamedTemporaryFile( + mode="wb", + delete=False, + dir=str(folder), + prefix="installed-plugins.", + suffix=".tmp", + ) + try: + try: + tmp.write(data) + tmp.flush() + os.fsync(tmp.fileno()) + finally: + tmp.close() + os.replace(tmp.name, target) + except Exception: + with contextlib.suppress(FileNotFoundError): + os.unlink(tmp.name) + raise diff --git a/cpex/framework/utils.py b/cpex/framework/utils.py index f9eb78ea..7eb4a94c 100644 --- a/cpex/framework/utils.py +++ b/cpex/framework/utils.py @@ -13,6 +13,7 @@ import importlib import logging from functools import cache +from pathlib import Path from types import ModuleType from typing import Any, Optional @@ -475,3 +476,43 @@ def render(self, content: Any) -> bytes: content, option=orjson.OPT_NON_STR_KEYS | orjson.OPT_SERIALIZE_NUMPY, ) + + +def find_package_path(package_name: str) -> Path: + """Locate installed package directory using importlib.metadata. + + Args: + package_name: The name of the installed package. + + Returns: + Path to the package directory. + + Raises: + RuntimeError: If package cannot be found. + """ + try: + # Use importlib.metadata for more reliable package discovery + for dist in importlib.metadata.distributions(): + if dist.name == package_name or dist.metadata.get("Name") == package_name: + if dist.files: + # Get the package root from the plugin-manifest.yaml file + for afile in dist.files: + if afile.name == "plugin-manifest.yaml": + located_path = dist.locate_file(afile) + package_path = Path(str(located_path)).parent + logger.debug("Found package %s at %s", package_name, package_path) + return package_path + + # Fallback to importlib.util.find_spec if metadata approach fails + spec = importlib.util.find_spec(package_name) + if spec is not None and spec.origin is not None: + package_path = Path(spec.origin).parent + logger.debug("Found package %s at %s (via find_spec)", package_name, package_path) + return package_path + + raise RuntimeError(f"Could not find installed package: {package_name}") + + except Exception as e: + if isinstance(e, RuntimeError): + raise + raise RuntimeError(f"Error locating package {package_name}: {str(e)}") from e diff --git a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/requirements.txt b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/requirements.txt index d35182aa..3a037075 100644 --- a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/requirements.txt +++ b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/requirements.txt @@ -1 +1,7 @@ -cpex>=0.1.0.dev10 \ No newline at end of file +cpex>=0.1.0 +# The requirements file is used to install the plugin for the isolated_venv scenario +# The cpex cli tool first creates a venv for the plugin, and then uses pip to install the requirements.txt file into the venv. +# The default package name is provided below, however if monorepo installation is desired use +# a format like this: +# git+https://github.com/tedhabeck/cpex-test-plugin +{{ cookiecutter.plugin_name }} \ No newline at end of file diff --git a/cpex/tools/README.md b/cpex/tools/README.md new file mode 100644 index 00000000..dd66d634 --- /dev/null +++ b/cpex/tools/README.md @@ -0,0 +1,207 @@ +## Before you begin + +Update the environment variables in .env + +All values except PLUGINS_GITHUB_TOKEN have defaults. + +```dotenv +### Plugin installation +# Comma Separated Values used by install with --type monorepo +# The default value is https://github.com/ibm/cpex-plugins +# PLUGINS_REPO_URLS="https://github.com/ibm/cpex-plugins" + +# registry path (default shown below) +# PLUGIN_REGISTRY_FOLDER=data + +# Github API (default shown below) +# PLUGINS_GITHUB_API=api.github.com + +# PLUGINS_GITHUB_TOKEN= +### end Plugin installation +``` + +## Plugin installation using the cli + +```bash + python cpex/tools/cli.py plugin --help + + Usage: cli.py plugin [OPTIONS] [CMD_ACTION] [SOURCE] + + List, search, install or uninstall plugins. + +default install type is monorepo + Examples: + python cpex/tools/cli.py plugin info pii + python cpex/tools/cli.py plugin search pii + python cpex/tools/cli.py plugin --type monorepo search pii + python cpex/tools/cli.py plugin --type monorepo install cpex-pii-filter + python cpex/tools/cli.py plugin --type pypi install "ExamplePlugin@>=0.1.0" + python cpex/tools/cli.py plugin --type test-pypi install "cpex-test-plugin@>=0.1.1" + python cpex/tools/cli.py plugin --type git install "cpex-test-plugin @ git+https://github.com/tedhabeck/cpex-test-plugin@main" + python cpex/tools/cli.py plugin versions cpex-test-plugin + python cpex/tools/cli.py plugin uninstall cpex-pii-filter. + +╭─ Arguments ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ cmd_action [CMD_ACTION] One of: list|info|install|search|uninstall │ +│ source [SOURCE] The pypi, git, or local folder where the plugin resides │ +╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ --type -t TEXT The types of plugins to list. One of: monorepo|pypi|test-pypi|git|local Defaults to monorepo if unspecified. │ +│ --help Show this message and exit. │ +╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + +``` + + +## Installation catalog and plugin registry + +### Catalog update sequence diagram + +Catalog update from monorepo IBM/cpex-plugins: + +```mermaid +sequenceDiagram + participant cli + participant dotenv + participant catalog + participant pygithub + cli->>catalog: update + dotenv->>catalog: PLUGINS_REPO_URLS + dotenv->>catalog: PLUGINS_GITHUB_TOKEN + catalog->>pygithub: find pyproject.toml files + catalog->>catalog: for each pyproject.toml + catalog->>catalog: extract [project].name + catalog->>pygithub: find plugin-manifest.yaml + pygithub->>catalog: plugin-manifest.yaml + catalog-->catalog: update plugin-manifest.yaml with monorepo details + catalog->>catalog: save plugin manifest to plugin-catalog + catalog->>cli: catalog update completed +``` + +### Plugin installation sequence diagrams +Installation from git monorepo: + +`python cpex/tools/cli.py plugin --type monorepo install pii` + +```mermaid +sequenceDiagram + participant User + participant cli + participant installed_plugin_registry + participant catalog + participant subprocess + participant python + participant pip + participant git + participant monorepo + User->>cli: python cpex/tools/cli.py plugin --type monorepo install pii + cli->>catalog: update + catalog->>monorepo: get available plugins + monorepo->>catalog: available plugins + catalog->>catalog: add monorepo.package_source to downloaded plugin-manifest.yaml + catalog->>cli: available plugins + cli->>User: select plugin from available plugins + User->>cli: selected plugin + cli->>catalog: install selected plugin + catalog->>subprocess: python -m pip install git+ + subprocess->>python: -m pip install git+ + python->>pip: install git+ + pip->>git: download to site-packages + git->>monorepo: download to site-packages + monorepo->>git: package installed + git->>pip: package installed + pip->>python: package installed + python->>subprocess: rc=0 + subprocess->>catalog: plugin installed + catalog->>cli: PluginManifest + cli->>installed_plugin_registry: register plugin PluginManifest + installed_plugin_registry->>cli: plugin registered + cli->>cli: update PLUGINS_CONFIG_FILE (i.e. plugins/config.yaml) + cli->>User: plugin installed OK +``` + + Installation from pypi: + +`python cpex/tools/cli.py --type pypi install >=` + +```mermaid +sequenceDiagram + participant User + participant cli + participant catalog + participant installed_plugin_registry + participant subprocess + participant python + participant pip + participant pypi (Python Package Index) + User->>cli: python cpex/tools/cli.py plugin --type pypi install + cli->>catalog: install_from_pypi( + catalog->>subprocess: python -m pip download to temp + subprocess->>python: -m pip download to temp + python->>pip: download + pip->>pypi (Python Package Index): download to temp + pypi (Python Package Index)->>python: downloaded OK + python->>subprocess: rc=0 + subprocess->>catalog: extracted_folder + catalog->>catalog: Loads and parse the plugin-manifest.yaml + catalog->>catalog: if manifest.kind is isolated_venv initialize isolated venv and STOP here. + catalog->>cli: PluginManifest (isolated_venv) + catalog->>subprocess: python -m pip install + subprocess->>python: -m pip install + python->>pip: install + pip->>pypi (Python Package Index): download to site-packages + pypi (Python Package Index)->>python: downloaded OK + python->>subprocess: rc=0 + subprocess->>catalog: plugin installed + catalog->>catalog: load plugin manifest + catalog->>catalog: package_info.pypi_package= + catalog->>catalog: package_info.version_constraint= + catalog->>catalog: save updated manifest to plugin-catalog + catalog->>cli: PluginManifest + cli->>installed_plugin_registry: register plugin + installed_plugin_registry->>cli: plugin registered + cli->>cli: update PLUGINS_CONFIG_FILE (i.e. plugins/config.yaml) + cli->>User: plugin installed OK +``` +Note: installation from test.pypi.org is also supported using --type test-pypi. e.g: + +`python cpex/tools/cli.py plugin --type test-pypi install "cpex-plugin-test@>=0.1.1" ` + +### Uninstall + +Example uninstall of plugin: +`python cpex/tools/cli.py plugin uninstall cpex-pii-filter` + + +### Pligin information query sequence diagram + +Query information for installed plugins: + +`python cpex/tools/cli.py plugin info` + +```mermaid +sequenceDiagram + participant User + participant cli + participant installed_plugin_registry + User->>cli: python cpex/tools/cli.py plugin info + cli->>installed_plugin_registry: pii + installed_plugin_registry->>cli: InstalledPluginInfo[] + cli->>User: InstalledPluginInfo[] +``` + +Example output: +```zsh + python cpex/tools/cli.py plugin info +{ + "name": "cpex-test-plugin", + "kind": "isolated_venv", + "version": "0.2.0", + "installation_type": "monorepo", + "installation_path": "/Users/habeck/tedhabeck/contextforge-plugins-framework/plugins/cpex_test_plugin/.venv/lib/python3.13/site-packages/cpex_test_plugin", + "installed_at": "2026-05-01T00:14:26.123924+00:00Z", + "installed_by": "habeck", + "package_source": "https://github.com/tedhabeck/cpex-test-plugin", + "editable": false +} +``` \ No newline at end of file diff --git a/cpex/tools/catalog.py b/cpex/tools/catalog.py new file mode 100644 index 00000000..5d689743 --- /dev/null +++ b/cpex/tools/catalog.py @@ -0,0 +1,1822 @@ +# -*- coding: utf-8 -*- +"""Location: ./cpex/tools/catalog.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck + +This module implements the plugin catalog object. +""" + +import base64 +import datetime +import json +import logging +import os +import shutil +import subprocess +import sys +import tarfile +import tempfile +import tomllib +import uuid +import zipfile +from pathlib import Path +from typing import Any, Optional + +import httpx +import yaml +from github import Auth, Github +from packaging.version import InvalidVersion, Version + +from cpex.framework.models import ( + GitRepo, + PluginManifest, + PluginPackageInfo, + PluginVersionInfo, + PluginVersionRegistry, + PyPiRepo, +) +from cpex.framework.utils import find_package_path +from cpex.tools.integrity import ( + IntegrityVerificationError, + fetch_pypi_package_hashes, + find_matching_hash, + verify_package_integrity, +) +from cpex.tools.settings import get_catalog_settings + +logger = logging.getLogger(__name__) + + +class PluginCatalog: + """ + Utility class to initialize the plugin catalog from configured monorepos + """ + + def __init__(self) -> None: + """Utility for creating the catalog from one or more monorepos.""" + settings = get_catalog_settings() + self.github_api = settings.GITHUB_API + self.github_token = settings.GITHUB_TOKEN + self.monorepos = settings.REPO_URLS.split(",") + self.plugin_folder = settings.FOLDER + self.catalog_folder = settings.CATALOG_FOLDER + self.manifests: list[PluginManifest] = [] + # Only create Auth.Token if a token is provided to avoid errors with None + self.auth = Auth.Token(self.github_token) if self.github_token else None + self.gh = Github(auth=self.auth, base_url=f"https://{self.github_api}", per_page=100) + self.python_executable = self._get_python_executable() + + def _get_python_executable(self) -> str: + """Get the Python executable path for the current environment.""" + return sys.executable + + def create_output_folder(self) -> None: + """Create the plugin catalog output folder.""" + os.makedirs(self.catalog_folder, exist_ok=True) + + def create_folder(self, base_path, rel_path): + """ + Creates the base_path / rel_path folder to store data in. + """ + relpath = Path(base_path) / rel_path + os.makedirs(relpath, exist_ok=True) + + def create_plugin_folder(self, path: str): + """ + Creates the self.plugin_folder/path folder to store the plugin source in. + """ + self.create_folder(self.plugin_folder, path) + + def create_catalog_folder(self, path: str): + """ + Creates the OUTPUT_FOLDER/path folder to store the plugin-manifest.yaml file in. + """ + self.create_folder(self.catalog_folder, path) + + def save_manifest(self, manifest: PluginManifest, path): + """Save a pypi installed manifest to the plugin catalog. + args: + manifest: The plugin manifest to be stored in the catalog + path: the name of the plugin package that was installed + """ + relpath = Path(self.catalog_folder) / path + updated_content = yaml.safe_dump(manifest.model_dump(), default_flow_style=False) + relpath.write_text(updated_content, encoding="utf-8") + + def _ver(self, version_str: str) -> Version: + """ + Parse a version string into a Version object. + + Args: + version_str: Version string to parse (e.g., "1.0.0", "2.0.0rc1") + + Returns: + Version object. Returns Version("0") if parsing fails. + """ + try: + return Version(version_str) + except InvalidVersion: + logger.debug("Could not parse version %r as PEP 440; treating as lowest", version_str) + return Version("0") + + def update_plugin_version_registry(self, manifest: PluginManifest, relpath: Path): + """ + Update the plugin version registry with the given manifest. + args: + manifest: The plugin manifest to be stored in the catalog + relpath: the relative path of the plugin package that was installed + """ + plugin_version = PluginVersionInfo( + version=manifest.version, + manifest_file=str(relpath), + released=datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"), + ) + + file_path = Path(self.catalog_folder) / manifest.name / "versions.json" + file_path.parent.mkdir(parents=True, exist_ok=True) + + # Load or create registry + if file_path.exists(): + with file_path.open("r") as f: + plugin_version_registry = PluginVersionRegistry(**json.load(f)) + else: + plugin_version_registry = PluginVersionRegistry(versions=[]) + + # Check if version already exists (avoid duplicates) + version_exists = any(pv.version == plugin_version.version for pv in plugin_version_registry.versions) + + # Add new version if not duplicate + if not version_exists: + plugin_version_registry.versions.append(plugin_version) + + # Recalculate latest version from all versions + if plugin_version_registry.versions: + plugin_version_registry.latest = max(plugin_version_registry.versions, key=lambda pv: self._ver(pv.version)) + + # Write the updated version registry to the file + file_path.write_text( + json.dumps(plugin_version_registry.model_dump(mode="json"), indent=2), + encoding="utf-8", + ) + + def save_manifest_content(self, content: str, path, repo_url: httpx.URL): + """ + write the manifest content to the supplied path relative to the ouptut folder, + injecting the monorepo.package_source value before saving the file. + """ + relpath = Path(self.catalog_folder) / path + repo_path = path.removesuffix(f"/{relpath.name}") + + manifest_data = yaml.safe_load(content) + + # Set name if not present (different from find_and_save_plugin_manifest which always sets it) + if "name" not in manifest_data: + manifest_data["name"] = repo_path + + # Use shared transformation logic + manifest_data = self._transform_manifest_data(manifest_data, manifest_data["name"], repo_path, repo_url) + + updated_content = yaml.safe_dump(manifest_data, default_flow_style=False) + relpath.write_text(updated_content, encoding="utf-8") + pm: PluginManifest = PluginManifest(**manifest_data) + self.update_plugin_version_registry(pm, relpath) + + def save_content(self, base_path, content: str, path): + """ + write the content to the supplied path relative to the ouptut folder. + """ + relpath = Path(base_path) / path + relpath.write_text(content, encoding="utf-8") + + def save_plugin_content(self, content: str, path): + """ + write the content to the supplied path relative to the plugin folder. + """ + self.save_content(self.plugin_folder, content, path) + + def save_catalog_content(self, content: str, path): + """ + write the content to the supplied path relative to the ouptut folder. + """ + self.save_content(self.catalog_folder, content, path) + + def download_contents(self, git_url: str, headers, path: str, repo_url: httpx.URL): + """ + Download the contents of the file using the github REST API. + """ + result = httpx.get(git_url, headers=headers, timeout=30.0) + if result.status_code == 200: + js = result.json() + b64_content = js["content"] + content = str(base64.b64decode(b64_content).decode("utf-8")) + # logger.info("decoded contents:\n%s", content) + # Extract directory path from full path (remove filename) + dir_path = str(Path(path).parent) if "/" in path else "" + if dir_path: + self.create_catalog_folder(dir_path) + self.save_manifest_content(content, path, repo_url) + else: + logger.error("Failed to download file: %s status_code: %d", git_url, result.status_code) + + def download_file(self, repo_path: str, item: dict, headers, gh_repo) -> str | None: + """Download the content of a github file + + Args: + repo_path: Repository path (e.g., 'owner/repo') + item: Dictionary containing the path of the file to download + headers: GitHub API headers + Returns: + Content of the file as a string or None if the file could not be downloaded + """ + # Get the repository using PyGithub + try: + file_content = gh_repo.get_contents(item["path"]) + content = file_content.decoded_content.decode("utf-8") + return content + except Exception as e: + logger.error("Failed to download file: %s error: %s", item["path"], str(e)) + + def _search_github_code_for_versions_json(self, repo_path: str, member: str | None, headers) -> list[dict] | None: + """Search GitHub for plugin-manifest*.yaml files in a specific path using PyGithub API. + + Args: + repo_path: Repository path (e.g., 'owner/repo') + member: Directory path within the repository + headers: HTTP headers for authentication (kept for compatibility but not used) + + Returns: + List of search result items as dicts with 'name' and 'git_url' keys, or None if request failed + """ + try: + # Build search query for PyGithub - search for files starting with plugin-manifest and ending with .yaml + # Note: GitHub search doesn't support wildcards in filename, so we search broadly and filter results + if member is not None: + query = f"repo:{repo_path} path:{member} filename:versions extension:json" + else: + query = f"repo:{repo_path} filename:versions extension:json" + # Use PyGithub's search_code method + search_results = self.gh.search_code(query=query) + + logger.info("Found %d versions.json files in %s/%s", search_results.totalCount, repo_path, member) + + # Convert PyGithub ContentFile objects to dict format compatible with existing code + items = [] + for content_file in search_results: + # Filter to only include files that start with "plugin-manifest" and end with ".yaml" + if content_file.name.startswith("versions") and content_file.name.endswith(".json"): + items.append( + { + "name": content_file.name, + "path": content_file.path, + "git_url": content_file.git_url, + "html_url": content_file.html_url, + } + ) + + return items + + except Exception as e: + logger.error("Catalog update failed with error: %s", str(e)) + return None + + def _search_github_code(self, repo_path: str, member: str | None, headers) -> list[dict] | None: + """Search GitHub for plugin-manifest*.yaml files in a specific path using PyGithub API. + + Args: + repo_path: Repository path (e.g., 'owner/repo') + member: Directory path within the repository + headers: HTTP headers for authentication (kept for compatibility but not used) + + Returns: + List of search result items as dicts with 'name' and 'git_url' keys, or None if request failed + """ + try: + # Build search query for PyGithub - search for files starting with plugin-manifest and ending with .yaml + # Note: GitHub search doesn't support wildcards in filename, so we search broadly and filter results + if member is not None: + query = f"repo:{repo_path} path:{member} extension:yaml" + else: + query = f"repo:{repo_path} extension:yaml" + # Use PyGithub's search_code method + search_results = self.gh.search_code(query=query) + + logger.info("Found %d plugin-manifest files in %s/%s", search_results.totalCount, repo_path, member) + + # Convert PyGithub ContentFile objects to dict format compatible with existing code + items = [] + for content_file in search_results: + # Filter to only include files that start with "plugin-manifest" and end with ".yaml" + if content_file.name.startswith("plugin-manifest") and content_file.name.endswith(".yaml"): + items.append( + { + "name": content_file.name, + "path": content_file.path, + "git_url": content_file.git_url, + "html_url": content_file.html_url, + } + ) + + return items + + except Exception as e: + logger.error("Catalog update failed with error: %s", str(e)) + return None + + def _transform_manifest_data( + self, manifest_content: dict, name: str, member: str | None, repo_url: httpx.URL + ) -> dict: + """Apply standard transformations to manifest data. + + Args: + manifest_content: Raw manifest data from YAML + name: Plugin name + member: Directory path within the repository + repo_url: Repository URL + + Returns: + Transformed manifest data with monorepo metadata + """ + if member is None: + package_source = str(repo_url) + else: + package_source = f"{repo_url}#subdirectory={member}" + + manifest_content["name"] = name + manifest_content.setdefault("tags", []) + manifest_content["monorepo"] = { + "package_source": package_source, + "repo_url": str(repo_url), + "package_folder": member if member is not None else "", + } + + # Normalize default_configs -> default_config + if "default_configs" in manifest_content: + manifest_content["default_config"] = manifest_content.pop("default_configs") or {} + + return manifest_content + + def _process_manifest_item( + self, + item: dict, + name: str, + member: str, + repo_url: httpx.URL, + headers, + relpath: Path, + repo_path: str, + gh_repo, + ) -> bool: + """Process a single manifest search result item. + + Args: + item: Search result item from GitHub API + name: Plugin name + member: Directory path within the repository + repo_url: Repository URL + headers: HTTP headers for authentication + relpath: Path where manifest should be saved + + Returns: + True if manifest was successfully processed and saved, False otherwise + """ + # Only download yaml files, not the README.md which may also contain references to available_hooks + if not (item["name"].endswith(".yaml") and item["name"].startswith("plugin-manifest")): + logger.warning("ignoring item[name]=%s. Not a yaml file.", item["name"]) + return False + + # manifest_data = self.download_file(repo_path=repo_path, git_url=item["git_url"], headers=headers) + manifest_data = self.download_file(repo_path=repo_path, item=item, headers=headers, gh_repo=gh_repo) + if manifest_data is None: + logger.error("Failed to download plugin-manifest from %s", member) + return False + + manifest_content = yaml.safe_load(manifest_data) + manifest_content = self._transform_manifest_data(manifest_content, name, member, repo_url) + + updated_content = yaml.safe_dump(manifest_content, default_flow_style=False) + relpath.write_text(updated_content, encoding="utf-8") + pm: PluginManifest = PluginManifest(**manifest_content) + self.update_plugin_version_registry(pm, relpath) + + return True + + def _process_version_item( + self, item: dict, member: str, name: str, repo_url: httpx.URL, headers, relpath, repo_path, gh_repo + ) -> None: + """Find plugin-versions.json files relative to the supplied member folder, + download and save the manifest, updating the monorepo's package_folder, package_source and repo_url attributes + Args: + member: Directory path within the repository + name: Plugin name + repo_url: Repository URL + headers: HTTP headers for authentication + """ + self.create_output_folder() + self.create_catalog_folder(name) + version_data = self.download_file(repo_path=repo_path, item=item, headers=headers, gh_repo=gh_repo) + if version_data is None: + logger.error("Skipping version item for %s (%s) — download failed", name, item.get("path")) + return + relpath.write_text(version_data, encoding="utf-8") + + def find_and_save_plugin_versions_json(self, member: str, name: str, repo_url: httpx.URL, headers, gh_repo) -> None: + """Find plugin-versions.json files relative to the supplied member folder, + download and save the manifest, updating the monorepo's package_folder, package_source and repo_url attributes + Args: + member: Directory path within the repository + name: Plugin name + repo_url: Repository URL + headers: HTTP headers for authentication + gh_repo: GitHub repository object + """ + self.create_output_folder() + self.create_catalog_folder(name) + + repo_path = repo_url.path.removeprefix("/") + items: list[dict[Any, Any]] | None = self._search_github_code_for_versions_json( + repo_path=repo_path, member=member, headers=headers + ) + if items is None: + return None + for item in items: + relpath = Path(self.catalog_folder) / name / item["name"] + self._process_version_item(item, member, name, repo_url, headers, relpath, repo_path, gh_repo) + + def find_and_save_plugin_manifest( + self, member: str, name: str, repo_url: httpx.URL, headers, gh_repo + ) -> PluginManifest | None: + """Find plugin-manifest*.yaml files relative to the supplied member folder, + download and save the manifest, updating the monorepo's package_folder, package_source and repo_url attributes + + Args: + member: Directory path within the repository + name: Plugin name + repo_url: Repository URL + headers: HTTP headers for authentication + + Returns: + None (could be extended to return PluginManifest if needed) + """ + self.create_output_folder() + self.create_catalog_folder(name) + + repo_path = repo_url.path.removeprefix("/") + + items = self._search_github_code(repo_path, member, headers) + if items is None: + return None + + for item in items: + # Use the actual filename from the search result + relpath = Path(self.catalog_folder) / name / item["name"] + self._process_manifest_item(item, name, member, repo_url, headers, relpath, repo_path, gh_repo) + + return None + + def _process_pyproject(self, gh_repo, item, repo_url: httpx.URL, headers) -> None: + """Process a single pyproject.toml file. + + Args: + gh_repo: PyGithub Repository object + item: Search result item containing pyproject.toml path + repo_url: Repository URL + headers: HTTP headers for authentication + + Raises: + Exception: If processing fails (caller should handle) + """ + # Get the directory path (remove filename) + if item.path.find("/") == -1: + member = None + else: + member = item.path.removesuffix("/" + item.name) + + # Download pyproject.toml content using PyGithub + file_content = gh_repo.get_contents(item.path) + pyproject_data = file_content.decoded_content.decode("utf-8") + + if pyproject_data is None: + logger.warning("Failed to download pyproject.toml from %s", item.path) + return + + # Parse the pyproject.toml + project_data = tomllib.loads(pyproject_data) + + # Find and save the versions.json file + self.find_and_save_plugin_versions_json( + member=member, name=project_data["project"]["name"], repo_url=repo_url, headers=headers, gh_repo=gh_repo + ) + + # Find and save the plugin manifest + self.find_and_save_plugin_manifest( + member=member, name=project_data["project"]["name"], repo_url=repo_url, headers=headers, gh_repo=gh_repo + ) + + def update_catalog_with_pyproject(self) -> bool: + """Update the catalog with the pyproject.toml file using PyGithub API.""" + if self.github_token is None: + logger.error("No GitHub token set") + return True + + headers = {"accept": "application/vnd.github+json", "authorization": f"Bearer {self.github_token}"} + self.create_output_folder() + + # Cache repositories to avoid repeated API calls + repo_cache: dict[str, Any] = {} + + for repo in self.monorepos: + repo_url = httpx.URL(repo.strip()) + repo_path = repo_url.path.removeprefix("/") + + try: + # Get repository using PyGithub (with caching) + if repo_path not in repo_cache: + repo_cache[repo_path] = self.gh.get_repo(repo_path) + gh_repo = repo_cache[repo_path] + + # Search for pyproject.toml files using PyGithub search + query = f"repo:{repo_path} filename:pyproject extension:toml" + search_results = self.gh.search_code(query=query) + + logger.info("Found %d pyproject.toml files in %s", search_results.totalCount, repo_path) + + for item in search_results: + if "pyproject.toml" in item.name: + try: + self._process_pyproject(gh_repo, item, repo_url, headers) + except Exception as e: + logger.error("Error processing pyproject.toml at %s: %s", item.path, str(e)) + continue + + except Exception as e: + logger.error("Error accessing repository %s: %s", repo_path, str(e)) + continue + + return False + + def load(self) -> None: + """Load plugin-manifest.yaml files from self.catalog_folder into self.manifests.""" + self.manifests = [] + output_path = Path(self.catalog_folder) + + if not output_path.exists(): + logger.warning("Output folder '%s' does not exist. No manifests to load.", self.catalog_folder) + return + + # Find all plugin-manifest.yaml files recursively + manifest_files = list(output_path.rglob("plugin-manifest*.yaml")) + + if not manifest_files: + logger.warning("No plugin-manifest.yaml files found in '%s'.", self.catalog_folder) + return + + logger.info("Found %d plugin-manifest.yaml file(s) in '%s'.", len(manifest_files), self.catalog_folder) + + for manifest_file in manifest_files: + try: + with open(manifest_file, "r", encoding="utf-8") as f: + manifest_data = yaml.safe_load(f) + + # Create PluginManifest object from the loaded data + manifest = PluginManifest(**manifest_data) + self.manifests.append(manifest) + logger.info("Loaded manifest from '%s'.", manifest_file) + + except Exception as e: + logger.error("Failed to load manifest from '%s': %s", manifest_file, str(e)) + + logger.info("Successfully loaded %d manifest(s).", len(self.manifests)) + + def search(self, plugin_name: str | None) -> Optional[list[PluginManifest]]: + """Search for a plugin in the catalog""" + matching: list[PluginManifest] = [] + # lookup the plugin from the catalog's plugin-manifest.yaml + if (self.manifests is not None) and (len(self.manifests) == 0): + self.load() + for manifest in self.manifests: + if plugin_name is not None: + if manifest.name.lower().count(plugin_name.lower()) > 0: + matching.append(manifest) + elif plugin_name.lower() in manifest.tags: + matching.append(manifest) + else: + matching.append(manifest) + return matching if len(matching) > 0 else None + + def find(self, plugin_name: str) -> Optional[PluginManifest]: + """Find a plugin in the catalog + Args: + plugin_name: The name of the plugin to find + Returns: + The manifest of the plugin if found, None otherwise + """ + # lookup the plugin from the catalog's plugin-manifest.yaml + if (self.manifests is not None) and (len(self.manifests) == 0): + self.load() + for manifest in self.manifests: + if manifest.name.lower() == plugin_name.lower(): + return manifest + return None + + def install_folder_via_pip(self, manifest: PluginManifest, verify_integrity: bool = True) -> Path | None: + """ + Runs a pip install using subfolder syntax for monorepo plugins. + For isolated_venv plugins, checks manifest kind BEFORE installing to avoid dependency conflicts. + e.g. "git+https://github.com[extra]&subdirectory=folder_name" + + Args: + manifest: The PluginManifest of the plugin to be installed + verify_integrity: Whether to compute and log package hash for verification + + Raises: + RuntimeError: If package installation fails. + """ + if manifest.monorepo is None: + raise RuntimeError("PluginManifest.monorepo can not be None.") + try: + repo_url = f"git+{manifest.monorepo.package_source}" + + plugin_path = None + # Check manifest kind BEFORE installing + if manifest.kind == "isolated_venv": + logger.info("Detected isolated_venv plugin from monorepo: %s", manifest.name) + # Install the package to make it available for venv initialization + package_path = self._download_monorepo_folder_to_temp( + repo_url, manifest.name, verify_integrity=verify_integrity + ) + plugin_path = self._initialize_isolated_venv(manifest, package_path) + logger.info("Isolated venv initialized. Plugin will be auto-installed via requirements.txt") + else: + # For non-isolated plugins, install normally into CLI's venv + logger.info("Installing non-isolated plugin from monorepo: %s", manifest.name) + subprocess.run( + [self.python_executable, "-m", "pip", "install", repo_url], + check=True, + capture_output=True, + text=True, + timeout=600, + ) + logger.info("Successfully installed package: %s", manifest.name) + return plugin_path + + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Failed to install {manifest.name}: {e.stderr}") from e + except Exception as e: + raise RuntimeError(f"Unexpected error installing {manifest.name}: {str(e)}") from e + + def _install_package(self, package_name: str, version_constraint: str | None, use_test: bool = False) -> None: + """Install package from PyPI with proper error handling. + + Args: + package_name: The PyPI package name to install. + version_constraint: Optional version constraint (e.g., ">=1.0.0,<2.0.0"). + + Raises: + RuntimeError: If package installation fails. + """ + try: + # Validate package name and constraint format + ppi = PluginPackageInfo(pypi_package=package_name, version_constraint=version_constraint) + tgt = ppi.pypi_package + if ppi.version_constraint is not None: + tgt = f"{tgt}{ppi.version_constraint}" + if use_test: + subprocess.run( + [ + self.python_executable, + "-m", + "pip", + "install", + "--index-url", + "https://test.pypi.org/simple/", + tgt, + ], + check=True, + capture_output=True, + text=True, + timeout=600, + ) + else: + subprocess.run( + [self.python_executable, "-m", "pip", "install", tgt], + check=True, + capture_output=True, + text=True, + timeout=600, + ) + logger.info("Successfully installed package: %s", package_name) + + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Failed to install {package_name}: {e.stderr}") from e + except Exception as e: + raise RuntimeError(f"Unexpected error installing {package_name}: {str(e)}") from e + + def _load_manifest_file(self, manifest_path: Path) -> dict[str, Any]: + """Load and parse plugin-manifest.yaml with validation. + + Args: + manifest_path: Path to the plugin-manifest.yaml file. + + Returns: + Parsed manifest data as a dictionary. + + Raises: + FileNotFoundError: If manifest file doesn't exist. + RuntimeError: If manifest file cannot be parsed. + """ + if not manifest_path.exists(): + raise FileNotFoundError(f"plugin-manifest.yaml not found at {manifest_path}") + + try: + with open(manifest_path, "r", encoding="utf-8") as f: + manifest_data = yaml.safe_load(f) + + if not isinstance(manifest_data, dict): + raise RuntimeError(f"Invalid manifest format: expected dictionary, got {type(manifest_data).__name__}") + + logger.debug("Successfully loaded manifest from %s", manifest_path) + return manifest_data + + except yaml.YAMLError as e: + raise RuntimeError(f"Failed to parse manifest YAML: {str(e)}") from e + except Exception as e: + raise RuntimeError(f"Error reading manifest file: {str(e)}") from e + + def _normalize_manifest_data( + self, manifest_data: dict[str, Any], package_name: str, version_constraint: str | None + ) -> PluginManifest: + """Transform raw manifest dict into validated PluginManifest model. + + Args: + manifest_data: Raw manifest dictionary from YAML. + package_name: The PyPI package name. + version_constraint: Optional version constraint. + + Returns: + Validated PluginManifest instance. + + Raises: + RuntimeError: If manifest validation fails. + """ + try: + # Set defaults for optional fields + manifest_data.setdefault("tags", []) + manifest_data.setdefault("name", package_name) + + # Handle legacy default_configs field + if "default_config" not in manifest_data and "default_configs" in manifest_data: + manifest_data["default_config"] = manifest_data.pop("default_configs") or {} + + # Validate and create manifest + manifest = PluginManifest(**manifest_data) + + # Ensure package_info is properly set + if manifest.package_info is None: + manifest.package_info = PyPiRepo(pypi_package=package_name, version_constraint=version_constraint) + else: + manifest.package_info.pypi_package = package_name + if version_constraint is not None: + manifest.package_info.version_constraint = version_constraint + + logger.debug("Successfully normalized manifest for %s", package_name) + return manifest + + except Exception as e: + raise RuntimeError(f"Failed to validate manifest for {package_name}: {str(e)}") from e + + def _persist_manifest(self, manifest: PluginManifest, package_name: str) -> None: + """Save manifest to catalog folder. + + Args: + manifest: The validated plugin manifest. + package_name: The package name (used for folder/file naming). + + Raises: + RuntimeError: If manifest cannot be saved. + """ + try: + self.create_catalog_folder(package_name) + self.save_manifest(manifest, f"{package_name}/plugin-manifest.yaml") + logger.info("Successfully saved %s package manifest to plugin catalog", package_name) + except Exception as e: + raise RuntimeError(f"Failed to save manifest for {package_name}: {str(e)}") from e + + @staticmethod + def _safe_zip_extract(zip_ref: zipfile.ZipFile, extract_dir: Path) -> None: + """Extract a zip archive, rejecting members whose paths escape extract_dir.""" + base = extract_dir.resolve() + for info in zip_ref.infolist(): + name = info.filename + if os.path.isabs(name): + raise RuntimeError(f"Unsafe path in archive: {name}") + target = (base / name).resolve() + if not target.is_relative_to(base): + raise RuntimeError(f"Unsafe path in archive: {name}") + zip_ref.extractall(extract_dir) + + def _extract_package_archive(self, package_file: Path, extract_dir: Path) -> None: + """Extract a package archive (zip, tar.gz, wheel, etc.) to a directory. + + Args: + package_file: Path to the archive file. + extract_dir: Directory to extract to. + + Raises: + RuntimeError: If the archive format is unsupported or contains unsafe paths. + """ + if package_file.suffix == ".whl" or package_file.name.endswith(".whl"): + with zipfile.ZipFile(package_file, "r") as zip_ref: + self._safe_zip_extract(zip_ref, extract_dir) + elif package_file.suffix == ".zip" or package_file.name.endswith(".zip"): + with zipfile.ZipFile(package_file, "r") as zip_ref: + self._safe_zip_extract(zip_ref, extract_dir) + elif package_file.suffix in [".gz", ".bz2"] or ".tar" in package_file.name: + with tarfile.open(package_file, "r:*") as tar_ref: + tar_ref.extractall(extract_dir, filter="data") + else: + raise RuntimeError(f"Unsupported package format: {package_file}") + + def _download_monorepo_folder_to_temp( + self, repo_url: str, package_name: str, verify_integrity: bool = True + ) -> Path: + """Download monorepo folder to temporary directory. + + Args: + repo_url: The URL of the monorepo. + package_name: Name used in error messages. + verify_integrity: Whether to compute and log package hash for verification. + + Returns: + Path to the extracted package directory. Caller is responsible for cleanup. + """ + tmpid = uuid.uuid4() + temp_dir = Path(tempfile.mkdtemp(prefix=f"cpex_plugin_{tmpid}_")) + try: + logger.info("Downloading monorepo folder to %s", temp_dir) + + download_args = [ + self.python_executable, + "-m", + "pip", + "download", + "--no-deps", + "--dest", + str(temp_dir), + ] + download_args.append(repo_url) + + subprocess.run(download_args, check=True, capture_output=True, text=True, timeout=600) + + downloaded_files = list(temp_dir.glob("*")) + if not downloaded_files: + raise RuntimeError(f"No files downloaded for {package_name}") + package_file = downloaded_files[0] + + # Compute and log hash for integrity verification + if verify_integrity: + try: + from cpex.tools.integrity import compute_file_hash + + package_hash = compute_file_hash(package_file) + logger.info( + "Package integrity hash for %s (%s): SHA256=%s", package_name, package_file.name, package_hash + ) + logger.info("Store this hash for future verification or to detect tampering") + except Exception as e: + logger.warning("Failed to compute package hash: %s", str(e)) + + extract_dir = temp_dir / "extracted" + extract_dir.mkdir() + + self._extract_package_archive(package_file, extract_dir) + + logger.info("Downloaded and extracted %s to %s", package_name, extract_dir) + return extract_dir + + except subprocess.CalledProcessError as e: + shutil.rmtree(temp_dir, ignore_errors=True) + raise RuntimeError(f"Failed to download {package_name}: {e.stderr}") from e + except Exception as e: + shutil.rmtree(temp_dir, ignore_errors=True) + raise RuntimeError(f"Unexpected error downloading {package_name}: {str(e)}") from e + + def _download_package_to_temp( + self, package_name: str, version_constraint: str | None, use_test: bool = False, verify_integrity: bool = True + ) -> Path: + """Download package to a temporary directory without installing it. + + Args: + package_name: The PyPI package name to download. + version_constraint: Optional version constraint. + use_test: Whether to use test.pypi.org. + verify_integrity: Whether to verify package integrity using SHA256 hashes. + + Returns: + Path to the downloaded package directory. + + Raises: + RuntimeError: If download fails. + IntegrityVerificationError: If hash verification fails. + """ + + try: + # Create temporary directory + temp_dir = Path(tempfile.mkdtemp(prefix=f"cpex_plugin_{package_name}_")) + + # Validate package name and constraint format + ppi = PluginPackageInfo(pypi_package=package_name, version_constraint=version_constraint) + tgt = ppi.pypi_package + if ppi.version_constraint is not None: + tgt = f"{tgt}{ppi.version_constraint}" + + # Fetch expected hashes from PyPI before downloading (if verification enabled) + expected_hashes = {} + if verify_integrity: + try: + logger.info("Fetching package hashes from PyPI for %s", package_name) + # Extract version from constraint if available, otherwise fetch latest + version_to_fetch = None + if version_constraint: + # Try to extract exact version from constraint (e.g., "==1.0.0" -> "1.0.0") + import re + + version_match = re.search(r"==\s*([0-9.]+)", version_constraint) + if version_match: + version_to_fetch = version_match.group(1) + + expected_hashes = fetch_pypi_package_hashes( + package_name=package_name, version=version_to_fetch, use_test=use_test + ) + if expected_hashes: + logger.info("Retrieved hashes for %d distribution files", len(expected_hashes)) + else: + logger.warning("No hashes available from PyPI for %s", package_name) + except Exception as e: + logger.warning("Failed to fetch hashes from PyPI: %s. Proceeding without verification.", str(e)) + expected_hashes = {} + + # Download package without installing + download_args = [ + self.python_executable, + "-m", + "pip", + "download", + "--no-deps", # Don't download dependencies + "--dest", + str(temp_dir), + ] + + if use_test: + download_args.extend(["--index-url", "https://test.pypi.org/simple/"]) + + download_args.append(tgt) + + subprocess.run(download_args, check=True, capture_output=True, text=True, timeout=600) + + # Find the downloaded file + downloaded_files = list(temp_dir.glob("*")) + if not downloaded_files: + raise RuntimeError(f"No files downloaded for {package_name}") + + package_file = downloaded_files[0] + + # Verify package integrity if hashes are available + if verify_integrity and expected_hashes: + expected_hash = find_matching_hash(package_file, expected_hashes, package_name) + if expected_hash: + logger.info("Verifying integrity of %s", package_file.name) + verify_package_integrity( + file_path=package_file, expected_hash=expected_hash, package_name=package_name, strict=True + ) + else: + logger.warning("No matching hash found for %s. Proceeding without verification.", package_file.name) + + extract_dir = temp_dir / "extracted" + extract_dir.mkdir() + + # Extract the package using common helper + self._extract_package_archive(package_file, extract_dir) + + logger.info("Downloaded and extracted %s to %s", package_name, extract_dir) + return extract_dir + + except IntegrityVerificationError: + # Re-raise integrity errors without wrapping + shutil.rmtree(temp_dir, ignore_errors=True) + raise + except subprocess.CalledProcessError as e: + shutil.rmtree(temp_dir, ignore_errors=True) + raise RuntimeError(f"Failed to download {package_name}: {e.stderr}") from e + except Exception as e: + shutil.rmtree(temp_dir, ignore_errors=True) + raise RuntimeError(f"Unexpected error downloading {package_name}: {str(e)}") from e + + def _find_manifest_in_extracted_package(self, extract_dir: Path, package_name: str) -> Path: + """Find plugin-manifest.yaml in extracted package. + + Args: + extract_dir: Directory where package was extracted. + package_name: Name of the package. + + Returns: + Path to plugin-manifest.yaml. + + Raises: + FileNotFoundError: If manifest not found. + """ + # Search for plugin-manifest.yaml in the extracted directory + manifest_files = list(extract_dir.rglob("plugin-manifest.yaml")) + + if not manifest_files: + raise FileNotFoundError(f"plugin-manifest.yaml not found in {package_name} package") + + # Return the first manifest found + return manifest_files[0] + + def _find_requirements_in_extracted_package( + self, extract_dir: Path, package_name: str, requirements_file: str + ) -> Path: + """Find requirements file in extracted package with path traversal protection. + + Args: + extract_dir: Directory where package was extracted. + package_name: Name of the package. + requirements_file: Name of the requirements file to find. + + Returns: + Path to requirements file. + + Raises: + FileNotFoundError: If requirements file not found. + ValueError: If requirements_file contains path traversal attempts. + """ + # Validate requirements_file to prevent path traversal attacks + # Normalize the path and check for suspicious patterns + normalized_file = os.path.normpath(requirements_file) + + # Check for path traversal attempts (../, absolute paths, etc.) + if normalized_file.startswith("..") or os.path.isabs(normalized_file): + raise ValueError( + f"Invalid requirements file path '{requirements_file}': path traversal attempts are not allowed" + ) + + # Additional check: ensure no path separators that could escape the directory + if normalized_file != requirements_file.replace("\\", "/").strip("/"): + raise ValueError( + f"Invalid requirements file path '{requirements_file}': suspicious path components detected" + ) + + # Search for requirements file in the extracted directory + manifest_files = list(extract_dir.rglob(requirements_file)) + + if not manifest_files: + raise FileNotFoundError(f"requirements file {requirements_file} not found in {package_name} package") + + # Verify the found file is actually within extract_dir (defense in depth) + found_file = manifest_files[0] + try: + found_file.resolve().relative_to(extract_dir.resolve()) + except ValueError as e: + raise ValueError( + f"Security violation: requirements file '{found_file}' is outside the package directory" + ) from e + + # Return the first manifest found + return found_file + + def _initialize_isolated_venv(self, manifest: PluginManifest, package_path: Path) -> Path: + """Initialize isolated venv for a plugin without installing it into the CLI's venv. + + This method creates and initializes the target venv for isolated_venv plugins, + allowing the plugin's requirements.txt to self-reference and auto-install the plugin. + + Args: + manifest: The plugin manifest. + package_path: Path to the installed package directory. + + Raises: + RuntimeError: If venv initialization fails. + """ + try: + # Import here to avoid circular dependency + from cpex.framework.isolated.client import IsolatedVenvPlugin + from cpex.framework.models import PluginMode + + logger.info("Initializing isolated venv for plugin: %s", manifest.name) + + # Create a temporary PluginConfig from the manifest + plugin_config = manifest.create_instance_config( + instance_name=manifest.name, + mode=PluginMode.SEQUENTIAL, # Mode doesn't matter for initialization + priority=100, + ) + + # Create an IsolatedVenvPlugin instance + isolated_plugin = IsolatedVenvPlugin( + config=plugin_config, + plugin_dirs=[str(self.plugin_folder)], + ) + # TODO: sec - prevent path traversal on user supplied requirements file path. + requirements_file = manifest.default_config.get("requirements_file", "requirements.txt") + source_path = self._find_requirements_in_extracted_package(package_path, manifest.name, requirements_file) + shutil.copy(source_path, isolated_plugin.plugin_path / requirements_file) + # Initialize the venv (this will create venv and install requirements) + import asyncio + import concurrent.futures + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop is None: + asyncio.run(isolated_plugin.initialize()) + else: + # Called from within a running event loop (e.g. Jupyter, async CLI). + # Run in a thread to avoid "asyncio.run cannot be called from a running event loop". + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: + ex.submit(asyncio.run, isolated_plugin.initialize()).result() + + logger.info("Successfully initialized isolated venv for %s", manifest.name) + + return isolated_plugin.plugin_path + + except Exception as e: + raise RuntimeError(f"Failed to initialize isolated venv for {manifest.name}: {str(e)}") from e + + def _find_and_load_versions_json( + self, manifest: PluginManifest, plugin_path: Path | None, plugin_package_name: str + ) -> Path | None: + """Find and load versions.json file from installed package. + + Args: + manifest: The plugin manifest + plugin_path: Path to the installed plugin (None for isolated_venv before installation) + plugin_package_name: The package name + + This method handles two cases: + 1. For non-isolated plugins: Uses the plugin_path directly + 2. For isolated_venv plugins: Runs a subprocess in the venv to find the package path + """ + try: + actual_plugin_path = plugin_path + + # For isolated_venv plugins, we need to find the package path within the venv + if manifest.kind == "isolated_venv" and plugin_path: + # The plugin_path for isolated_venv is the venv directory + # We need to find where the package is actually installed within it + venv_path = plugin_path + python_executable = self._get_venv_python_executable(venv_path / ".venv") + + # Script receives the package name via sys.argv to avoid f-string injection. + find_package_script = """ +import sys +import importlib.metadata +from pathlib import Path + +package_name = sys.argv[1] +try: + for dist in importlib.metadata.distributions(): + if dist.name == package_name or dist.metadata.get("Name") == package_name: + if dist.files: + for afile in dist.files: + if afile.name == "versions.json": + located_path = dist.locate_file(afile) + print(str(Path(located_path).parent)) + sys.exit(0) + print("NOT_FOUND", file=sys.stderr) + sys.exit(1) +except Exception as e: + print(f"ERROR: {e}", file=sys.stderr) + sys.exit(1) +""" + + # Execute the script in the isolated venv + result = subprocess.run( + [python_executable, "-c", find_package_script, plugin_package_name], + check=True, + capture_output=True, + text=True, + timeout=60, + ) + + if result.returncode == 0 and result.stdout.strip(): + actual_plugin_path = Path(result.stdout.strip()) + logger.debug("Found package path in isolated venv: %s", actual_plugin_path) + else: + logger.warning( + "Could not find versions.json in isolated venv for %s: %s", + plugin_package_name, + result.stderr, + ) + return + + # Now load the versions.json file if it exists + if actual_plugin_path: + versions_json_path = actual_plugin_path / "versions.json" + if versions_json_path.exists(): + logger.info("Found versions.json at %s", versions_json_path) + with open(versions_json_path, "r", encoding="utf8") as f: + versions_data = json.load(f) + # Save to catalog + catalog_versions_path = Path(self.catalog_folder) / manifest.name / "versions.json" + catalog_versions_path.parent.mkdir(parents=True, exist_ok=True) + with open(catalog_versions_path, "w", encoding="utf8") as f: + json.dump(versions_data, f, indent=2) + logger.info("Saved versions.json to catalog: %s", catalog_versions_path) + return actual_plugin_path + else: + logger.debug("No versions.json found at %s", versions_json_path) + + except Exception as e: + logger.warning("Failed to find/load versions.json for %s: %s", plugin_package_name, e) + + def _get_venv_python_executable(self, venv_path: Path) -> str: + """Get the Python executable path for a virtual environment. + + Args: + venv_path: Path to the virtual environment directory + + Returns: + Path to the Python executable as a string + """ + if sys.platform == "win32": + python_exe = venv_path / "Scripts" / "python.exe" + else: + python_exe = venv_path / "bin" / "python" + + if not python_exe.exists(): + raise FileNotFoundError(f"Python executable not found at {python_exe}") + + return str(python_exe) + + def _handle_plugin_installation( + self, manifest: PluginManifest, package_path: Path, install_command: list[str] | None = None + ) -> Path | None: + """Handle plugin installation based on its kind (isolated_venv or regular). + + Args: + manifest: The plugin manifest. + package_path: Path to the package source. + install_command: Optional custom install command for non-isolated plugins. + If None, no installation is performed for non-isolated plugins. + + Returns: + Path to the installed plugin, or None if not applicable. + + Raises: + RuntimeError: If installation fails. + """ + plugin_path = None + + if manifest.kind == "isolated_venv": + logger.info("Detected isolated_venv plugin: %s", manifest.name) + plugin_path = self._initialize_isolated_venv(manifest, package_path) + logger.info("Isolated venv initialized. Plugin auto-installed via requirements.txt") + else: + # For non-isolated plugins, install if command provided + if install_command: + logger.info("Installing non-isolated plugin: %s", manifest.name) + subprocess.run( + install_command, + check=True, + capture_output=True, + text=True, + timeout=600, + ) + logger.info("Successfully installed package: %s", manifest.name) + + return plugin_path + + def _finalize_plugin_installation( + self, manifest: PluginManifest, plugin_path: Path | None, package_name: str + ) -> Path | None: + """Perform post-installation steps: persist manifest, find versions.json, update registry. + + Args: + manifest: The plugin manifest. + plugin_path: Path to the installed plugin (plugins/{manifest.name} directory). + package_name: Name of the package. + + Returns: + The actual plugin path from versions.json (inside .venv for isolated plugins), + or plugin_path if versions.json not found. + """ + # Step 1: Persist to catalog + self._persist_manifest(manifest, package_name) + + # Step 2: Find and save versions.json if available + # This returns the actual package location (inside .venv for isolated plugins) + actual_plugin_path = self._find_and_load_versions_json(manifest, plugin_path, package_name) + + # Step 3: Update the plugin version registry + # IMPORTANT: Use plugin_path (not actual_plugin_path) for the registry + # plugin_path is the plugins/{manifest.name} directory + # actual_plugin_path is the location inside .venv (for isolated plugins) + if plugin_path is not None: + self.update_plugin_version_registry(manifest=manifest, relpath=plugin_path) + + logger.info("Successfully installed and cataloged %s", package_name) + + # Return actual_plugin_path for reference (may be inside .venv) + return actual_plugin_path if actual_plugin_path is not None else plugin_path + + def install_from_pypi( + self, + plugin_package_name: str, + version_constraint: str | None = None, + use_pytest: bool = False, + verify_integrity: bool = True, + ) -> tuple[PluginManifest, Path | None]: + """Install Python package from PyPI and load its plugin-manifest.yaml. + + This method performs the following steps: + 1. Downloads package to check manifest (without installing for isolated_venv) + 2. Loads and parses the plugin-manifest.yaml + 3. Normalizes and validates the manifest data + 4. For isolated_venv plugins: initializes the target venv (plugin auto-installs via requirements.txt) + 5. For other plugins: installs normally into CLI's venv + 6. Persists the manifest to the plugin catalog + 7. Finds and saves versions.json if available + 8. Updates the plugin version registry + + Args: + plugin_package_name: The name of the package hosted on PyPI. + version_constraint: Optional version constraint (e.g., ">=1.0.0,<2.0.0"). + use_pytest: Whether to use test.pypi.org instead of pypi.org. + verify_integrity: Whether to verify package integrity using SHA256 hashes from PyPI. + + Returns: + The loaded and validated plugin manifest. + + Raises: + RuntimeError: If any step of the installation process fails. + FileNotFoundError: If plugin-manifest.yaml is not found in the package. + IntegrityVerificationError: If package hash verification fails. + """ + + # Step 1: Download package to temporary location to read manifest (with integrity verification) + temp_extract_dir = self._download_package_to_temp( + plugin_package_name, version_constraint, use_pytest, verify_integrity=verify_integrity + ) + + try: + # Step 2: Find and load the manifest file + manifest_path = self._find_manifest_in_extracted_package(temp_extract_dir, plugin_package_name) + manifest_data = self._load_manifest_file(manifest_path) + + # Step 3: Normalize and validate the manifest + manifest = self._normalize_manifest_data(manifest_data, plugin_package_name, version_constraint) + + package_path = manifest_path.parent + + # Step 4: Handle installation based on plugin kind + plugin_path = self._handle_plugin_installation( + manifest, + package_path, + install_command=None, # Will install separately for non-isolated + ) + + # For non-isolated plugins, install via pip and find package path + if manifest.kind != "isolated_venv": + self._install_package(plugin_package_name, version_constraint, use_pytest) + plugin_path = find_package_path(plugin_package_name) + + # Step 5-7: Finalize installation (persist, versions.json, registry) + plugin_path = self._finalize_plugin_installation(manifest, plugin_path, plugin_package_name) + + return manifest, plugin_path + + finally: + # Clean up temporary directory + if temp_extract_dir.exists(): + shutil.rmtree(temp_extract_dir.parent) + + def install_from_git(self, url: str, verify_integrity: bool = True) -> tuple[PluginManifest, Path | None]: + """Install Python package from Git repository and load its plugin-manifest.yaml. + + This method performs the following steps: + 1. Parses the Git URL to extract package name and repository details + 2. Downloads package to temporary location to read manifest + 3. Loads and parses the plugin-manifest.yaml + 4. Normalizes and validates the manifest data + 5. For isolated_venv plugins: initializes the target venv and installs via pip into isolated venv + 6. For other plugins: installs via pip into current venv + 7. Persists the manifest to the plugin catalog + 8. Finds and saves versions.json if available + 9. Updates the plugin version registry + + Args: + url: Git repository URL in one of these formats: + - MyProject @ git+ssh://git@git.example.com/MyProject + - MyProject @ git+https://git.example.com/MyProject + - MyProject @ git+https://git.example.com/MyProject@master + verify_integrity: Whether to compute and log package hash for verification + + Returns: + Tuple of (PluginManifest, Path to plugin or None) + + Raises: + ValueError: If URL format is invalid. + RuntimeError: If any step of the installation process fails. + FileNotFoundError: If plugin-manifest.yaml is not found in the package. + """ + # Step 1: Parse the Git URL + # Expected format: "PackageName@git+protocol://repo_url[@branch/tag/commit]" + if " @ " not in url: + raise ValueError( + f"Invalid Git URL format: '{url}'. Expected format: 'PackageName @ git+protocol://repo_url'" + ) + + package_name, git_spec = url.split(" @ ", 1) + package_name = package_name.strip() + + # Remove 'git+' prefix and extract protocol + if not git_spec.startswith("git+"): + raise ValueError(f"Git URL must start with 'git+': '{git_spec}'") + + git_url = git_spec[4:] # Remove 'git+' prefix + + # Extract branch/tag/commit if specified (after @) + git_branch_tag_commit = None + if "@" in git_url and not git_url.startswith("git@"): + # Split on the last @ to handle git@host:repo@branch format + parts = git_url.rsplit("@", 1) + if len(parts) == 2: + git_url, git_branch_tag_commit = parts + + # Validate using PluginPackageInfo + try: + PluginPackageInfo( + git_repository=git_url, + git_branch_tag_commit=git_branch_tag_commit, + ) + except Exception as e: + raise ValueError(f"Invalid Git repository URL: {str(e)}") from e + + logger.info("Installing package '%s' from Git repository: %s", package_name, git_url) + if git_branch_tag_commit: + logger.info("Using branch/tag/commit: %s", git_branch_tag_commit) + + # Step 2: Download package to temporary location to read manifest + # We'll use pip download to get the package without installing it first + temp_dir = Path(tempfile.mkdtemp(prefix="cpex_git_")) + temp_extract_dir = temp_dir / "extracted" + temp_extract_dir.mkdir(parents=True, exist_ok=True) + + try: + # Construct the full git URL for pip + pip_git_url = f"git+{git_url}" + if git_branch_tag_commit: + pip_git_url = f"{pip_git_url}@{git_branch_tag_commit}" + + # Download the package using pip + logger.info("Downloading package from Git repository...") + subprocess.run( + [ + self.python_executable, + "-m", + "pip", + "download", + "--no-deps", + "--dest", + str(temp_dir), + pip_git_url, + ], + check=True, + capture_output=True, + text=True, + timeout=600, + ) + + # Find the downloaded archive + archives = list(temp_dir.glob("*.tar.gz")) + list(temp_dir.glob("*.zip")) + list(temp_dir.glob("*.whl")) + if not archives: + raise RuntimeError(f"No package archive found after downloading from {git_url}") + + archive_path = archives[0] + logger.info("Downloaded archive: %s", archive_path.name) + + # Compute and log hash for integrity verification + if verify_integrity: + try: + from cpex.tools.integrity import compute_file_hash + + package_hash = compute_file_hash(archive_path) + logger.info( + "Package integrity hash for %s (%s): SHA256=%s", package_name, archive_path.name, package_hash + ) + logger.info("Store this hash for future verification or to detect tampering") + except Exception as e: + logger.warning("Failed to compute package hash: %s", str(e)) + + # Extract the archive using common helper + self._extract_package_archive(archive_path, temp_extract_dir) + + # Step 3: Find and load the manifest file + manifest_path = self._find_manifest_in_extracted_package(temp_extract_dir, package_name) + manifest_data = self._load_manifest_file(manifest_path) + + # Step 4: Normalize and validate the manifest + manifest = self._normalize_manifest_data(manifest_data, package_name, None) + + # Update the manifest with the git repo information + git_repo: GitRepo = GitRepo( + git_repository=git_url, + git_branch_tag_commit=git_branch_tag_commit, + ) + manifest.git_repo = git_repo + + package_path = manifest_path.parent + install_url = f"{package_name} @ {git_spec}" + + # Step 5: Handle installation based on plugin kind + plugin_path = self._handle_plugin_installation( + manifest, + package_path, + install_command=None, # Will install separately + ) + + # Install the package from git + if manifest.kind == "isolated_venv": + # Install into isolated venv + if plugin_path is None: + raise RuntimeError(f"Failed to initialize isolated venv for {manifest.name}") + venv_python = self._get_venv_python_executable(plugin_path / ".venv") + logger.info("Installing package into isolated venv: %s", install_url) + subprocess.run( + [venv_python, "-m", "pip", "install", install_url], + check=True, + capture_output=True, + text=True, + timeout=600, + ) + logger.info("Successfully installed into isolated venv") + else: + # Install into current venv + subprocess.run( + [self.python_executable, "-m", "pip", "install", install_url], + check=True, + capture_output=True, + text=True, + timeout=600, + ) + plugin_path = find_package_path(package_name) + + # Step 6-8: Finalize installation (persist, versions.json, registry) + plugin_path = self._finalize_plugin_installation(manifest, plugin_path, package_name) + + return manifest, plugin_path + + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Failed to install {package_name} from Git: {e.stderr}") from e + except Exception as e: + raise RuntimeError(f"Unexpected error installing {package_name} from Git: {str(e)}") from e + finally: + # Clean up temporary directory + if temp_dir.exists(): + shutil.rmtree(temp_dir) + + def uninstall_package(self, package_name: str, manifest: PluginManifest) -> bool: + """Uninstall a Python package using pip. + + Args: + package_name: The name of the package to uninstall. + + Returns: + True if uninstallation was successful, False otherwise. + + Raises: + RuntimeError: If the uninstallation process fails. + """ + try: + if manifest.kind == "isolated_venv": + # Import here to avoid circular dependency + from cpex.framework.isolated.client import IsolatedVenvPlugin + from cpex.framework.models import PluginMode + + # Create a temporary PluginConfig from the manifest + plugin_config = manifest.create_instance_config( + instance_name=manifest.name, + mode=PluginMode.SEQUENTIAL, + priority=100, + ) + + # Create an IsolatedVenvPlugin instance + isolated_plugin = IsolatedVenvPlugin( + config=plugin_config, + plugin_dirs=[str(self.plugin_folder)], + ) + + venv_python = self._get_venv_python_executable(isolated_plugin.plugin_path / ".venv") + subprocess.run( + [venv_python, "-m", "pip", "uninstall", "-y", package_name], + check=True, + capture_output=True, + text=True, + timeout=120, + ) + isolated_plugin.remove_venv() + logger.info("Successfully uninstalled package: %s", package_name) + return True + else: + subprocess.run( + [self.python_executable, "-m", "pip", "uninstall", "-y", package_name], + check=True, + capture_output=True, + text=True, + timeout=120, + ) + logger.info("Successfully uninstalled package: %s", package_name) + return True + + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Failed to uninstall {package_name}: {e.stderr}") from e + except Exception as e: + raise RuntimeError(f"Unexpected error uninstalling {package_name}: {str(e)}") from e + + def install_from_local(self, source: Path) -> tuple[PluginManifest, Path]: + """Install a plugin from a local source directory. + + This method performs the following steps: + 1. Locates and loads pyproject.toml from source or subdirectories + 2. Finds and loads the plugin-manifest.yaml from source or subdirectories + 3. Parses and validates the manifest + 4. For isolated_venv plugins: initializes the target venv and installs in editable mode + 5. For other plugins: installs in editable mode into current environment + 6. Persists the manifest to the plugin catalog + 7. Finds and saves versions.json if available + 8. Updates the plugin version registry + + Args: + source: Path to the local plugin source directory. + + Returns: + Tuple of (PluginManifest, installation_path) where installation_path is the + path where the plugin was installed. + + Raises: + FileNotFoundError: If pyproject.toml or plugin-manifest.yaml is not found in source or subdirectories. + RuntimeError: If installation fails. + """ + # Step 1: Find and load pyproject.toml in source or subdirectories + pyproject_path = None + pyproject_data = None + + # Check in the source directory itself + candidate = source / "pyproject.toml" + if candidate.exists(): + pyproject_path = candidate + else: + # Search in subdirectories (one level deep) + for subdir in source.iterdir(): + if subdir.is_dir(): + candidate = subdir / "pyproject.toml" + if candidate.exists(): + pyproject_path = candidate + break + + if pyproject_path is None: + raise FileNotFoundError(f"pyproject.toml not found in {source} or its immediate subdirectories") + + logger.info("Found pyproject.toml at: %s", pyproject_path) + + # Load and parse the pyproject.toml + try: + with open(pyproject_path, "rb") as f: + pyproject_data = tomllib.load(f) + logger.info( + "Successfully loaded pyproject.toml with project name: %s", + pyproject_data.get("project", {}).get("name", "unknown"), + ) + except Exception as e: + raise RuntimeError(f"Failed to parse pyproject.toml at {pyproject_path}: {str(e)}") from e + + # Step 2: Find plugin-manifest.yaml in source or subdirectories + manifest_path = None + + # Check in the source directory itself + candidate = source / "plugin-manifest.yaml" + if candidate.exists(): + manifest_path = candidate + else: + # Search in subdirectories (one level deep) + for subdir in source.iterdir(): + if subdir.is_dir(): + candidate = subdir / "plugin-manifest.yaml" + if candidate.exists(): + manifest_path = candidate + break + + if manifest_path is None: + raise FileNotFoundError(f"plugin-manifest.yaml not found in {source} or its immediate subdirectories") + + logger.info("Found plugin-manifest.yaml at: %s", manifest_path) + + # Step 2: Load and parse the manifest + manifest_data = self._load_manifest_file(manifest_path) + manifest = self._normalize_manifest_data(manifest_data, pyproject_data["project"]["name"], None) + manifest.local = str(source.resolve()) + + logger.info("Loaded manifest for plugin: %s (kind: %s)", manifest.name, manifest.kind) + + plugin_path = None + + # Step 3: Install based on plugin kind + if manifest.kind == "isolated_venv": + logger.info("Installing isolated_venv plugin from local source: %s", source) + + try: + # Import here to avoid circular dependency + from cpex.framework.isolated.client import IsolatedVenvPlugin + from cpex.framework.models import PluginMode + + # Create a temporary PluginConfig from the manifest + plugin_config = manifest.create_instance_config( + instance_name=manifest.name, + mode=PluginMode.SEQUENTIAL, + priority=100, + ) + + # Create an IsolatedVenvPlugin instance + isolated_plugin = IsolatedVenvPlugin( + config=plugin_config, + plugin_dirs=[str(self.plugin_folder)], + ) + + # Initialize the venv (creates venv directory structure) + import asyncio + import concurrent.futures + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop is None: + asyncio.run(isolated_plugin.initialize()) + else: + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: + ex.submit(asyncio.run, isolated_plugin.initialize()).result() + + # Get the venv python executable + venv_path = isolated_plugin.plugin_path / ".venv" + venv_python = self._get_venv_python_executable(venv_path) + + # Install the plugin in editable mode into the isolated venv + logger.info("Installing plugin in editable mode into isolated venv: %s", venv_path) + subprocess.run( + [venv_python, "-m", "pip", "install", "-e", str(source)], + check=True, + capture_output=True, + text=True, + timeout=600, + ) + + plugin_path = isolated_plugin.plugin_path + logger.info("Successfully installed %s into isolated venv at %s", manifest.name, plugin_path) + + except Exception as e: + raise RuntimeError(f"Failed to install isolated_venv plugin from {source}: {str(e)}") from e + + else: + # Install into current environment for non-isolated plugins + logger.info("Installing plugin from local source into current environment: %s", source) + + try: + subprocess.run( + [self.python_executable, "-m", "pip", "install", "-e", str(source)], + check=True, + capture_output=True, + text=True, + timeout=600, + ) + + # For non-isolated plugins, the plugin_path is the same folder that hosts the plugin-manifest.yaml + plugin_path = Path(str(manifest_path).removesuffix(manifest_path.name)) + if plugin_path is None: + # Fallback to source path if package path not found + plugin_path = source + + logger.info("Successfully installed %s into current environment at %s", manifest.name, plugin_path) + + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Failed to install plugin from {source}: {e.stderr}") from e + except Exception as e: + raise RuntimeError(f"Unexpected error installing plugin from {source}: {str(e)}") from e + + # Step 4: Persist to catalog + self._persist_manifest(manifest, manifest.name) + + # Step 5: Find and save versions.json if available + actual_plugin_path = self._find_and_load_versions_json(manifest, plugin_path, manifest.name) + + # Step 6: Update the plugin version registry + self.update_plugin_version_registry(manifest=manifest, relpath=plugin_path) + + logger.info("Successfully installed and cataloged %s from local source", manifest.name) + + # Return the actual plugin path if found, otherwise the original plugin_path + final_path = actual_plugin_path if actual_plugin_path is not None else plugin_path + return manifest, final_path diff --git a/cpex/tools/cli.py b/cpex/tools/cli.py index 43599d33..07c4c982 100644 --- a/cpex/tools/cli.py +++ b/cpex/tools/cli.py @@ -26,19 +26,42 @@ """ # Standard +import json import logging import shutil import subprocess # nosec B404 # Safe: Used only for git commands with hardcoded args from pathlib import Path +from typing import List, Optional -# Third-Party +import inquirer import typer +from rich.console import Console from typing_extensions import Annotated # First-Party +from cpex.framework.loader.config import ConfigLoader, ConfigSaver +from cpex.framework.models import ( + Config, + PluginManifest, + PluginMode, +) from cpex.framework.settings import settings +from cpex.tools.catalog import PluginCatalog + +# Third-Party +from cpex.tools.plugin_registry import PluginRegistry +from cpex.tools.settings import get_catalog_settings + +# Exit codes for CLI commands +EXIT_SUCCESS = 0 +EXIT_GENERAL_ERROR = 1 +EXIT_INVALID_ARGS = 2 +EXIT_NOT_FOUND = 3 +EXIT_OPERATION_FAILED = 4 logger = logging.getLogger(__name__) +console = Console() + # --------------------------------------------------------------------------- # Configuration defaults @@ -217,11 +240,655 @@ def bootstrap( extra_context=extra_context, ) else: - logger.warning("No local templates found and git is not available to fetch remote template.") + logger.error("No local templates found and git is not available to fetch remote template.") + raise typer.Exit(EXIT_OPERATION_FAILED) except (SystemExit, typer.Exit): raise - except Exception: + except Exception as e: logger.exception("An error was caught while copying template.") + console.print(f":x: Failed to create plugin project: {str(e)}") + raise typer.Exit(EXIT_OPERATION_FAILED) + + +def list_registered_plugins(type: str, fmt: str = "text") -> None: + """List the installed plugins + Args: + type (str): The type of plugins to list. Can be "native" or "external". + fmt (str): Output format — "text" (default) or "json". + """ + pr = PluginRegistry() + + registered_plugins = pr.registry.plugins + + if fmt == "json": + console.print( + json.dumps( + { + "plugins": [ + {"name": p.name, "version": p.version, "installation_type": p.installation_type} + for p in registered_plugins + ] + } + ) + ) + return + + if registered_plugins: + for plug_in in registered_plugins: + console.print( + f"name: {plug_in.name} version: {plug_in.version} installation type: {plug_in.installation_type}\n" + ) + else: + logger.info("No plugins registered.") + + +def instance_name_is_unique(config: Config, suggested_instance_name) -> bool: + """See if the instance name already exists in the plugins/config.yaml""" + if config.plugins is not None: + for a_plugin in config.plugins: + if a_plugin.name == suggested_instance_name: + return False + return True + + +def update_plugins_config_yaml(manifest: PluginManifest): + """ + Update the plugins/config.yaml file with the new plugin manifest. + + Args: + manifest (PluginManifest): The plugin manifest to be added to the config.yaml file. + Returns: + bool: True if the update was successful, False otherwise. + """ + plugin_configs: Config = ConfigLoader.load_config(settings.config_file) + suggested_name = manifest.suggest_instance_name() + ctr = 1 + while not instance_name_is_unique(plugin_configs, suggested_instance_name=suggested_name): + suggested_name = manifest.suggest_instance_name() + "_" + str(ctr) + ctr += 1 + + accepted_name = suggested_name + # TODO: prompt to confirm mode, priority etc and accepted name? + plugin_config = manifest.create_instance_config( + instance_name=accepted_name, mode=PluginMode.SEQUENTIAL, priority=100 + ) + if plugin_configs.plugins is None: + plugin_configs.plugins = [] + if plugin_configs.plugin_dirs is None or len(plugin_configs.plugin_dirs) == 0: + catalog_settings = get_catalog_settings() + plugin_configs.plugin_dirs = [f"{catalog_settings.FOLDER}"] + plugin_configs.plugins.append(plugin_config) + # now serialize the config + ConfigSaver.save_config(plugin_configs, settings.config_file) + + +def remove_from_plugins_config_yaml(manifest: PluginManifest) -> bool: + """ + Remove a plugin from the plugins/config.yaml file. + + Args: + plugin_name: The name of the plugin to remove from the config. + + Returns: + bool: True if the plugin was found and removed, False otherwise. + """ + try: + plugin_configs: Config = ConfigLoader.load_config(settings.config_file) + + if plugin_configs.plugins is None: + return False + + initial_count = len(plugin_configs.plugins) + plugin_configs.plugins = [ + p for p in plugin_configs.plugins if not (p.kind == manifest.kind and p.name.count(manifest.name) > 0) + ] + if len(plugin_configs.plugins) < initial_count: + ConfigSaver.save_config(plugin_configs, settings.config_file) + return True + + return False + except Exception as e: + logger.error("Error removing plugin from config: %s", str(e)) + return False + + +def install_from_manifest(manifest: PluginManifest, installation_type: str, catalog: PluginCatalog): + """ + Given a plugin manifest, download the plugin and register it in the plugin registry. + + Args: + manifest (PluginManifest): The plugin manifest to be installed. + installation_type (str): The type of installation, either "monorepo" or "pypi". + catalog (PluginCatalog): The plugin catalog to be used for installation. + Returns: + None: This function does not return anything. + """ + + # download the plugin to the plugins folder + if installation_type == "monorepo": + logger.info("installation type: %s", installation_type) + plugin_path = catalog.install_folder_via_pip(manifest) + actual_plugin_path = catalog._find_and_load_versions_json(manifest, plugin_path, manifest.name) + plugin_registry: PluginRegistry = PluginRegistry() + # add the newly downloaded plugin to the registry + plugin_registry.update( + manifest=manifest, + installation_type=installation_type, + catalog=catalog, + git_user_name=git_user_name(), + plugin_path=actual_plugin_path if actual_plugin_path is not None else plugin_path, + ) + update_plugins_config_yaml(manifest) + + +def select_plugin_from_catalog( + available_plugins: List[PluginManifest], assume_yes: bool = False +) -> Optional[PluginManifest]: + """Select a plugin from a list of available plugins using an interactive prompt. + + Args: + available_plugins: List of available plugin manifests to choose from. + assume_yes: When True, skip the interactive prompt and return the first + match (sorted by name/version descending). + + Returns: + The selected PluginManifest, or None if no selection was made. + """ + if not available_plugins: + return None + + # Sort plugins by name and version + available_plugins = sorted(available_plugins, key=lambda p: (p.name, p.version), reverse=True) + + if assume_yes: + selected_plugin = available_plugins[0] + installation_type = ( + "monorepo" + if selected_plugin.monorepo is not None + else "pypi" + if selected_plugin.package_info is not None + else "local" + ) + console.print( + "name: ", + selected_plugin.name, + "Version: ", + selected_plugin.version, + "type: ", + installation_type, + ) + return selected_plugin + + # Build choices list with plugin information + choices = [] + for index, plug_in in enumerate(available_plugins): + installation_type = ( + "monorepo" if plug_in.monorepo is not None else "pypi" if plug_in.package_info is not None else "local" + ) + choice = f"{index} name: {plug_in.name} version: {plug_in.version} installation type: {installation_type}" + choices.append((choice, index)) + + # Prompt user to select a plugin + questions = [ + inquirer.List( + "plugins", + message="Which plugin would you like to install?", + choices=choices, + ), + ] + answers = inquirer.prompt(questions) + + if not answers: + return None + + logger.info(json.dumps(answers)) + selected_index = int(answers["plugins"]) + selected_plugin = available_plugins[selected_index] + + # Display selected plugin information + installation_type = ( + "monorepo" + if selected_plugin.monorepo is not None + else "pypi" + if selected_plugin.package_info is not None + else "local" + ) + console.print( + "name: ", + selected_plugin.name, + "Version: ", + selected_plugin.version, + "type: ", + installation_type, + ) + + return selected_plugin + + +def _parse_pypi_source(source: str) -> tuple[str, Optional[str]]: + """Parse PyPI source string to extract package name and version constraint. + + Args: + source: PyPI package source string, optionally with version (e.g., "package@>=1.0.0"). + + Returns: + Tuple of (package_name, version_constraint). + """ + parts = source.split("@", 1) + package_name = parts[0] + version_constraint = parts[1] if len(parts) > 1 else None + return package_name, version_constraint + + +def _finalize_installation( + manifest: PluginManifest, install_type: str, catalog: PluginCatalog, plugin_path: Path | None = None +): + """Common finalization steps for plugin installation. + + Args: + manifest: The plugin manifest to finalize. + install_type: The type of installation (e.g., "pypi", "monorepo"). + catalog: The plugin catalog. + """ + plugin_registry = PluginRegistry() + editable = install_type == "local" + plugin_registry.update( + manifest=manifest, + installation_type=install_type, + catalog=catalog, + git_user_name=git_user_name(), + plugin_path=plugin_path, + editable=editable, + ) + update_plugins_config_yaml(manifest=manifest) + + +def _install_from_local(source: str, catalog: PluginCatalog, use_test: bool = False): + """Handle local-based installation (not yet implemented). + + Args: + source: local path. + catalog: The plugin catalog. + + Raises: + FileNotFoundError: If plugin-manifest.yaml is not found in source or subdirectories. + RuntimeError: If installation fails. + """ + install_source = Path(source) + with console.status(f"Installing plugin from source {source}...", spinner="dots"): + manifest, installation_path = catalog.install_from_local(install_source) + _finalize_installation(manifest, "local", catalog, installation_path) + console.print(f":white_heavy_check_mark: {manifest.name} installation complete.") + + +def _install_from_git(source: str, catalog: PluginCatalog, use_test: bool = False): + """Handle git-based installation. + + Args: + source: Git repository URL or path. + catalog: The plugin catalog. + use_test: Unused for git installations (kept for consistency). + """ + # Get integrity verification setting from catalog settings + catalog_settings = get_catalog_settings() + verify_integrity = catalog_settings.VERIFY_PACKAGE_INTEGRITY + + if verify_integrity: + console.log("Package integrity verification: enabled (hash will be computed and logged)") + else: + console.log("Package integrity verification: disabled") + + with console.status(f"Installing plugin from source {source}...", spinner="dots"): + manifest, installation_path = catalog.install_from_git(source, verify_integrity=verify_integrity) + _finalize_installation(manifest, "git", catalog, installation_path) + console.print(f":white_heavy_check_mark: {manifest.name} installation complete.") + + +def _install_from_monorepo(source: str, catalog: PluginCatalog, use_test: bool = False, assume_yes: bool = False): + """Handle monorepo-based installation. + + Args: + source: Plugin name or search term in the monorepo. + catalog: The plugin catalog. + assume_yes: Skip the interactive selection prompt. + """ + logger.info("Trying to install from git monorepo: %s", source) + available_plugins = catalog.search(source) + + if not available_plugins: + console.print("No matching plugins found.") + return + + selected_plugin = select_plugin_from_catalog(available_plugins, assume_yes=assume_yes) + if not selected_plugin: + return + + with console.status(f"Installing plugin {selected_plugin.name}...", spinner="dots"): + install_from_manifest(selected_plugin, "monorepo", catalog=catalog) + + console.print(f":white_heavy_check_mark: {selected_plugin.name} installation complete.") + + +def _install_from_pypi(source: str, catalog: PluginCatalog, use_test: bool = False): + """Handle PyPI-based installation. + + Args: + source: PyPI package name, optionally with version constraint (e.g., "package@>=1.0.0"). + catalog: The plugin catalog. + use_test: Whether to use test.pypi.org instead of pypi.org. + """ + logger.info("Trying to install from pypi package %s", source) + + # Parse version constraint + package_name, version_constraint = _parse_pypi_source(source) + + # Get integrity verification setting from catalog settings + catalog_settings = get_catalog_settings() + verify_integrity = catalog_settings.VERIFY_PACKAGE_INTEGRITY + + if verify_integrity: + console.log("Package integrity verification: enabled") + else: + console.log("Package integrity verification: disabled") + + with console.status(f"Installing plugin {package_name} via pypi", spinner="dots"): + manifest, plugin_path = catalog.install_from_pypi( + plugin_package_name=package_name, + version_constraint=version_constraint, + use_pytest=use_test, + verify_integrity=verify_integrity, + ) + + if manifest is None: + console.print(f":x: Failed to install {package_name}") + return + + _finalize_installation(manifest, "pypi", catalog, plugin_path) + console.print(f":white_heavy_check_mark: {package_name} installation complete.") + + +def install(source: str, install_type: str | None, catalog: PluginCatalog, assume_yes: bool = False): + """Install a plugin from its associated source. + + Args: + source: The source of the plugin (package name, repo URL, or search term). + install_type: The type of installation ("git", "monorepo", or "pypi"). + catalog: The catalog of plugins. + assume_yes: Skip interactive selection prompt for monorepo installs. + + Raises: + typer.Exit: With EXIT_INVALID_ARGS if install_type is not supported. + typer.Exit: With EXIT_OPERATION_FAILED if installation fails. + """ + if install_type is None: + install_type = "monorepo" + + if install_type == "monorepo": + try: + _install_from_monorepo(source, catalog, assume_yes=assume_yes) + return + except Exception as e: + console.print(f":x: Installation failed: {str(e)}") + logger.error("Install error: %s", str(e), exc_info=True) + raise typer.Exit(EXIT_OPERATION_FAILED) + + handlers = { + "git": _install_from_git, + "pypi": _install_from_pypi, + "test-pypi": _install_from_pypi, + "local": _install_from_local, + } + + handler = handlers.get(install_type) + if handler is None: + console.print( + f":x: Unsupported installation type: {install_type}. Must be one of: {', '.join(handlers.keys())}" + ) + raise typer.Exit(EXIT_INVALID_ARGS) + + try: + handler(source, catalog, use_test=True if install_type == "test-pypi" else False) + except Exception as e: + console.print(f":x: Installation failed: {str(e)}") + logger.error("Install error: %s", str(e), exc_info=True) + raise typer.Exit(EXIT_OPERATION_FAILED) + + +def versions(plugin_name: str | None, catalog: PluginCatalog, fmt: str = "text"): + """List available versions of the plugin + Args: + plugin_name (str | None): The name of the plugin to search for. + catalog (PluginCatalog): The catalog to search in. + fmt (str): Output format — "text" (default) or "json". + """ + return search(plugin_name, catalog, fmt=fmt) + + +def search(plugin_name: str | None, catalog: PluginCatalog, fmt: str = "text"): + """Search for a plugin in the catalog + Args: + plugin_name (str | None): The name of the plugin to search for. + catalog (PluginCatalog): The catalog to search in. + fmt (str): Output format — "text" (default) or "json". + Returns: + list[Plugin]: A list of plugins that match the search criteria. + """ + with console.status("Searching for available plugins ...", spinner="dots"): + available_plugins = catalog.search(plugin_name) + + if fmt == "json": + print( + json.dumps( + { + "results": [ + { + "name": p.name, + "version": p.version, + "installation_type": ( + "monorepo" + if p.monorepo is not None + else "pypi" + if p.package_info is not None + else "local" + ), + } + for p in (available_plugins or []) + ] + } + ) + ) + return + + if available_plugins: + console.log("Available plugins:") + for plug_in in available_plugins: + msg = f"name: {plug_in.name} version: {plug_in.version} installation type: {'monorepo' if plug_in.monorepo is not None else 'pypi' if plug_in.package_info is not None else 'local'}" + console.log(msg) + else: + console.log("No plugins found.") + + +def info(plugin_name: str | None, fmt: str = "text"): + """Search for or list all installed plugins + + Args: + plugin_name (str | None): The name of the plugin to search for. + If None, list all installed plugins. + fmt (str): Output format — "text" (default) or "json". + """ + registry = PluginRegistry().registry + + matches = [ + p + for p in registry.plugins + if plugin_name is None + or p.name.lower().count(plugin_name.lower()) > 0 + or p.kind.lower().count(plugin_name.lower()) > 0 + ] + + if fmt == "json": + print(json.dumps({"plugins": [p.model_dump() for p in matches]})) + return + + if matches: + for plug_in in matches: + console.print_json(json.dumps(plug_in.model_dump())) + else: + console.print("No plugins found") + + +def uninstall(plugin_name: str, catalog: PluginCatalog, assume_yes: bool = False) -> None: + """Uninstall a plugin. + + Args: + plugin_name: The name of the plugin to uninstall. + catalog: The plugin catalog. + assume_yes: Skip the confirmation prompt. + """ + # Get plugin registry to find the installed plugin + plugin_registry = PluginRegistry() + + # Find the plugin in the registry + installed_plugin = None + for plugin in plugin_registry.registry.plugins: + if plugin.name == plugin_name: + installed_plugin = plugin + break + + if installed_plugin is None: + console.print(f":x: Plugin '{plugin_name}' is not installed.") + raise typer.Exit(EXIT_NOT_FOUND) + + # Confirm uninstallation + console.print(f"Found plugin: {installed_plugin.name} (version {installed_plugin.version})") + console.print(f"Installation type: {installed_plugin.installation_type}") + console.print(f"Installation path: {installed_plugin.installation_path}") + + if not assume_yes: + questions = [ + inquirer.Confirm( + "confirm", + message=f"Are you sure you want to uninstall '{plugin_name}'?", + default=False, + ), + ] + answers = inquirer.prompt(questions) + + if not answers or not answers["confirm"]: + console.print("Uninstall cancelled.") + return + + try: + with console.status(f"Uninstalling plugin {plugin_name}...", spinner="dots"): + # retrieve the manifest so we can match on kind value + catalog = PluginCatalog() + manifest = catalog.find(plugin_name) + # Remove from plugins/config.yaml + if manifest: + remove_from_plugins_config_yaml(manifest) + catalog.uninstall_package(plugin_name, manifest) + # Remove from plugin registry + plugin_registry.remove(plugin_name) + else: + console.print(f":x: Plugin {plugin_name} not found in catalog.") + raise typer.Exit(EXIT_NOT_FOUND) + + console.print(f":white_heavy_check_mark: {plugin_name} uninstalled successfully.") + + except typer.Exit: + raise + except Exception as e: + console.print(f":x: Failed to uninstall {plugin_name}: {str(e)}") + logger.error("Uninstall error: %s", str(e), exc_info=True) + raise typer.Exit(EXIT_OPERATION_FAILED) + + +@app.command( + help="List, search, install or uninstall plugins.\n\n" + "Exit Codes:\n" + " 0 - Success\n" + " 1 - General error\n" + " 2 - Invalid arguments\n" + " 3 - Plugin not found\n" + " 4 - Operation failed\n\n" + "Default install type is monorepo\n\n" + "Examples:\n" + "python cpex/tools/cli.py plugin info pii\n" + "python cpex/tools/cli.py plugin search pii\n" + "python cpex/tools/cli.py plugin --type monorepo search pii\n" + "python cpex/tools/cli.py plugin --type monorepo install cpex-pii-filter\n" + 'python cpex/tools/cli.py plugin --type pypi install "ExamplePlugin@>=0.1.0"\n' + 'python cpex/tools/cli.py plugin --type test-pypi install "cpex-test-plugin@>=0.1.1"\n' + 'python cpex/tools/cli.py plugin --type git install "cpex-test-plugin @ git+https://github.com/tedhabeck/cpex-test-plugin@main"\n' + "python cpex/tools/cli.py plugin versions cpex-test-plugin\n" + "python cpex/tools/cli.py plugin uninstall cpex-pii-filter\n" +) +def plugin( + cmd_action: str = typer.Argument(None, help="One of: list|info|install|search|versions|uninstall"), + source: str | None = typer.Argument(None, help="The pypi, git, or local folder where the plugin resides"), + install_type: Annotated[ + str, + typer.Option( + "--type", + "-t", + help="The types of plugins to list. One of: monorepo|pypi|test-pypi|git|local Defaults to monorepo if unspecified.", + ), + ] = None, + assume_yes: Annotated[ + bool, + typer.Option( + "--yes", + "-y", + help="Bypass interactive prompts: pick the first match on install, skip confirm on uninstall.", + ), + ] = False, + fmt: Annotated[ + str, + typer.Option( + "--format", + "-f", + help="Output format for read commands: 'text' (default) or 'json'.", + ), + ] = "text", +) -> None: + """Lists installed plugins""" + if cmd_action == "info": + return info(source, fmt=fmt) + + # For uninstall, we don't need to update the catalog + if cmd_action == "uninstall": + if source is None: + console.print(":x: Please specify a plugin name to uninstall.") + raise typer.Exit(EXIT_INVALID_ARGS) + pc = PluginCatalog() + return uninstall(source, catalog=pc, assume_yes=assume_yes) + if cmd_action == "install" and source is not None: + registry = PluginRegistry() + if registry.has(source): + console.print(f"Plugin {source} is already installed.") + return + + # update the catalog before proceeding with install etc. + pc = PluginCatalog() + # optimized github search REST api takes ~14s to search & download all manifests + if install_type not in {"test-pypi", "pypi", "local"}: + console.log("Update catalog") + with console.status("Updating catalog...", spinner="dots"): + rc = pc.update_catalog_with_pyproject() + if rc: + console.log(":x: Catalog update failed.") + else: + console.log("Catalog update completed.") + + if cmd_action == "versions": + return versions(source, catalog=pc, fmt=fmt) + + if cmd_action == "list": + return list_registered_plugins(install_type, fmt=fmt) + if cmd_action == "install" and source is not None: + return install(source, install_type, catalog=pc, assume_yes=assume_yes) + if cmd_action == "search": + return search(source, catalog=pc, fmt=fmt) @app.callback() @@ -229,38 +896,6 @@ def callback() -> None: # pragma: no cover """This function exists to force 'bootstrap' to be a subcommand.""" -# @app.command(help="Installs plugins into a Python environment.") -# def install( -# install_manifest: Annotated[typer.FileText, typer.Option("--install_manifest", "-i", help="The install manifest describing which plugins to install.")] = DEFAULT_INSTALL_MANIFEST, -# installer: Annotated[str, typer.Option("--installer", "-c", help="The install command to install plugins.")] = DEFAULT_INSTALLER, -# ): -# typer.echo(f"Installing plugin packages from {install_manifest.name}") -# data = yaml.safe_load(install_manifest) -# manifest = InstallManifest.model_validate(data) -# for pkg in manifest.packages: -# typer.echo(f"Installing plugin package {pkg.package} from {pkg.repository}") -# repository = os.path.expandvars(pkg.repository) -# cmd = installer.split(" ") -# if pkg.extras: -# cmd.append(f"{pkg.package}[{','.join(pkg.extras)}]@{repository}") -# else: -# cmd.append(f"{pkg.package}@{repository}") -# subprocess.run(cmd) - - -# @app.command(help="Builds an MCP server to serve plugins as tools.") -# def package( -# image_tag: Annotated[str, typer.Option("--image_tag", "-t", help="The container image tag to generated container.")] = DEFAULT_IMAGE_TAG, -# containerfile: Annotated[Path, typer.Option("--containerfile", "-c", help="The Dockerfile used to build the container.")] = DEFAULT_CONTAINERFILE_PATH, -# builder: Annotated[str, typer.Option("--builder", "-b", help="The container builder, compatible with docker build.")] = DEFAULT_IMAGE_BUILDER, -# build_context: Annotated[Path, typer.Option("--build_context", "-p", help="The container builder context, specified as a path.")] = DEFAULT_BUILD_CONTEXT, -# ): -# typer.echo("Building MCP server image") -# cmd = builder.split(" ") -# cmd.extend(["-f", containerfile, "-t", image_tag, build_context]) -# subprocess.run(cmd) - - def main() -> None: # noqa: D401 - imperative mood is fine here """Entry point for the *mcpplugins* console script. @@ -280,4 +915,9 @@ def main() -> None: # noqa: D401 - imperative mood is fine here if __name__ == "__main__": # pragma: no cover - executed only when run directly + # logging.basicConfig( + # level=logging.INFO, + # format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + # stream=sys.stderr, # Log to stderr to keep stdout clean for coordination + # ) main() diff --git a/cpex/tools/integrity.py b/cpex/tools/integrity.py new file mode 100644 index 00000000..9f23f884 --- /dev/null +++ b/cpex/tools/integrity.py @@ -0,0 +1,301 @@ +# -*- coding: utf-8 -*- +"""Location: ./cpex/tools/integrity.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck + +Package integrity verification utilities. + +This module provides SHA256 hash verification for downloaded packages +to ensure integrity beyond pip's built-in checks. It fetches expected +hashes from PyPI's JSON API and verifies downloaded files against them. + +Features +──────── +* SHA256 hash computation for package files +* PyPI JSON API integration for hash retrieval +* Configurable verification modes (strict/permissive) +* Detailed logging and error reporting + +Typical usage +───────────── +```python +from cpex.tools.integrity import verify_package_integrity, fetch_pypi_package_hashes + +# Fetch expected hashes from PyPI +hashes = fetch_pypi_package_hashes("requests", "2.31.0") + +# Verify downloaded package +verify_package_integrity(Path("/tmp/requests-2.31.0.tar.gz"), hashes["sha256"]) +``` +""" + +# Standard +import hashlib +import logging +from pathlib import Path +from typing import Optional + +import httpx + +logger = logging.getLogger(__name__) + +# Constants +PYPI_JSON_API_URL = "https://pypi.org/pypi/{package}/json" +TEST_PYPI_JSON_API_URL = "https://test.pypi.org/pypi/{package}/json" +HASH_CHUNK_SIZE = 8192 # 8KB chunks for efficient file reading + + +class IntegrityVerificationError(Exception): + """Raised when package integrity verification fails. + + This exception indicates that a downloaded package's hash does not + match the expected hash from PyPI, suggesting potential tampering + or corruption. + + Attributes: + package_name: Name of the package that failed verification. + expected_hash: The expected SHA256 hash from PyPI. + actual_hash: The computed SHA256 hash of the downloaded file. + """ + + def __init__(self, package_name: str, expected_hash: str, actual_hash: str): + """Initialize the exception with verification details. + + Args: + package_name: Name of the package that failed verification. + expected_hash: The expected SHA256 hash from PyPI. + actual_hash: The computed SHA256 hash of the downloaded file. + """ + self.package_name = package_name + self.expected_hash = expected_hash + self.actual_hash = actual_hash + super().__init__( + f"Integrity verification failed for {package_name}: " + f"expected {expected_hash[:16]}..., got {actual_hash[:16]}..." + ) + + +def compute_file_hash(file_path: Path, algorithm: str = "sha256") -> str: + """Compute cryptographic hash of a file. + + Reads the file in chunks to handle large files efficiently without + loading the entire file into memory. + + Args: + file_path: Path to the file to hash. + algorithm: Hash algorithm to use (default: sha256). + + Returns: + Hexadecimal hash string. + + Raises: + FileNotFoundError: If the file does not exist. + ValueError: If the hash algorithm is not supported. + + Examples: + >>> from pathlib import Path + >>> import tempfile + >>> with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: + ... _ = f.write("test content") + ... temp_path = Path(f.name) + >>> hash_value = compute_file_hash(temp_path) + >>> len(hash_value) + 64 + >>> temp_path.unlink() + """ + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + try: + hasher = hashlib.new(algorithm) + except ValueError as e: + raise ValueError(f"Unsupported hash algorithm: {algorithm}") from e + + with open(file_path, "rb") as f: + while chunk := f.read(HASH_CHUNK_SIZE): + hasher.update(chunk) + + hash_value = hasher.hexdigest() + logger.debug("Computed %s hash for %s: %s", algorithm, file_path.name, hash_value[:16] + "...") + return hash_value + + +def fetch_pypi_package_hashes( + package_name: str, version: Optional[str] = None, use_test: bool = False, timeout: float = 30.0 +) -> dict[str, dict[str, str]]: + """Fetch package hashes from PyPI JSON API. + + Retrieves SHA256 hashes for all distribution files of a package version + from PyPI's JSON API. If no version is specified, fetches hashes for + the latest version. + + Args: + package_name: Name of the package on PyPI. + version: Specific version to fetch hashes for (optional). + use_test: Whether to use test.pypi.org instead of pypi.org. + timeout: HTTP request timeout in seconds. + + Returns: + Dictionary mapping filename to hash information: + { + "package-1.0.0.tar.gz": { + "sha256": "abc123...", + "url": "https://files.pythonhosted.org/..." + } + } + + Raises: + RuntimeError: If the API request fails or package is not found. + + Examples: + >>> hashes = fetch_pypi_package_hashes("requests", "2.31.0") # doctest: +SKIP + >>> "requests-2.31.0.tar.gz" in hashes # doctest: +SKIP + True + """ + api_url = TEST_PYPI_JSON_API_URL if use_test else PYPI_JSON_API_URL + url = api_url.format(package=package_name) + + if version: + url = f"{url.rstrip('/json')}/{version}/json" + + logger.debug("Fetching package hashes from: %s", url) + + try: + with httpx.Client(timeout=timeout) as client: + response = client.get(url) + response.raise_for_status() + data = response.json() + + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + raise RuntimeError(f"Package '{package_name}' not found on {'test.' if use_test else ''}PyPI") from e + raise RuntimeError(f"Failed to fetch package metadata: {e}") from e + except httpx.RequestError as e: + raise RuntimeError(f"Network error fetching package metadata: {e}") from e + except Exception as e: + raise RuntimeError(f"Unexpected error fetching package metadata: {e}") from e + + # Extract hashes from the response + hashes = {} + urls = data.get("urls", []) + + if not urls: + logger.warning("No distribution files found for %s", package_name) + return hashes + + for file_info in urls: + filename = file_info.get("filename") + digests = file_info.get("digests", {}) + sha256_hash = digests.get("sha256") + file_url = file_info.get("url") + + if filename and sha256_hash: + hashes[filename] = {"sha256": sha256_hash, "url": file_url} + logger.debug("Found hash for %s: %s...", filename, sha256_hash[:16]) + + logger.info("Fetched hashes for %d distribution files of %s", len(hashes), package_name) + return hashes + + +def verify_package_integrity( + file_path: Path, expected_hash: str, package_name: Optional[str] = None, strict: bool = True +) -> bool: + """Verify package file integrity against expected SHA256 hash. + + Computes the SHA256 hash of the file and compares it to the expected + hash. In strict mode, raises an exception on mismatch. In non-strict + mode, logs a warning and returns False. + + Args: + file_path: Path to the package file to verify. + expected_hash: Expected SHA256 hash (hexadecimal string). + package_name: Name of the package (for error messages). + strict: If True, raise exception on mismatch. If False, return False. + + Returns: + True if hash matches, False if mismatch in non-strict mode. + + Raises: + IntegrityVerificationError: If hash doesn't match in strict mode. + FileNotFoundError: If the file does not exist. + + Examples: + >>> from pathlib import Path + >>> import tempfile + >>> with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: + ... _ = f.write("test") + ... temp_path = Path(f.name) + >>> expected = compute_file_hash(temp_path) + >>> verify_package_integrity(temp_path, expected, "test-pkg") + True + >>> temp_path.unlink() + """ + if not file_path.exists(): + raise FileNotFoundError(f"Package file not found: {file_path}") + + pkg_name = package_name or file_path.name + logger.info("Verifying integrity of %s", pkg_name) + + actual_hash = compute_file_hash(file_path) + + if actual_hash.lower() == expected_hash.lower(): + logger.info("✓ Integrity verification passed for %s", pkg_name) + return True + + error_msg = ( + f"Integrity verification failed for {pkg_name}\n" + f" Expected: {expected_hash}\n" + f" Actual: {actual_hash}\n" + f" File: {file_path}" + ) + + if strict: + logger.error(error_msg) + raise IntegrityVerificationError(pkg_name, expected_hash, actual_hash) + + logger.warning(error_msg) + return False + + +def find_matching_hash( + file_path: Path, hashes_dict: dict[str, dict[str, str]], package_name: Optional[str] = None +) -> Optional[str]: + """Find the expected hash for a downloaded file from PyPI hashes dictionary. + + Matches the downloaded file against the hashes dictionary by filename. + Handles various filename patterns including wheels and source distributions. + + Args: + file_path: Path to the downloaded package file. + hashes_dict: Dictionary of hashes from fetch_pypi_package_hashes(). + package_name: Name of the package (for logging). + + Returns: + The expected SHA256 hash if found, None otherwise. + + Examples: + >>> hashes = {"pkg-1.0.0.tar.gz": {"sha256": "abc123", "url": "..."}} + >>> find_matching_hash(Path("/tmp/pkg-1.0.0.tar.gz"), hashes) + 'abc123' + """ + filename = file_path.name + pkg_name = package_name or filename + + if filename in hashes_dict: + hash_value = hashes_dict[filename]["sha256"] + logger.debug("Found matching hash for %s", filename) + return hash_value + + # Try case-insensitive match + for key, value in hashes_dict.items(): + if key.lower() == filename.lower(): + hash_value = value["sha256"] + logger.debug("Found case-insensitive match for %s", filename) + return hash_value + + logger.warning("No matching hash found for %s in PyPI metadata", pkg_name) + return None + + +# Made with Bob diff --git a/cpex/tools/plugin_registry.py b/cpex/tools/plugin_registry.py new file mode 100644 index 00000000..29f4dda8 --- /dev/null +++ b/cpex/tools/plugin_registry.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +"""Location: ./cpex/tools/plugin_registry.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck + +This module implements the plugin registry object. +""" + +import datetime +import json +import os +from pathlib import Path + +from cpex.framework.models import InstalledPluginInfo, InstalledPluginRegistry, PluginInstallationType, PluginManifest +from cpex.framework.utils import find_package_path +from cpex.tools.catalog import PluginCatalog +from cpex.tools.settings import get_plugin_registry_path + + +class PluginRegistry: + """Plugin registry. + Plugin registry is responsible for storing information about installed plugins. + """ + + registry: InstalledPluginRegistry = InstalledPluginRegistry() + + def __init__(self, *args, **kwargs): + """Initialize the plugin registry.""" + super().__init__(*args, **kwargs) + ipr_file = get_plugin_registry_path() + os.makedirs(ipr_file.parent, exist_ok=True) + if ipr_file.exists(): + try: + with open(ipr_file, "r", encoding="utf-8") as ipr: + self.registry = InstalledPluginRegistry(**json.load(ipr)) + except (json.JSONDecodeError, ValueError, KeyError) as e: + # If registry is corrupted, log error and start fresh + import logging + + logger = logging.getLogger(__name__) + logger.error( + "Corrupted plugin registry file at %s: %s. Starting with empty registry.", ipr_file, str(e) + ) + # Backup the corrupted file + backup_file = ipr_file.with_suffix(".json.corrupted") + try: + ipr_file.rename(backup_file) + logger.info("Backed up corrupted registry to %s", backup_file) + except Exception as backup_error: + logger.warning("Could not backup corrupted registry: %s", str(backup_error)) + self.registry = InstalledPluginRegistry() + else: + self.registry = InstalledPluginRegistry() + + def update( + self, + manifest: PluginManifest, + installation_type: str, + catalog: PluginCatalog, + git_user_name: str, + plugin_path: Path | None = None, + editable: bool = False, + ) -> None: + """ + Given a plugin manifest, register it in the plugin registry. + + Args: + manifest: PluginManifest: The manifest of the plugin to be registered. + installation_type: str: The type of installation (e.g., "local", "global"). + catalog: PluginCatalog: The catalog containing the plugin. + git_user_name: str: The name of the user who installed the plugin. + + Raises: + RuntimeError: If the plugin manifest is invalid or the installation type is not recognized. + """ + package_source = "" + if installation_type == "monorepo": + if manifest.monorepo is None: + raise RuntimeError("PluginManifest.monorepo can not be None.") + package_source = manifest.monorepo.package_source + elif installation_type == "pypi": + if manifest.package_info is None: + raise RuntimeError("PluginManifest.package_info can not be None.") + package_source = manifest.package_info.pypi_package + elif installation_type == "local": + if manifest.local is None: + raise RuntimeError("PluginManifest local path can not be None.") + package_source = manifest.local + elif installation_type == "git": + if manifest.git_repo is None: + raise RuntimeError("PluginManifest.git_repo can not be None.") + package_source = manifest.name + " @ " + manifest.git_repo.git_repository + if manifest.git_repo.git_branch_tag_commit is not None: + package_source += f"@{manifest.git_repo.git_branch_tag_commit}" + else: + raise ValueError(f"Invalid installation type: {installation_type}") + + installation_path = plugin_path if plugin_path is not None else find_package_path(manifest.name) + + ipi: InstalledPluginInfo = InstalledPluginInfo( + name=manifest.name, + kind=manifest.kind, + version=manifest.version, + installation_type=PluginInstallationType(installation_type), + installation_path=str(installation_path.resolve()), + installed_at=datetime.datetime.now(datetime.timezone.utc).isoformat() + "Z", + installed_by=git_user_name, + package_source=package_source, + editable=editable, + ) + # add the newly downloaded plugin to the registry + self.registry.register_plugin(ipi) + + def has(self, plugin_name: str) -> bool: + """ + Check if a plugin is installed. + Args: + plugin_name: The name of the plugin to check. + Returns: + True if the plugin is installed, False otherwise. + """ + for plugin in self.registry.plugins: + if plugin.name == plugin_name: + return True + return False + + def remove(self, plugin_name: str) -> bool: + """ + Remove a plugin from the registry. + + Args: + plugin_name: The name of the plugin to remove. + + Returns: + True if the plugin was found and removed, False otherwise. + """ + return self.registry.unregister_plugin(plugin_name) diff --git a/cpex/tools/settings.py b/cpex/tools/settings.py new file mode 100644 index 00000000..f64111ab --- /dev/null +++ b/cpex/tools/settings.py @@ -0,0 +1,68 @@ +"""Location: ./cpex/tools/settings.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck + +This module implements the plugin catalog object. +""" + +import logging +import os +from pathlib import Path + +from dotenv import find_dotenv, load_dotenv +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +logger = logging.getLogger(__name__) + + +load_dotenv(find_dotenv("../../.env")) + + +class CatalogSettings(BaseSettings): + """Catalog settings.""" + + model_config = SettingsConfigDict(env_prefix="PLUGINS_", env_file=".env", env_file_encoding="utf-8", extra="ignore") + + GITHUB_TOKEN: str | None = Field( + default=None, description="The github token for accessing the plugins repositories" + ) + GITHUB_API: str | None = Field(default="api.github.com", description="api.github.com") + REPO_URLS: str = Field( + default="https://github.com/ibm/cpex-plugins", description="The url of the plugins repositories comma separated" + ) + REGISTRY_FOLDER: str | None = Field( + default="data", description="The folder where the plugin registry is located (r/w)" + ) + CATALOG_FOLDER: str = Field( + default="plugin-catalog", description="The folder where the plugin catalog is located (r/w)" + ) + FOLDER: str = Field(default="plugins", description="The folder where the plugins are located (r/w)") + VERIFY_PACKAGE_INTEGRITY: bool = Field( + default=True, description="Enable SHA256 hash verification for downloaded packages from PyPI" + ) + STRICT_INTEGRITY_MODE: bool = Field( + default=False, description="Fail installation if package hashes are unavailable (strict mode)" + ) + + +def get_catalog_settings() -> CatalogSettings: + """Get catalog settings. + Returns: + CatalogSettings: Catalog settings. + """ + return CatalogSettings() + + +def get_plugin_registry_path() -> Path: + """Get the plugin registry file path. + + This centralizes the logic for determining where the plugin registry is stored. + Uses PLUGIN_REGISTRY_FILE env var if set, otherwise falls back to 'data' folder. + + Returns: + Path: Path to the installed-plugins.json file. + """ + folder = Path(os.environ.get("PLUGIN_REGISTRY_FILE", "data")) + return folder / "installed-plugins.json" diff --git a/docs/content/docs/package-integrity.md b/docs/content/docs/package-integrity.md new file mode 100644 index 00000000..66dda63f --- /dev/null +++ b/docs/content/docs/package-integrity.md @@ -0,0 +1,323 @@ +--- +title: "Package Integrity Verification" +weight: 150 +--- + +# Package Integrity Verification + +The CPEX framework includes built-in SHA256 hash verification for packages, providing an additional security layer beyond pip's built-in checks. + +## Overview + +The framework provides integrity verification for different installation sources: + +### PyPI Packages + +When installing plugins from PyPI, the framework automatically: + +1. Fetches expected SHA256 hashes from PyPI's JSON API +2. Downloads the package file +3. Computes the SHA256 hash of the downloaded file +4. Compares the computed hash against the expected hash +5. Aborts installation if hashes don't match + +### Git and Monorepo Packages + +When installing from Git repositories or monorepos, the framework: + +1. Downloads the package archive +2. Computes the SHA256 hash of the downloaded file +3. Logs the hash for future reference and verification +4. Allows manual verification against known-good hashes + +This protects against: +- **Tampered packages**: Detects if a package has been modified in transit +- **Corrupted downloads**: Identifies incomplete or corrupted downloads +- **Supply chain attacks**: Verifies package authenticity + +## Configuration + +### Environment Variables + +Control integrity verification behavior using environment variables: + +```bash +# Enable/disable integrity verification (default: true) +export PLUGINS_VERIFY_PACKAGE_INTEGRITY=true + +# Strict mode: fail if hashes unavailable (default: false) +export PLUGINS_STRICT_INTEGRITY_MODE=false +``` + +### Configuration File + +Add to your `.env` file: + +```ini +# Package Integrity Verification +PLUGINS_VERIFY_PACKAGE_INTEGRITY=true +PLUGINS_STRICT_INTEGRITY_MODE=false +``` + +## Verification Modes + +### Standard Mode (Default) + +```bash +PLUGINS_VERIFY_PACKAGE_INTEGRITY=true +PLUGINS_STRICT_INTEGRITY_MODE=false +``` + +**Behavior:** +- Verifies packages when hashes are available +- Warns but continues if hashes are unavailable +- Fails immediately on hash mismatch + +**Use case:** Recommended for most deployments. Provides security without breaking installations for packages that don't publish hashes. + +### Strict Mode + +```bash +PLUGINS_VERIFY_PACKAGE_INTEGRITY=true +PLUGINS_STRICT_INTEGRITY_MODE=true +``` + +**Behavior:** +- Requires hashes for all packages +- Fails installation if hashes are unavailable +- Fails immediately on hash mismatch + +**Use case:** High-security environments where all packages must be verifiable. + +### Disabled Mode + +```bash +PLUGINS_VERIFY_PACKAGE_INTEGRITY=false +``` + +**Behavior:** +- Skips hash verification entirely +- Relies only on pip's built-in checks + +**Use case:** Development environments or when troubleshooting installation issues. + +## Usage Examples + +### Installing with Verification (Default) + +**PyPI Installation:** +```bash +# Verification is enabled by default +cpex plugin install --type pypi my-plugin + +# Output shows verification status: +# Package integrity verification: enabled +# Fetching package hashes from PyPI for my-plugin +# Retrieved hashes for 2 distribution files +# Verifying integrity of my-plugin-1.0.0.tar.gz +# ✓ Integrity verification passed for my-plugin +``` + +**Git Installation:** +```bash +# Hash is computed and logged for future verification +cpex plugin install --type git "MyPlugin @ git+https://github.com/user/repo.git" + +# Output shows: +# Package integrity verification: enabled (hash will be computed and logged) +# Package integrity hash for MyPlugin (MyPlugin-1.0.0.tar.gz): SHA256=abc123def456... +# Store this hash for future verification or to detect tampering +``` + +**Monorepo Installation:** +```bash +# Hash is computed and logged +cpex plugin install --type monorepo my-plugin + +# Output shows: +# Package integrity hash for my-plugin (my-plugin-1.0.0.tar.gz): SHA256=abc123def456... +# Store this hash for future verification or to detect tampering +``` + +### Installing with Verification Disabled + +```bash +# Temporarily disable verification +export PLUGINS_VERIFY_PACKAGE_INTEGRITY=false +cpex plugin install --type pypi my-plugin + +# Output shows: +# Package integrity verification: disabled +``` + +### Installing from Test PyPI + +```bash +# Verification works with test.pypi.org too +cpex plugin install --type test-pypi my-test-plugin +``` + +## Error Handling + +### Hash Mismatch + +If a downloaded package's hash doesn't match the expected hash: + +``` +ERROR: Integrity verification failed for my-plugin + Expected: abc123def456... + Actual: 789ghi012jkl... + File: /tmp/cpex_plugin_my-plugin_xyz/my-plugin-1.0.0.tar.gz + +IntegrityVerificationError: Integrity verification failed for my-plugin +``` + +**Resolution:** +1. Retry the installation (may be a transient network issue) +2. Check if PyPI is experiencing issues +3. Report to package maintainer if problem persists + +### Hash Unavailable + +If PyPI doesn't provide hashes for a package: + +**Standard Mode:** +``` +WARNING: No hashes available from PyPI for my-plugin +WARNING: No matching hash found for my-plugin-1.0.0.tar.gz. Proceeding without verification. +``` +Installation continues. + +**Strict Mode:** +``` +ERROR: No hashes available from PyPI for my-plugin +RuntimeError: Package hashes required in strict mode but not available +``` +Installation fails. + +### Network Error Fetching Hashes + +If the PyPI API is unreachable: + +``` +WARNING: Failed to fetch hashes from PyPI: Connection timeout. Proceeding without verification. +``` + +Installation continues to avoid breaking deployments due to temporary network issues. + +## Security Best Practices + +### Production Deployments + +1. **Enable verification** (default setting) +2. **Monitor logs** for verification warnings +3. **Consider strict mode** for critical environments +4. **Use private PyPI mirrors** with known-good packages + +### Development Environments + +1. **Keep verification enabled** to catch issues early +2. **Use standard mode** for flexibility +3. **Disable only when troubleshooting** specific issues + +### CI/CD Pipelines + +1. **Enable verification** in all pipelines +2. **Use strict mode** for production deployments +3. **Cache verified packages** to reduce API calls +4. **Fail builds** on verification errors + +## Technical Details + +### Hash Algorithm + +- **Algorithm**: SHA256 (256-bit) +- **Source**: PyPI JSON API (`/pypi/{package}/json`) +- **Format**: Hexadecimal string (64 characters) + +### Verification Process + +```python +# 1. Fetch expected hashes from PyPI +hashes = fetch_pypi_package_hashes("my-plugin", "1.0.0") +# Returns: {"my-plugin-1.0.0.tar.gz": {"sha256": "abc123...", "url": "..."}} + +# 2. Download package +package_file = download_package("my-plugin==1.0.0") + +# 3. Compute actual hash +actual_hash = compute_file_hash(package_file) + +# 4. Verify +if actual_hash != expected_hash: + raise IntegrityVerificationError(...) +``` + +### Performance Impact + +- **API Call**: ~100-500ms to fetch hashes from PyPI +- **Hash Computation**: ~10-50ms per MB of package size +- **Total Overhead**: Typically <1 second per package + +The overhead is minimal compared to download time and provides significant security benefits. + +## Troubleshooting + +### Verification Always Fails + +**Symptoms:** Every package fails verification with hash mismatch. + +**Possible Causes:** +1. Corporate proxy modifying downloads +2. Antivirus scanning altering files +3. Disk corruption + +**Solutions:** +1. Check proxy configuration +2. Temporarily disable antivirus +3. Run disk check utility + +### Verification Warnings for All Packages + +**Symptoms:** "No hashes available" warning for every package. + +**Possible Causes:** +1. Network blocking PyPI API +2. Firewall rules +3. PyPI API outage + +**Solutions:** +1. Check network connectivity to `pypi.org` +2. Review firewall rules +3. Check PyPI status page + +### Slow Installations + +**Symptoms:** Package installations take much longer than expected. + +**Possible Causes:** +1. Slow network to PyPI API +2. Large packages taking time to hash + +**Solutions:** +1. Use a PyPI mirror closer to your location +2. Consider caching verified packages +3. This is normal for very large packages (>100MB) + +## API Reference + +See [`cpex.tools.integrity`](../api-reference/#cpextoolsintegrity) for detailed API documentation. + +## Related Documentation + +- [CLI Reference](../cli/) - Command-line usage +- [Configuration](../configuration/) - General configuration options +- [Security Best Practices](../security/) - Comprehensive security guide + +## Changelog + +### Version 0.1.0rc1 +- Initial implementation of SHA256 hash verification +- Support for PyPI and Test PyPI +- Configurable verification modes (standard/strict/disabled) +- Comprehensive error handling and logging \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 5498a853..691a87bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,10 @@ dependencies = [ "pydantic-settings>=2.13.1", "pydantic>=2.12.5", "pyyaml>=6.0.3", - "packaging>=26.0" + "packaging>=26.0", + "inquirer>=3.4.1", + "rich>=14.3.3", + "pygithub>=2.9.0" ] [project.scripts] diff --git a/tests/unit/cpex/framework/isolated/conftest.py b/tests/unit/cpex/framework/isolated/conftest.py index 26c941dc..9b79a787 100644 --- a/tests/unit/cpex/framework/isolated/conftest.py +++ b/tests/unit/cpex/framework/isolated/conftest.py @@ -8,6 +8,7 @@ """ import sys +from pathlib import Path from unittest.mock import MagicMock import pytest @@ -19,16 +20,16 @@ @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" @@ -38,28 +39,28 @@ def mock_venv_structure(tmp_path): 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", @@ -71,8 +72,8 @@ def sample_plugin_config(tmp_path): "venv_path": str(venv_path), "script_path": str(script_path), "requirements_file": str(requirements_file), - "class_name": "test_plugin.TestPlugin", - }, + "class_name": "test_plugin.TestPlugin" + } } return PluginConfig(**config_dict) @@ -80,50 +81,60 @@ def sample_plugin_config(tmp_path): @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") + 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"} + 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": {}} - ) + 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 """ @@ -131,5 +142,4 @@ def sample_requirements_file(tmp_path): 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 index 516512c1..d60a3089 100644 --- a/tests/unit/cpex/framework/isolated/test_client.py +++ b/tests/unit/cpex/framework/isolated/test_client.py @@ -657,6 +657,25 @@ async def test_initialize_with_invalid_cache( # 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() + @pytest.mark.asyncio async def test_cleanup(self, plugin): diff --git a/tests/unit/cpex/framework/isolated/test_venv_comm.py b/tests/unit/cpex/framework/isolated/test_venv_comm.py index 4b02d09c..82b2bab8 100644 --- a/tests/unit/cpex/framework/isolated/test_venv_comm.py +++ b/tests/unit/cpex/framework/isolated/test_venv_comm.py @@ -96,10 +96,15 @@ def test_install_requirements_success(self, mock_check_call, communicator, tmp_p 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)] - ) + + mock_check_call.assert_called_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): @@ -894,4 +899,478 @@ def test_send_task_very_large_data_exceeds_default_limit(self, mock_thread, mock assert len(communicator.response_queues) == 0 + @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__() + + @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/isolated/test_worker.py b/tests/unit/cpex/framework/isolated/test_worker.py index 76265359..3dcfb686 100644 --- a/tests/unit/cpex/framework/isolated/test_worker.py +++ b/tests/unit/cpex/framework/isolated/test_worker.py @@ -63,17 +63,12 @@ async def test_process_task_info(self): 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.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, mock_plugin_dirs + self, mock_executor_class, mock_import, mock_plugin_dirs ): """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() @@ -115,13 +110,9 @@ async def test_process_task_load_and_run_hook_success( self.cleanup_mock_plugin_dirs() @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, mock_plugin_dirs): + @patch("cpex.framework.isolated.worker.import_module") + async def test_process_task_load_and_run_hook_import_error(self, mock_import, mock_plugin_dirs): """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"} @@ -139,16 +130,12 @@ async def test_process_task_load_and_run_hook_import_error(self, mock_import, mo 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") + @patch("cpex.framework.isolated.worker.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, mock_plugin_dirs + self, mock_executor_class, mock_import, mock_plugin_dirs ): """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() @@ -197,15 +184,12 @@ async def test_process_task_unknown_task_type(self): assert result == {"message": "task type not supported.", "request_id": "unknown", "status": "error"} @pytest.mark.asyncio - @patch("cpex.framework.isolated.worker.get_proper_config") - @patch("cpex.framework.isolated.worker.importlib.import_module") + @patch("cpex.framework.isolated.worker.import_module") @patch("cpex.framework.isolated.worker.PluginExecutor") async def test_process_task_with_metadata( - self, mock_executor_class, mock_import, mock_get_config, mock_plugin_dirs + self, mock_executor_class, mock_import, mock_plugin_dirs ): """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() diff --git a/tests/unit/cpex/tools/test_catalog.py b/tests/unit/cpex/tools/test_catalog.py new file mode 100644 index 00000000..557fc7a9 --- /dev/null +++ b/tests/unit/cpex/tools/test_catalog.py @@ -0,0 +1,3625 @@ +# -*- coding: utf-8 -*- +"""Location: ./tests/unit/cpex/tools/test_catalog.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck + +Tests for the cpex.tools.catalog module. +""" + +# Standard +import base64 +import json +import subprocess +import sys +import tarfile +import zipfile +from pathlib import Path +from unittest import mock +from unittest.mock import MagicMock, Mock, patch, mock_open + +# Third-Party +import httpx +import pytest +import yaml + +# First-Party +from cpex.tools.catalog import PluginCatalog +from cpex.framework.models import PluginManifest, Monorepo + + +# Helper function to create test manifests +def create_test_manifest(**kwargs): + """Create a test PluginManifest with default values.""" + defaults = { + "name": "test_plugin", + "version": "1.0.0", + "kind": "native", + "description": "Test plugin description", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {}, + "monorepo": Monorepo(package_source="https://github.com/org/repo#subdirectory=plugin", repo_url="https://github.com/org/repo", package_folder="plugin"), + } + defaults.update(kwargs) + return PluginManifest(**defaults) + + +@pytest.fixture +def mock_github_env(): + """Fixture to provide a mocked GitHub environment.""" + with ( + patch.dict("os.environ", {"PLUGINS_GITHUB_TOKEN": "test_token"}), + patch("cpex.tools.catalog.Github"), + ): + yield + + +class TestPluginCatalogInit: + """Tests for PluginCatalog initialization.""" + + def test_init_with_defaults(self): + """Test initialization with default environment variables.""" + with ( + patch.dict( + "os.environ", + { + "PLUGINS_GITHUB_TOKEN": "test_token", + "PLUGINS_GITHUB_API": "api.github.com", + "PLUGINS_REPO_URLS": "https://github.com/ibm/cpex-plugins", + "PLUGINS_FOLDER": "plugins", + "PLUGINS_CATALOG_FOLDER": "plugin-catalog", + }, + clear=True, + ), + patch("cpex.tools.catalog.Github"), + ): + catalog = PluginCatalog() + assert catalog.github_api == "api.github.com" + assert catalog.github_token == "test_token" + assert catalog.monorepos == ["https://github.com/ibm/cpex-plugins"] + assert catalog.plugin_folder == "plugins" + assert catalog.manifests == [] + assert catalog.python_executable == sys.executable + + def test_init_with_custom_env_vars(self): + """Test initialization with custom environment variables.""" + with ( + patch.dict( + "os.environ", + { + "PLUGINS_GITHUB_API": "api.github.example.com", + "PLUGINS_GITHUB_TOKEN": "test_token", + "PLUGINS_REPO_URLS": "https://github.com/org/repo1,https://github.com/org/repo2", + "PLUGINS_FOLDER": "custom_plugins", + }, + ), + patch("cpex.tools.catalog.Github"), + ): + catalog = PluginCatalog() + assert catalog.github_api == "api.github.example.com" + assert catalog.github_token == "test_token" + assert catalog.monorepos == ["https://github.com/org/repo1", "https://github.com/org/repo2"] + assert catalog.plugin_folder == "custom_plugins" + + def test_get_python_executable(self): + """Test _get_python_executable returns sys.executable.""" + with ( + patch.dict("os.environ", {"PLUGINS_GITHUB_TOKEN": "test_token"}), + patch("cpex.tools.catalog.Github"), + ): + catalog = PluginCatalog() + assert catalog._get_python_executable() == sys.executable + + +class TestPluginCatalogFolderOperations: + """Tests for folder creation operations.""" + + def test_create_output_folder(self, tmp_path, mock_github_env): + """Test creating the output folder.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "test-catalog") + catalog.create_output_folder() + assert (tmp_path / "test-catalog").exists() + + def test_create_folder(self, tmp_path, mock_github_env): + """Test creating a folder with relative path.""" + catalog = PluginCatalog() + catalog.create_folder(tmp_path, "subdir/file.txt") + assert (tmp_path / "subdir").exists() + + def test_create_plugin_folder(self, tmp_path, mock_github_env): + """Test creating a plugin folder.""" + catalog = PluginCatalog() + catalog.plugin_folder = str(tmp_path / "plugins") + catalog.create_plugin_folder("test_plugin/plugin.py") + assert (tmp_path / "plugins" / "test_plugin").exists() + + def test_create_catalog_folder(self, tmp_path, mock_github_env): + """Test creating a catalog folder.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + catalog.create_catalog_folder("test_plugin/plugin-manifest.yaml") + assert (tmp_path / "catalog" / "test_plugin").exists() + + +class TestPluginCatalogSaveOperations: + """Tests for save operations.""" + + def test_save_content(self, tmp_path, mock_github_env): + """Test saving content to a file.""" + catalog = PluginCatalog() + test_content = "test content" + catalog.save_content(tmp_path, test_content, "test.txt") + assert (tmp_path / "test.txt").read_text() == test_content + + def test_save_plugin_content(self, tmp_path, mock_github_env): + """Test saving plugin content.""" + catalog = PluginCatalog() + catalog.plugin_folder = str(tmp_path / "plugins") + (tmp_path / "plugins").mkdir() + test_content = "plugin code" + catalog.save_plugin_content(test_content, "plugin.py") + assert (tmp_path / "plugins" / "plugin.py").read_text() == test_content + + def test_save_catalog_content(self, tmp_path, mock_github_env): + """Test saving catalog content.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + (tmp_path / "catalog").mkdir() + test_content = "catalog data" + catalog.save_catalog_content(test_content, "manifest.yaml") + assert (tmp_path / "catalog" / "manifest.yaml").read_text() == test_content + + def test_save_manifest_content(self, tmp_path, mock_github_env): + """Test saving manifest content with transformations.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + catalog_dir = tmp_path / "catalog" / "test_plugin" + catalog_dir.mkdir(parents=True) + + manifest_yaml = """ +name: test_plugin +version: 1.0.0 +kind: native +description: Test +author: Test Author +available_hooks: [tools] +default_configs: + key: value +""" + repo_url = httpx.URL("https://github.com/org/repo") + catalog.save_manifest_content(manifest_yaml, "test_plugin/plugin-manifest.yaml", repo_url) + + saved_file = tmp_path / "catalog" / "test_plugin" / "plugin-manifest.yaml" + assert saved_file.exists() + + saved_data = yaml.safe_load(saved_file.read_text()) + assert saved_data["monorepo"]["package_source"] == "https://github.com/org/repo#subdirectory=test_plugin" + assert "tags" in saved_data + assert "default_config" in saved_data + assert "default_configs" not in saved_data # Should be renamed to default_config + + def test_save_manifest_content_without_name(self, tmp_path, mock_github_env): + """Test saving manifest content without name field.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + catalog_dir = tmp_path / "catalog" / "test_plugin" + catalog_dir.mkdir(parents=True) + + manifest_yaml = """ +version: 1.0.0 +kind: native +description: Test +author: Test Author +available_hooks: [tools] +default_config: + key: value +""" + repo_url = httpx.URL("https://github.com/org/repo") + catalog.save_manifest_content(manifest_yaml, "test_plugin/plugin-manifest.yaml", repo_url) + + saved_file = tmp_path / "catalog" / "test_plugin" / "plugin-manifest.yaml" + assert saved_file.exists() + + saved_data = yaml.safe_load(saved_file.read_text()) + assert saved_data["name"] == "test_plugin" # Should be set from path + + def test_save_manifest_content_with_null_default_configs(self, tmp_path, mock_github_env): + """Test saving manifest content with null default_configs.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + catalog_dir = tmp_path / "catalog" / "test_plugin" + catalog_dir.mkdir(parents=True) + + manifest_yaml = """ +name: test_plugin +version: 1.0.0 +kind: native +description: Test +author: Test Author +available_hooks: [tools] +default_configs: null +""" + repo_url = httpx.URL("https://github.com/org/repo") + catalog.save_manifest_content(manifest_yaml, "test_plugin/plugin-manifest.yaml", repo_url) + + saved_file = tmp_path / "catalog" / "test_plugin" / "plugin-manifest.yaml" + assert saved_file.exists() + + saved_data = yaml.safe_load(saved_file.read_text()) + assert saved_data["default_config"] == {} # Should be empty dict + + +class TestPluginCatalogDownloadOperations: + """Tests for download operations.""" + + def test_download_contents_success(self, tmp_path, mock_github_env): + """Test successful download of contents.""" + with patch("cpex.tools.catalog.httpx.get") as mock_get: + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Mock the HTTP response + manifest_content = "name: test\nversion: 1.0.0\nkind: native\ndescription: Test\nauthor: Test\navailable_hooks: [tools]\ndefault_config: {}" + b64_content = base64.b64encode(manifest_content.encode()).decode() + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"content": b64_content} + mock_get.return_value = mock_response + + repo_url = httpx.URL("https://github.com/org/repo") + # download_contents calls create_catalog_folder which creates the directory + # then save_manifest_content writes the file + catalog.download_contents("https://api.github.com/file", {}, "test_plugin/plugin-manifest.yaml", repo_url) + + assert (tmp_path / "catalog" / "test_plugin" / "plugin-manifest.yaml").exists() + + def test_download_contents_failure(self, tmp_path, mock_github_env): + """Test failed download of contents.""" + with ( + patch("cpex.tools.catalog.httpx.get") as mock_get, + patch("cpex.tools.catalog.logger") as mock_logger, + ): + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + mock_response = Mock() + mock_response.status_code = 404 + mock_get.return_value = mock_response + + repo_url = httpx.URL("https://github.com/org/repo") + catalog.download_contents("https://api.github.com/file", {}, "test/plugin-manifest.yaml", repo_url) + + mock_logger.error.assert_called_once() + + +class TestPluginCatalogLoadOperations: + """Tests for load operations.""" + + def test_load_no_output_folder(self, tmp_path, mock_github_env): + """Test load when output folder doesn't exist.""" + with patch("cpex.tools.catalog.logger") as mock_logger: + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "nonexistent") + catalog.load() + assert catalog.manifests == [] + mock_logger.warning.assert_called() + + def test_load_no_manifest_files(self, tmp_path, mock_github_env): + """Test load when no manifest files exist.""" + with patch("cpex.tools.catalog.logger") as mock_logger: + (tmp_path / "catalog").mkdir() + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + catalog.load() + assert catalog.manifests == [] + assert mock_logger.warning.call_count >= 1 + + def test_load_with_manifest_files(self, tmp_path, mock_github_env): + """Test load with valid manifest files.""" + catalog_dir = tmp_path / "catalog" / "test_plugin" + catalog_dir.mkdir(parents=True) + + manifest_data = { + "name": "test_plugin", + "version": "1.0.0", + "kind": "native", + "description": "Test", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {}, + } + manifest_file = catalog_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + catalog.load() + + assert len(catalog.manifests) == 1 + assert catalog.manifests[0].name == "test_plugin" + + def test_load_with_invalid_manifest(self, tmp_path, mock_github_env): + """Test load with invalid manifest file.""" + with patch("cpex.tools.catalog.logger") as mock_logger: + catalog_dir = tmp_path / "catalog" + catalog_dir.mkdir() + + manifest_file = catalog_dir / "plugin-manifest.yaml" + manifest_file.write_text("invalid: yaml: content:") + + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + catalog.load() + + assert len(catalog.manifests) == 0 + mock_logger.error.assert_called() + + +class TestPluginCatalogSearchOperations: + """Tests for search operations.""" + + def test_search_empty_catalog(self, tmp_path, mock_github_env): + """Test search with empty catalog.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + catalog.manifests = [] + result = catalog.search("test") + assert result is None + + def test_search_by_name(self, tmp_path, mock_github_env): + """Test search by plugin name.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + catalog.manifests = [ + create_test_manifest(name="test_plugin", tags=["plugin"]), + create_test_manifest(name="another_plugin", tags=["other"]), + ] + result = catalog.search("test") + assert result is not None + assert len(result) == 1 + assert result[0].name == "test_plugin" + + def test_search_by_tag(self, tmp_path, mock_github_env): + """Test search by tag.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + catalog.manifests = [ + create_test_manifest(name="plugin1", tags=["security"]), + create_test_manifest(name="plugin2", tags=["data"]), + ] + result = catalog.search("security") + assert result is not None + assert len(result) == 1 + assert result[0].name == "plugin1" + + def test_search_no_match(self, tmp_path, mock_github_env): + """Test search with no matches.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + catalog.manifests = [create_test_manifest(name="test_plugin")] + result = catalog.search("nonexistent") + assert result is None + + def test_search_loads_manifests_if_empty(self, tmp_path, mock_github_env): + """Test search loads manifests if catalog is empty.""" + catalog_dir = tmp_path / "catalog" / "test_plugin" + catalog_dir.mkdir(parents=True) + + manifest_data = { + "name": "test_plugin", + "version": "1.0.0", + "kind": "native", + "description": "Test", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {}, + } + manifest_file = catalog_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + result = catalog.search("test") + + assert result is not None + assert len(result) == 1 + + +class TestPluginCatalogInstallFromPypi: + """Tests for install_from_pypi method.""" + + def test_install_from_pypi_success(self, tmp_path, mock_github_env): + """Test successful installation from PyPI.""" + # Create manifest file in temp directory + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + package_dir = extract_dir / "test_package" + package_dir.mkdir() + manifest_data = { + "name": "test_package", + "version": "1.0.0", + "kind": "native", + "description": "Test", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {}, + } + manifest_file = package_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + with ( + patch("cpex.tools.catalog.PluginCatalog._download_package_to_temp", return_value=extract_dir), + patch("cpex.tools.catalog.PluginCatalog._find_manifest_in_extracted_package", return_value=manifest_file), + patch("cpex.tools.catalog.subprocess.run") as mock_subprocess, + patch("cpex.framework.utils.importlib.metadata.distributions") as mock_distributions, + patch("cpex.framework.utils.importlib.util.find_spec") as mock_find_spec, + patch("shutil.rmtree") as mock_rmtree, + ): + # Setup mock distribution + mock_dist = Mock() + mock_dist.name = "test_package" + mock_dist.files = None # No files attribute for non-isolated plugins + mock_distributions.return_value = [mock_dist] + + # Setup mock spec for find_spec fallback + mock_spec = Mock() + mock_spec.origin = str(package_dir / "__init__.py") + mock_find_spec.return_value = mock_spec + + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + manifest, plugin_path = catalog.install_from_pypi("test_package") + + # Should call subprocess.run for non-isolated plugin + mock_subprocess.assert_called_once() + assert manifest.name == "test_package" + assert plugin_path == package_dir # Non-isolated plugins return the package path + # Should clean up temp directory + mock_rmtree.assert_called_once() + + def test_install_from_pypi_install_failure(self, mock_github_env): + """Test installation failure from PyPI.""" + with patch("cpex.tools.catalog.PluginCatalog._download_package_to_temp", side_effect=RuntimeError("Download failed")): + catalog = PluginCatalog() + with pytest.raises(RuntimeError, match="Download failed"): + catalog.install_from_pypi("test_package") + + def test_install_from_pypi_package_not_found(self, tmp_path, mock_github_env): + """Test when package is not found after installation (for isolated_venv plugins).""" + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + package_dir = extract_dir / "test_package" + package_dir.mkdir() + manifest_data = { + "name": "test_package", + "version": "1.0.0", + "kind": "isolated_venv", # Changed to isolated_venv to trigger find_package_path + "description": "Test", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {}, + } + manifest_file = package_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + with ( + patch("cpex.tools.catalog.PluginCatalog._download_package_to_temp", return_value=extract_dir), + patch("cpex.tools.catalog.PluginCatalog._find_manifest_in_extracted_package", return_value=manifest_file), + patch("cpex.tools.catalog.subprocess.run"), + patch("cpex.framework.utils.importlib.metadata.distributions", return_value=[]), + patch("cpex.framework.utils.importlib.util.find_spec", return_value=None), + patch("shutil.rmtree"), + ): + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + with pytest.raises(RuntimeError, match="Failed to initialize isolated venv for test_package"): + catalog.install_from_pypi("test_package") + + def test_install_from_pypi_manifest_not_found(self, tmp_path, mock_github_env): + """Test when manifest file is not found in package.""" + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + + with ( + patch("cpex.tools.catalog.PluginCatalog._download_package_to_temp", return_value=extract_dir), + patch("cpex.tools.catalog.PluginCatalog._find_manifest_in_extracted_package", side_effect=FileNotFoundError("plugin-manifest.yaml not found")), + patch("shutil.rmtree"), + ): + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + with pytest.raises(FileNotFoundError, match="plugin-manifest.yaml not found"): + catalog.install_from_pypi("test_package") + + def test_install_from_pypi_invalid_manifest(self, tmp_path, mock_github_env): + """Test when manifest file is invalid.""" + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + package_dir = extract_dir / "test_package" + package_dir.mkdir() + manifest_file = package_dir / "plugin-manifest.yaml" + manifest_file.write_text("invalid: yaml: content:") + + with ( + patch("cpex.tools.catalog.PluginCatalog._download_package_to_temp", return_value=extract_dir), + patch("cpex.tools.catalog.PluginCatalog._find_manifest_in_extracted_package", return_value=manifest_file), + patch("shutil.rmtree"), + ): + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + with pytest.raises(RuntimeError, match="Failed to parse manifest YAML"): + catalog.install_from_pypi("test_package") + + +class TestPluginCatalogInstallFolderViaPip: + """Tests for install_folder_via_pip method.""" + + def test_install_folder_via_pip_success(self, tmp_path, mock_github_env): + """Test successful installation from monorepo.""" + with patch("cpex.tools.catalog.subprocess.run") as mock_subprocess: + manifest = create_test_manifest( + monorepo=Monorepo( + package_source="https://github.com/org/repo#subdirectory=plugin", + repo_url="https://github.com/org/repo", + package_folder="plugin" + ) + ) + + catalog = PluginCatalog() + catalog.install_folder_via_pip(manifest) + mock_subprocess.assert_called_once() + + def test_install_folder_via_pip_no_monorepo(self, mock_github_env): + """Test installation fails when monorepo is None.""" + manifest = create_test_manifest(monorepo=None) + + catalog = PluginCatalog() + with pytest.raises(RuntimeError, match="PluginManifest.monorepo can not be None"): + catalog.install_folder_via_pip(manifest) + + def test_install_folder_via_pip_subprocess_error(self, mock_github_env): + """Test installation fails on subprocess error.""" + # Create a CalledProcessError with proper arguments + error = subprocess.CalledProcessError(1, ["pip"], stderr="Install failed") + with patch("cpex.tools.catalog.subprocess.run", side_effect=error): + manifest = create_test_manifest( + monorepo=Monorepo( + package_source="https://github.com/org/repo#subdirectory=plugin", + repo_url="https://github.com/org/repo", + package_folder="plugin" + ) + ) + + catalog = PluginCatalog() + with pytest.raises(RuntimeError, match="Failed to install"): + catalog.install_folder_via_pip(manifest) + + +class TestPluginCatalogSaveManifest: + """Tests for save_manifest method.""" + + def test_save_manifest(self, tmp_path, mock_github_env): + """Test saving a manifest to the catalog.""" + catalog_dir = tmp_path / "catalog" / "test_plugin" + catalog_dir.mkdir(parents=True) + + manifest = create_test_manifest(name="test_plugin") + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + catalog.save_manifest(manifest, "test_plugin/plugin-manifest.yaml") + + saved_file = tmp_path / "catalog" / "test_plugin" / "plugin-manifest.yaml" + assert saved_file.exists() + + saved_data = yaml.safe_load(saved_file.read_text()) + assert saved_data["name"] == "test_plugin" + + +class TestPluginCatalogDownloadFile: + """Tests for download_file method.""" + + def test_download_file_success(self, mock_github_env): + """Test successful file download.""" + catalog = PluginCatalog() + + # Mock the GitHub repository and file content + mock_repo = Mock() + mock_file_content = Mock() + manifest_content = "name: test\nversion: 1.0.0" + mock_file_content.decoded_content = manifest_content.encode() + mock_repo.get_contents.return_value = mock_file_content + catalog.gh.get_repo = Mock(return_value=mock_repo) + + item = {"path": "test_plugin/plugin-manifest.yaml"} + result = catalog.download_file("org/repo", item, {}, mock_repo) + + assert result == manifest_content + + def test_download_file_failure(self, mock_github_env): + """Test failed file download.""" + with patch("cpex.tools.catalog.logger") as mock_logger: + catalog = PluginCatalog() + # Mock the GitHub repository to raise an exception + mock_repo = Mock(side_effect=Exception("Not found")) + mock_repo.get_contents.return_value = Exception("Not found") + item = {"path": "test_plugin/plugin-manifest.yaml"} + result = catalog.download_file("org/repo", item, {}, mock_repo) + + assert result is None + mock_logger.error.assert_called_once() + + +class TestPluginCatalogFindAndSavePluginManifest: + """Tests for find_and_save_plugin_manifest method.""" + + def test_find_and_save_plugin_manifest_success(self, tmp_path, mock_github_env): + """Test successful finding and saving of plugin manifest.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Mock the search results + mock_search_result = Mock() + mock_content_file = Mock() + mock_content_file.name = "plugin-manifest.yaml" + mock_content_file.path = "test_plugin/plugin-manifest.yaml" + mock_content_file.git_url = "https://api.github.com/repos/org/repo/git/blobs/abc123" + mock_content_file.html_url = "https://github.com/org/repo/blob/main/test_plugin/plugin-manifest.yaml" + + mock_search_result.totalCount = 1 + mock_search_result.__iter__ = Mock(return_value=iter([mock_content_file])) + + catalog.gh.search_code = Mock(return_value=mock_search_result) + + # Mock the repository and file content + mock_repo = Mock() + manifest_content = "name: test\nversion: 1.0.0\nkind: native\ndescription: Test\nauthor: Test\navailable_hooks: [tools]\ndefault_config: {}" + mock_file_content = Mock() + mock_file_content.decoded_content = manifest_content.encode() + mock_repo.get_contents.return_value = mock_file_content + catalog.gh.get_repo = Mock(return_value=mock_repo) + + repo_url = httpx.URL("https://github.com/org/repo") + catalog.find_and_save_plugin_manifest("test_plugin", "test_plugin", repo_url, {}, mock_repo) + + saved_file = tmp_path / "catalog" / "test_plugin" / "plugin-manifest.yaml" + assert saved_file.exists() + + +class TestPluginCatalogUpdateCatalogWithPyproject: + """Tests for update_catalog_with_pyproject method.""" + + def test_update_catalog_with_pyproject_success(self, tmp_path, mock_github_env): + """Test successful catalog update with pyproject.toml files.""" + with patch("cpex.tools.catalog.httpx.get") as mock_get: + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + catalog.monorepos = ["https://github.com/org/repo"] + + # Mock search response for pyproject.toml files + search_response = Mock() + search_response.status_code = 200 + search_response.json.return_value = { + "items": [ + { + "name": "pyproject.toml", + "path": "plugin1/pyproject.toml" + } + ] + } + + # Mock pyproject.toml content response + pyproject_content = '[project]\nname = "test_plugin"' + b64_pyproject = base64.b64encode(pyproject_content.encode()).decode() + pyproject_response = Mock() + pyproject_response.status_code = 200 + pyproject_response.json.return_value = {"content": b64_pyproject} + + # Mock search response for manifest + manifest_search_response = Mock() + manifest_search_response.status_code = 200 + manifest_search_response.json.return_value = { + "items": [ + { + "name": "plugin-manifest.yaml", + "path": "plugin1/plugin-manifest.yaml", + "git_url": "https://api.github.com/repos/org/repo/git/blobs/abc123" + } + ] + } + + # Mock manifest content response + manifest_content = "name: test\nversion: 1.0.0\nkind: native\ndescription: Test\nauthor: Test\navailable_hooks: [tools]" + b64_manifest = base64.b64encode(manifest_content.encode()).decode() + manifest_response = Mock() + manifest_response.status_code = 200 + manifest_response.json.return_value = {"content": b64_manifest} + + mock_get.side_effect = [search_response, pyproject_response, manifest_search_response, manifest_response] + + catalog.update_catalog_with_pyproject() + + assert (tmp_path / "catalog").exists() + + +class TestPluginCatalogSearchEdgeCases: + """Tests for search method edge cases.""" + + def test_search_with_none_plugin_name(self, mock_github_env): + """Test search with None as plugin name returns all manifests.""" + catalog = PluginCatalog() + catalog.manifests = [ + create_test_manifest(name="plugin1"), + create_test_manifest(name="plugin2"), + ] + result = catalog.search(None) + assert result is not None + assert len(result) == 2 + + +class TestPluginCatalogInstallFromPypiExtended: + """Extended tests for install_from_pypi method.""" + + def test_install_from_pypi_with_version_constraint(self, tmp_path, mock_github_env): + """Test installation with version constraint.""" + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + package_dir = extract_dir / "test_package" + package_dir.mkdir() + manifest_data = { + "name": "test_package", + "version": "1.0.0", + "kind": "native", + "description": "Test", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {}, + } + manifest_file = package_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + with ( + patch("cpex.tools.catalog.PluginCatalog._download_package_to_temp", return_value=extract_dir), + patch("cpex.tools.catalog.PluginCatalog._find_manifest_in_extracted_package", return_value=manifest_file), + patch("cpex.tools.catalog.subprocess.run") as mock_subprocess, + patch("cpex.framework.utils.importlib.metadata.distributions") as mock_distributions, + patch("cpex.framework.utils.importlib.util.find_spec") as mock_find_spec, + patch("shutil.rmtree"), + ): + mock_dist = Mock() + mock_dist.name = "test_package" + mock_dist.files = None # No files attribute for non-isolated plugins + mock_distributions.return_value = [mock_dist] + + # Setup mock spec for find_spec fallback + mock_spec = Mock() + mock_spec.origin = str(package_dir / "__init__.py") + mock_find_spec.return_value = mock_spec + + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + manifest, plugin_path = catalog.install_from_pypi("test_package", ">=1.0.0") + + mock_subprocess.assert_called_once() + assert manifest.name == "test_package" + assert manifest.package_info is not None + assert manifest.package_info.version_constraint == ">=1.0.0" + + def test_install_from_pypi_with_default_configs(self, tmp_path, mock_github_env): + """Test installation with default_configs field.""" + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + package_dir = extract_dir / "test_package" + package_dir.mkdir() + manifest_data = { + "name": "test_package", + "version": "1.0.0", + "kind": "native", + "description": "Test", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_configs": {"key": "value"}, + } + manifest_file = package_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + with ( + patch("cpex.tools.catalog.PluginCatalog._download_package_to_temp", return_value=extract_dir), + patch("cpex.tools.catalog.PluginCatalog._find_manifest_in_extracted_package", return_value=manifest_file), + patch("cpex.tools.catalog.subprocess.run") as mock_subprocess, + patch("cpex.framework.utils.importlib.metadata.distributions") as mock_distributions, + patch("cpex.framework.utils.importlib.util.find_spec") as mock_find_spec, + patch("shutil.rmtree"), + ): + mock_dist = Mock() + mock_dist.name = "test_package" + mock_dist.files = None # No files attribute for non-isolated plugins + mock_distributions.return_value = [mock_dist] + + # Setup mock spec for find_spec fallback + mock_spec = Mock() + mock_spec.origin = str(package_dir / "__init__.py") + mock_find_spec.return_value = mock_spec + + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + manifest, plugin_path = catalog.install_from_pypi("test_package") + + assert manifest.default_config == {"key": "value"} + + def test_install_from_pypi_with_existing_package_info(self, tmp_path, mock_github_env): + """Test installation with existing package_info.""" + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + package_dir = extract_dir / "test_package" + package_dir.mkdir() + manifest_data = { + "name": "test_package", + "version": "1.0.0", + "kind": "native", + "description": "Test", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {}, + "package_info": { + "pypi_package": "old_name", + "version_constraint": ">=0.1.0" + } + } + manifest_file = package_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + with ( + patch("cpex.tools.catalog.PluginCatalog._download_package_to_temp", return_value=extract_dir), + patch("cpex.tools.catalog.PluginCatalog._find_manifest_in_extracted_package", return_value=manifest_file), + patch("cpex.tools.catalog.subprocess.run") as mock_subprocess, + patch("cpex.framework.utils.importlib.metadata.distributions") as mock_distributions, + patch("cpex.framework.utils.importlib.util.find_spec") as mock_find_spec, + patch("shutil.rmtree"), + ): + mock_dist = Mock() + mock_dist.name = "test_package" + mock_dist.files = None # No files attribute for non-isolated plugins + mock_distributions.return_value = [mock_dist] + + # Setup mock spec for find_spec fallback + mock_spec = Mock() + mock_spec.origin = str(package_dir / "__init__.py") + mock_find_spec.return_value = mock_spec + + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + manifest, plugin_path = catalog.install_from_pypi("test_package", ">=2.0.0") + + assert manifest.package_info is not None + assert manifest.package_info.pypi_package == "test_package" + assert manifest.package_info.version_constraint == ">=2.0.0" + + def test_install_from_pypi_with_null_default_configs_in_manifest(self, tmp_path, mock_github_env): + """Test installation with null default_configs in manifest.""" + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + package_dir = extract_dir / "test_package" + package_dir.mkdir() + manifest_data = { + "name": "test_package", + "version": "1.0.0", + "kind": "native", + "description": "Test", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_configs": None, + } + manifest_file = package_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + with ( + patch("cpex.tools.catalog.PluginCatalog._download_package_to_temp", return_value=extract_dir), + patch("cpex.tools.catalog.PluginCatalog._find_manifest_in_extracted_package", return_value=manifest_file), + patch("cpex.tools.catalog.subprocess.run") as mock_subprocess, + patch("cpex.framework.utils.importlib.metadata.distributions") as mock_distributions, + patch("cpex.framework.utils.importlib.util.find_spec") as mock_find_spec, + patch("shutil.rmtree"), + ): + mock_dist = Mock() + mock_dist.name = "test_package" + mock_dist.files = None # No files attribute for non-isolated plugins + mock_distributions.return_value = [mock_dist] + + # Setup mock spec for find_spec fallback + mock_spec = Mock() + mock_spec.origin = str(package_dir / "__init__.py") + mock_find_spec.return_value = mock_spec + + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + manifest, plugin_path = catalog.install_from_pypi("test_package") + + # default_config should be empty dict when default_configs is None + assert manifest.default_config == {} + + +# Made with Bob + + + +class TestPluginCatalogProcessPyproject: + """Tests for _process_pyproject helper method.""" + + def test_process_pyproject_with_download_failure(self, tmp_path, mock_github_env): + """Test _process_pyproject when download fails.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Mock the repository to raise exception + mock_repo = Mock() + mock_repo.get_contents = Mock(side_effect=Exception("Download failed")) + + item = Mock() + item.name = "pyproject.toml" + item.path = "plugin1/pyproject.toml" + + repo_url = httpx.URL("https://github.com/org/repo") + headers = {} + + # Should raise exception + with pytest.raises(Exception, match="Download failed"): + catalog._process_pyproject(mock_repo, item, repo_url, headers) + + +class TestPluginCatalogUpdateCatalogWithPyprojectExtended: + """Extended tests for update_catalog_with_pyproject method.""" + + def test_update_catalog_with_pyproject_no_token(self, tmp_path, mock_github_env): + """Test update_catalog_with_pyproject when no GitHub token is set.""" + with ( + patch("cpex.tools.catalog.logger") as mock_logger, + ): + catalog = PluginCatalog() + catalog.github_token = None + result = catalog.update_catalog_with_pyproject() + + assert result is True + mock_logger.error.assert_called_with("No GitHub token set") + + def test_update_catalog_with_pyproject_repo_access_error(self, tmp_path, mock_github_env): + """Test update_catalog_with_pyproject when repository access fails.""" + with ( + patch("cpex.tools.catalog.logger") as mock_logger, + ): + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + catalog.monorepos = ["https://github.com/org/repo"] + + # Mock get_repo to raise exception + catalog.gh.get_repo = Mock(side_effect=Exception("Access denied")) + + result = catalog.update_catalog_with_pyproject() + + assert result is False + mock_logger.error.assert_called() + + def test_update_catalog_with_pyproject_search_error(self, tmp_path, mock_github_env): + """Test update_catalog_with_pyproject when search fails.""" + with patch("cpex.tools.catalog.logger") as mock_logger: + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + catalog.monorepos = ["https://github.com/org/repo"] + + # Mock successful get_repo but failing search + mock_repo = Mock() + catalog.gh.get_repo = Mock(return_value=mock_repo) + catalog.gh.search_code = Mock(side_effect=Exception("Search failed")) + + result = catalog.update_catalog_with_pyproject() + + assert result is False + mock_logger.error.assert_called() + + +class TestPluginCatalogSearchGithubCode: + """Tests for _search_github_code method.""" + + def test_search_github_code_exception(self, mock_github_env): + """Test _search_github_code when exception occurs.""" + with patch("cpex.tools.catalog.logger") as mock_logger: + catalog = PluginCatalog() + + # Mock search_code to raise exception + catalog.gh.search_code = Mock(side_effect=Exception("Search error")) + + result = catalog._search_github_code("org/repo", "plugins", {}) + + assert result is None + mock_logger.error.assert_called() + + +class TestPluginCatalogProcessManifestItem: + """Tests for _process_manifest_item method.""" + + def test_process_manifest_item_not_yaml(self, tmp_path, mock_github_env): + """Test _process_manifest_item with non-YAML file.""" + with patch("cpex.tools.catalog.logger") as mock_logger: + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + item = { + "name": "README.md", + "path": "plugin1/README.md", + "git_url": "https://api.github.com/file" + } + + repo_url = httpx.URL("https://github.com/org/repo") + relpath = tmp_path / "catalog" / "plugin1" / "plugin-manifest.yaml" + mock_repo = Mock() + result = catalog._process_manifest_item(item, "plugin1", "plugin1", repo_url, {}, relpath, "org/repo", gh_repo=mock_repo) + + assert result is False + mock_logger.warning.assert_called() + + def test_process_manifest_item_download_failure(self, tmp_path, mock_github_env): + """Test _process_manifest_item when download fails.""" + with patch("cpex.tools.catalog.logger") as mock_logger: + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Mock download_file to return None + catalog.download_file = Mock(return_value=None) + + item = { + "name": "plugin-manifest.yaml", + "path": "plugin1/plugin-manifest.yaml", + "git_url": "https://api.github.com/file" + } + mock_repo = Mock() + repo_url = httpx.URL("https://github.com/org/repo") + relpath = tmp_path / "catalog" / "plugin1" / "plugin-manifest.yaml" + + result = catalog._process_manifest_item(item, "plugin1", "plugin1", repo_url, {}, relpath, "org/repo", gh_repo=mock_repo) + + assert result is False + mock_logger.error.assert_called() + + +class TestPluginCatalogFindAndSavePluginManifestExtended: + """Extended tests for find_and_save_plugin_manifest method.""" + + def test_find_and_save_plugin_manifest_search_returns_none(self, tmp_path, mock_github_env): + """Test find_and_save_plugin_manifest when search returns None.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Mock _search_github_code to return None + catalog._search_github_code = Mock(return_value=None) + mock_repo = Mock() + repo_url = httpx.URL("https://github.com/org/repo") + result = catalog.find_and_save_plugin_manifest("plugin1", "plugin1", repo_url, {}, mock_repo) + + assert result is None + + +class TestPluginCatalogVersionsJson: + """Tests for versions.json discovery and download.""" + + def test_search_github_code_for_versions_json_success(self, mock_github_env): + """Test _search_github_code_for_versions_json filters versions.json files.""" + catalog = PluginCatalog() + + mock_search_result = Mock() + matching_file = Mock() + matching_file.name = "versions.json" + matching_file.path = "plugin1/versions.json" + matching_file.git_url = "https://api.github.com/repos/org/repo/git/blobs/versions" + matching_file.html_url = "https://github.com/org/repo/blob/main/plugin1/versions.json" + + ignored_file = Mock() + ignored_file.name = "plugin-manifest.yaml" + ignored_file.path = "plugin1/plugin-manifest.yaml" + ignored_file.git_url = "https://api.github.com/repos/org/repo/git/blobs/manifest" + ignored_file.html_url = "https://github.com/org/repo/blob/main/plugin1/plugin-manifest.yaml" + + mock_search_result.totalCount = 2 + mock_search_result.__iter__ = Mock(return_value=iter([matching_file, ignored_file])) + catalog.gh.search_code = Mock(return_value=mock_search_result) + + result = catalog._search_github_code_for_versions_json("org/repo", "plugin1", {}) + + assert result == [ + { + "name": "versions.json", + "path": "plugin1/versions.json", + "git_url": "https://api.github.com/repos/org/repo/git/blobs/versions", + "html_url": "https://github.com/org/repo/blob/main/plugin1/versions.json", + } + ] + catalog.gh.search_code.assert_called_once_with( + query="repo:org/repo path:plugin1 filename:versions extension:json" + ) + + def test_search_github_code_for_versions_json_exception(self, mock_github_env): + """Test _search_github_code_for_versions_json when exception occurs.""" + with patch("cpex.tools.catalog.logger") as mock_logger: + catalog = PluginCatalog() + catalog.gh.search_code = Mock(side_effect=Exception("Search error")) + + result = catalog._search_github_code_for_versions_json("org/repo", "plugin1", {}) + + assert result is None + mock_logger.error.assert_called() + + def test_find_and_save_plugin_versions_json_success(self, tmp_path, mock_github_env): + """Test successful finding and saving of versions.json.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + mock_repo = Mock() + repo_url = httpx.URL("https://github.com/org/repo") + + catalog._search_github_code_for_versions_json = Mock( + return_value=[ + { + "name": "versions.json", + "path": "plugin1/versions.json", + "git_url": "https://api.github.com/repos/org/repo/git/blobs/versions", + "html_url": "https://github.com/org/repo/blob/main/plugin1/versions.json", + } + ] + ) + + versions_content = '{\n "plugin1": [{"version": "1.0.0"}]\n}' + catalog.download_file = Mock(return_value=versions_content) + + catalog.find_and_save_plugin_versions_json("plugin1", "plugin1", repo_url, {}, mock_repo) + + saved_file = tmp_path / "catalog" / "plugin1" / "versions.json" + assert saved_file.exists() + assert saved_file.read_text(encoding="utf-8") == versions_content + + def test_find_and_save_plugin_versions_json_search_returns_none(self, tmp_path, mock_github_env): + """Test find_and_save_plugin_versions_json when search returns None.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + catalog._search_github_code_for_versions_json = Mock(return_value=None) + + mock_repo = Mock() + repo_url = httpx.URL("https://github.com/org/repo") + + result = catalog.find_and_save_plugin_versions_json("plugin1", "plugin1", repo_url, {}, mock_repo) + + assert result is None + assert not (tmp_path / "catalog" / "plugin1" / "versions.json").exists() + + +class TestPluginCatalogLoadManifestFile: + """Tests for _load_manifest_file method.""" + + def test_load_manifest_file_not_found(self, tmp_path, mock_github_env): + """Test _load_manifest_file when file doesn't exist.""" + catalog = PluginCatalog() + manifest_path = tmp_path / "nonexistent" / "plugin-manifest.yaml" + + with pytest.raises(FileNotFoundError, match="plugin-manifest.yaml not found"): + catalog._load_manifest_file(manifest_path) + + def test_load_manifest_file_invalid_yaml(self, tmp_path, mock_github_env): + """Test _load_manifest_file with invalid YAML.""" + catalog = PluginCatalog() + manifest_path = tmp_path / "plugin-manifest.yaml" + manifest_path.write_text("invalid: yaml: content:") + + with pytest.raises(RuntimeError, match="Failed to parse manifest YAML"): + catalog._load_manifest_file(manifest_path) + + def test_load_manifest_file_not_dict(self, tmp_path, mock_github_env): + """Test _load_manifest_file when YAML is not a dictionary.""" + catalog = PluginCatalog() + manifest_path = tmp_path / "plugin-manifest.yaml" + manifest_path.write_text("- item1\n- item2") + + with pytest.raises(RuntimeError, match="Invalid manifest format"): + catalog._load_manifest_file(manifest_path) + + +class TestPluginCatalogNormalizeManifestData: + """Tests for _normalize_manifest_data method.""" + + def test_normalize_manifest_data_validation_error(self, mock_github_env): + """Test _normalize_manifest_data with validation error.""" + catalog = PluginCatalog() + + # Invalid manifest data (missing required fields) + manifest_data = {"name": "test"} + + with pytest.raises(RuntimeError, match="Failed to validate manifest"): + catalog._normalize_manifest_data(manifest_data, "test_package", None) + + +class TestPluginCatalogPersistManifest: + """Tests for _persist_manifest method.""" + + def test_persist_manifest_error(self, tmp_path, mock_github_env): + """Test _persist_manifest when save fails.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "nonexistent" / "catalog") + + manifest = create_test_manifest() + + # Make directory read-only to cause save failure + with patch("cpex.tools.catalog.PluginCatalog.save_manifest", side_effect=Exception("Save failed")): + with pytest.raises(RuntimeError, match="Failed to save manifest"): + catalog._persist_manifest(manifest, "test_plugin") + + +class TestPluginCatalogInstallPackage: + """Tests for _install_package method.""" + + def test_install_package_with_version_constraint(self, mock_github_env): + """Test _install_package with version constraint.""" + with patch("cpex.tools.catalog.subprocess.run") as mock_subprocess: + catalog = PluginCatalog() + catalog._install_package("test_package", ">=1.0.0") + + mock_subprocess.assert_called_once() + call_args = mock_subprocess.call_args[0][0] + assert "test_package>=1.0.0" in " ".join(call_args) + + +class TestPluginCatalogDownloadFileExtended: + """Extended tests for download_file method.""" + + def test_download_file_with_exception_message(self, mock_github_env): + """Test download_file logs proper error message.""" + with patch("cpex.tools.catalog.logger") as mock_logger: + catalog = PluginCatalog() + + # Mock to raise exception + catalog.gh.get_repo = Mock(side_effect=Exception("API error")) + mock_repo = Mock() + mock_repo.get_contents = Mock(side_effect=Exception("API error")) + item = {"path": "test/file.yaml"} + result = catalog.download_file("org/repo", item, {}, gh_repo=mock_repo) + + assert result is None + # Check that error was logged with the item path + assert mock_logger.error.called + + +class TestPluginCatalogSearchGithubCodeWithNullMember: + """Tests for _search_github_code with member=None.""" + + def test_search_github_code_with_null_member(self, mock_github_env): + """Test _search_github_code when member is None.""" + catalog = PluginCatalog() + + # Mock the search results + mock_search_results = MagicMock() + mock_search_results.totalCount = 1 + + mock_content_file = MagicMock() + mock_content_file.name = "plugin-manifest.yaml" + mock_content_file.path = "plugin-manifest.yaml" + mock_content_file.git_url = "https://api.github.com/repos/org/repo/git/blobs/abc123" + mock_content_file.html_url = "https://github.com/org/repo/blob/main/plugin-manifest.yaml" + + mock_search_results.__iter__ = Mock(return_value=iter([mock_content_file])) + + with patch.object(catalog.gh, 'search_code', return_value=mock_search_results): + result = catalog._search_github_code("org/repo", None, {}) + + assert result is not None + assert len(result) == 1 + assert result[0]["name"] == "plugin-manifest.yaml" + + +class TestPluginCatalogTransformManifestDataWithNullMember: + """Tests for _transform_manifest_data with member=None.""" + + def test_transform_manifest_data_with_null_member(self, mock_github_env): + """Test _transform_manifest_data when member is None.""" + catalog = PluginCatalog() + + manifest_content = { + "version": "1.0.0", + "kind": "native", + "description": "Test plugin", + "author": "Test Author", + "available_hooks": ["tools"], + } + + repo_url = httpx.URL("https://github.com/org/repo") + result = catalog._transform_manifest_data(manifest_content, "test_plugin", None, repo_url) + + assert result["name"] == "test_plugin" + assert result["monorepo"]["package_source"] == "https://github.com/org/repo" + assert result["monorepo"]["package_folder"] == "" + + +class TestPluginCatalogDownloadMonorepoFolderToTemp: + """Tests for _download_monorepo_folder_to_temp method.""" + + def test_download_monorepo_folder_success(self, tmp_path, mock_github_env): + """Test successful download of monorepo folder.""" + catalog = PluginCatalog() + + # Create a mock tarball + mock_tarball = tmp_path / "package.tar.gz" + with tarfile.open(mock_tarball, "w:gz") as tar: + # Create a temporary file to add to the tarball + temp_file = tmp_path / "test_file.txt" + temp_file.write_text("test content") + tar.add(temp_file, arcname="test_file.txt") + + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + with patch('pathlib.Path.glob') as mock_glob: + mock_glob.return_value = [mock_tarball] + + result = catalog._download_monorepo_folder_to_temp( + "git+https://github.com/org/repo#subdirectory=plugin", + "test_plugin" + ) + + assert result.exists() + assert result.name == "extracted" + + def test_download_monorepo_folder_no_files(self, tmp_path, mock_github_env): + """Test error when no files are downloaded.""" + catalog = PluginCatalog() + + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + with patch('pathlib.Path.glob') as mock_glob: + mock_glob.return_value = [] + + with pytest.raises(RuntimeError) as exc_info: + catalog._download_monorepo_folder_to_temp( + "git+https://github.com/org/repo#subdirectory=plugin", + "test_plugin" + ) + + assert "No files downloaded" in str(exc_info.value) + + def test_download_monorepo_folder_unsupported_format(self, tmp_path, mock_github_env): + """Test error with unsupported package format.""" + catalog = PluginCatalog() + + # Create a mock file with unsupported extension + mock_file = tmp_path / "package.unknown" + mock_file.write_text("test") + + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + with patch('pathlib.Path.glob') as mock_glob: + mock_glob.return_value = [mock_file] + + with pytest.raises(RuntimeError) as exc_info: + catalog._download_monorepo_folder_to_temp( + "git+https://github.com/org/repo#subdirectory=plugin", + "test_plugin" + ) + + assert "Unsupported package format" in str(exc_info.value) + + def test_download_monorepo_folder_subprocess_error(self, mock_github_env): + """Test subprocess error handling.""" + catalog = PluginCatalog() + + with patch('subprocess.run') as mock_run: + mock_run.side_effect = subprocess.CalledProcessError(1, "pip", stderr="Download failed") + + with pytest.raises(RuntimeError) as exc_info: + catalog._download_monorepo_folder_to_temp( + "git+https://github.com/org/repo#subdirectory=plugin", + "test_plugin" + ) + + assert "Failed to download" in str(exc_info.value) + + +class TestPluginCatalogDownloadPackageToTemp: + """Tests for _download_package_to_temp method.""" + + def test_download_package_with_test_pypi(self, tmp_path, mock_github_env): + """Test downloading from test.pypi.org.""" + catalog = PluginCatalog() + + # Create a mock wheel file + mock_wheel = tmp_path / "package-1.0.0-py3-none-any.whl" + with zipfile.ZipFile(mock_wheel, "w") as zf: + zf.writestr("test_file.txt", "test content") + + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + with patch('pathlib.Path.glob') as mock_glob: + mock_glob.return_value = [mock_wheel] + + result = catalog._download_package_to_temp("test_plugin", None, use_test=True) + + assert result.exists() + assert result.name == "extracted" + + # Verify test.pypi.org was used + call_args = mock_run.call_args[0][0] + assert "--index-url" in call_args + assert "https://test.pypi.org/simple/" in call_args + + def test_download_package_no_files_downloaded(self, mock_github_env): + """Test error when no files are downloaded.""" + catalog = PluginCatalog() + + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + with patch('pathlib.Path.glob') as mock_glob: + mock_glob.return_value = [] + + with pytest.raises(RuntimeError) as exc_info: + catalog._download_package_to_temp("test_plugin", None) + + assert "No files downloaded" in str(exc_info.value) + + def test_download_package_unsupported_format(self, tmp_path, mock_github_env): + """Test error with unsupported package format.""" + catalog = PluginCatalog() + + mock_file = tmp_path / "package.exe" + mock_file.write_text("test") + + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + with patch('pathlib.Path.glob') as mock_glob: + mock_glob.return_value = [mock_file] + + with pytest.raises(RuntimeError) as exc_info: + catalog._download_package_to_temp("test_plugin", None) + + assert "Unsupported package format" in str(exc_info.value) + + +class TestPluginCatalogFindManifestInExtractedPackage: + """Tests for _find_manifest_in_extracted_package method.""" + + def test_find_manifest_not_found(self, tmp_path, mock_github_env): + """Test FileNotFoundError when manifest is not found.""" + catalog = PluginCatalog() + + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + + with pytest.raises(FileNotFoundError) as exc_info: + catalog._find_manifest_in_extracted_package(extract_dir, "test_plugin") + + assert "plugin-manifest.yaml not found" in str(exc_info.value) + + +class TestPluginCatalogUninstallPackage: + """Tests for uninstall_package method.""" + + def test_uninstall_package_success_native(self, mock_github_env): + """Test successful package uninstallation for native plugin.""" + catalog = PluginCatalog() + + # Create a native plugin manifest + manifest = create_test_manifest(kind="native") + + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + result = catalog.uninstall_package("test_plugin", manifest) + + assert result is True + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + assert "pip" in call_args + assert "uninstall" in call_args + assert "-y" in call_args + assert "test_plugin" in call_args + # Should use current python executable for native plugins + assert call_args[0] == catalog.python_executable + + def test_uninstall_package_success_isolated_venv(self, tmp_path, mock_github_env): + """Test successful package uninstallation for isolated_venv plugin.""" + catalog = PluginCatalog() + catalog.plugin_folder = str(tmp_path / "plugins") + + # Create an isolated_venv plugin manifest + manifest = create_test_manifest(kind="isolated_venv") + + # Create mock venv structure + plugin_path = tmp_path / "plugins" / "test_plugin" + plugin_path.mkdir(parents=True) + venv_path = plugin_path / ".venv" + venv_bin = venv_path / "bin" + venv_bin.mkdir(parents=True) + venv_python = venv_bin / "python" + venv_python.touch() + + # Mock the IsolatedVenvPlugin + mock_isolated_plugin = MagicMock() + mock_isolated_plugin.plugin_path = plugin_path + + with ( + patch('subprocess.run') as mock_run, + patch('cpex.framework.isolated.client.IsolatedVenvPlugin', return_value=mock_isolated_plugin), + patch.object(catalog, '_get_venv_python_executable', return_value=str(venv_python)), + ): + mock_run.return_value = MagicMock(returncode=0) + + result = catalog.uninstall_package("test_plugin", manifest) + + assert result is True + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + assert "pip" in call_args + assert "uninstall" in call_args + assert "-y" in call_args + assert "test_plugin" in call_args + # Should use venv python executable for isolated plugins + assert call_args[0] == str(venv_python) + + def test_uninstall_package_subprocess_error(self, mock_github_env): + """Test subprocess error during uninstallation.""" + catalog = PluginCatalog() + manifest = create_test_manifest(kind="native") + + with patch('subprocess.run') as mock_run: + mock_run.side_effect = subprocess.CalledProcessError(1, "pip", stderr="Uninstall failed") + + with pytest.raises(RuntimeError) as exc_info: + catalog.uninstall_package("test_plugin", manifest) + + assert "Failed to uninstall" in str(exc_info.value) + + def test_uninstall_package_unexpected_error(self, mock_github_env): + """Test unexpected error during uninstallation.""" + catalog = PluginCatalog() + manifest = create_test_manifest(kind="native") + + with patch('subprocess.run') as mock_run: + mock_run.side_effect = Exception("Unexpected error") + + with pytest.raises(RuntimeError) as exc_info: + catalog.uninstall_package("test_plugin", manifest) + + assert "Unexpected error uninstalling" in str(exc_info.value) + + def test_uninstall_package_isolated_venv_error(self, tmp_path, mock_github_env): + """Test error during isolated_venv plugin uninstallation.""" + catalog = PluginCatalog() + catalog.plugin_folder = str(tmp_path / "plugins") + manifest = create_test_manifest(kind="isolated_venv") + + # Create mock venv structure + plugin_path = tmp_path / "plugins" / "test_plugin" + plugin_path.mkdir(parents=True) + venv_path = plugin_path / ".venv" + venv_bin = venv_path / "bin" + venv_bin.mkdir(parents=True) + venv_python = venv_bin / "python" + venv_python.touch() + + # Mock the IsolatedVenvPlugin + mock_isolated_plugin = MagicMock() + mock_isolated_plugin.plugin_path = plugin_path + + with ( + patch('subprocess.run') as mock_run, + patch('cpex.framework.isolated.client.IsolatedVenvPlugin', return_value=mock_isolated_plugin), + patch.object(catalog, '_get_venv_python_executable', return_value=str(venv_python)), + ): + mock_run.side_effect = subprocess.CalledProcessError(1, "pip", stderr="Uninstall failed") + + with pytest.raises(RuntimeError) as exc_info: + catalog.uninstall_package("test_plugin", manifest) + + assert "Failed to uninstall" in str(exc_info.value) + + +class TestPluginCatalogInstallFolderViaPipIsolated: + """Tests for install_folder_via_pip with isolated_venv plugins.""" + + def test_install_folder_via_pip_isolated_venv(self, tmp_path, mock_github_env): + """Test installing an isolated_venv plugin from monorepo.""" + catalog = PluginCatalog() + + manifest = create_test_manifest(kind="isolated_venv") + + # Mock the download and initialization + with patch.object(catalog, '_download_monorepo_folder_to_temp') as mock_download: + mock_download.return_value = tmp_path / "package" + + with patch.object(catalog, '_initialize_isolated_venv') as mock_init: + mock_init.return_value = tmp_path / "venv" + + result = catalog.install_folder_via_pip(manifest) + + assert result == tmp_path / "venv" + mock_download.assert_called_once() + mock_init.assert_called_once() + + + +class TestPluginCatalogProcessPyprojectExtended: + """Extended tests for _process_pyproject method.""" + + def test_process_pyproject_with_member_none(self, mock_github_env): + """Test _process_pyproject when member is None (root directory).""" + catalog = PluginCatalog() + + mock_repo = MagicMock() + mock_item = MagicMock() + mock_item.path = "pyproject.toml" + mock_item.name = "pyproject.toml" + + # Mock file content + pyproject_content = """ +[project] +name = "test_plugin" +version = "1.0.0" +""" + mock_file_content = MagicMock() + mock_file_content.decoded_content = pyproject_content.encode('utf-8') + mock_repo.get_contents.return_value = mock_file_content + + repo_url = httpx.URL("https://github.com/org/repo") + + with patch.object(catalog, 'find_and_save_plugin_manifest') as mock_find: + catalog._process_pyproject(mock_repo, mock_item, repo_url, {}) + + # Verify find_and_save_plugin_manifest was called with member=None + mock_find.assert_called_once() + call_args = mock_find.call_args + assert call_args[1]['member'] is None + + +class TestPluginCatalogInstallFolderViaPipNonIsolated: + """Tests for install_folder_via_pip with non-isolated plugins.""" + + def test_install_folder_via_pip_non_isolated(self, mock_github_env): + """Test installing a non-isolated plugin from monorepo.""" + catalog = PluginCatalog() + + manifest = create_test_manifest(kind="native") + + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + result = catalog.install_folder_via_pip(manifest) + + # For non-isolated plugins, should return None + assert result is None + + # Verify pip install was called + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + assert "pip" in call_args + assert "install" in call_args + + +class TestPluginCatalogInstallPackageEdgeCases: + """Edge case tests for _install_package method.""" + + def test_install_package_with_null_version_constraint(self, mock_github_env): + """Test installing package with None version constraint.""" + catalog = PluginCatalog() + + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + catalog._install_package("test_plugin", None, use_test=False) + + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + assert "test_plugin" in call_args + assert "--index-url" not in call_args + + def test_install_package_unexpected_error(self, mock_github_env): + """Test unexpected error during package installation.""" + catalog = PluginCatalog() + + with patch('subprocess.run') as mock_run: + mock_run.side_effect = Exception("Unexpected error") + + with pytest.raises(RuntimeError) as exc_info: + catalog._install_package("test_plugin", None) + + assert "Unexpected error installing" in str(exc_info.value) + + +class TestPluginCatalogDownloadPackageEdgeCases: + """Edge case tests for download methods.""" + + def test_download_package_with_version_constraint(self, tmp_path, mock_github_env): + """Test downloading package with version constraint.""" + catalog = PluginCatalog() + + mock_wheel = tmp_path / "package-1.0.0-py3-none-any.whl" + with zipfile.ZipFile(mock_wheel, "w") as zf: + zf.writestr("test_file.txt", "test content") + + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + with patch('pathlib.Path.glob') as mock_glob: + mock_glob.return_value = [mock_wheel] + + result = catalog._download_package_to_temp("test_plugin", ">=1.0.0", use_test=False) + + assert result.exists() + + # Verify version constraint was included + call_args = mock_run.call_args[0][0] + assert any("test_plugin>=1.0.0" in str(arg) for arg in call_args) + + def test_download_monorepo_zip_format(self, tmp_path, mock_github_env): + """Test downloading monorepo with zip format.""" + catalog = PluginCatalog() + + # Create a mock zip file + mock_zip = tmp_path / "package.zip" + with zipfile.ZipFile(mock_zip, "w") as zf: + zf.writestr("test_file.txt", "test content") + + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + with patch('pathlib.Path.glob') as mock_glob: + mock_glob.return_value = [mock_zip] + + result = catalog._download_monorepo_folder_to_temp( + "git+https://github.com/org/repo#subdirectory=plugin", + "test_plugin" + ) + + assert result.exists() + assert result.name == "extracted" + + +class TestPluginCatalogFindOperations: + """Tests for find method.""" + + def test_find_case_insensitive(self, tmp_path, mock_github_env): + """Test that find is case-insensitive.""" + catalog = PluginCatalog() + + # Create a manifest with uppercase name + manifest_dir = tmp_path / "catalog" / "TEST_PLUGIN" + manifest_dir.mkdir(parents=True) + manifest_file = manifest_dir / "plugin-manifest.yaml" + + manifest_data = { + "name": "TEST_PLUGIN", + "version": "1.0.0", + "kind": "native", + "description": "Test plugin", + "author": "Test Author", + "available_hooks": ["tools"], + "tags": [], + "default_config": {}, + } + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + catalog.catalog_folder = str(tmp_path / "catalog") + catalog.load() + + # Search with lowercase should find it + result = catalog.find("test_plugin") + + assert result is not None + assert result.name == "TEST_PLUGIN" + + +class TestPluginCatalogInstallFromPypiIsolated: + """Tests for install_from_pypi with isolated_venv plugins.""" + + def test_install_from_pypi_isolated_venv(self, tmp_path, mock_github_env): + """Test installing an isolated_venv plugin from PyPI.""" + catalog = PluginCatalog() + + # Create mock extracted package with manifest + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + plugin_dir = extract_dir / "test_plugin" + plugin_dir.mkdir() + + manifest_file = plugin_dir / "plugin-manifest.yaml" + manifest_data = { + "name": "test_plugin", + "version": "1.0.0", + "kind": "isolated_venv", + "description": "Test isolated plugin", + "author": "Test Author", + "available_hooks": ["tools"], + "default_config": {"requirements_file": "requirements.txt"}, + } + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + with patch.object(catalog, '_download_package_to_temp') as mock_download: + mock_download.return_value = extract_dir + + with patch.object(catalog, '_initialize_isolated_venv') as mock_init: + mock_init.return_value = tmp_path / "venv" + + with patch.object(catalog, '_persist_manifest'): + manifest, plugin_path = catalog.install_from_pypi("test_plugin") + + assert manifest.kind == "isolated_venv" + assert plugin_path == tmp_path / "venv" + mock_init.assert_called_once() + + + +class TestPluginCatalogFindRequirementsInExtractedPackage: + """Tests for _find_requirements_in_extracted_package method with path traversal protection.""" + + def test_find_requirements_success(self, tmp_path, mock_github_env): + """Test successful finding of requirements file.""" + catalog = PluginCatalog() + + # Create a mock extracted package directory with requirements.txt + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + plugin_dir = extract_dir / "my_plugin" + plugin_dir.mkdir() + requirements_file = plugin_dir / "requirements.txt" + requirements_file.write_text("pytest>=7.0.0\n") + + # Find the requirements file + result = catalog._find_requirements_in_extracted_package( + extract_dir, "my_plugin", "requirements.txt" + ) + + assert result == requirements_file + assert result.exists() + + def test_find_requirements_not_found(self, tmp_path, mock_github_env): + """Test FileNotFoundError when requirements file doesn't exist.""" + catalog = PluginCatalog() + + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + + with pytest.raises(FileNotFoundError) as exc_info: + catalog._find_requirements_in_extracted_package( + extract_dir, "my_plugin", "requirements.txt" + ) + + assert "requirements file requirements.txt not found" in str(exc_info.value) + assert "my_plugin" in str(exc_info.value) + + def test_find_requirements_path_traversal_parent_directory(self, tmp_path, mock_github_env): + """Test that path traversal with ../ is blocked.""" + catalog = PluginCatalog() + + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + + # Try to access parent directory + with pytest.raises(ValueError) as exc_info: + catalog._find_requirements_in_extracted_package( + extract_dir, "my_plugin", "../../../etc/passwd" + ) + + assert "path traversal attempts are not allowed" in str(exc_info.value) + + def test_find_requirements_path_traversal_absolute_path(self, tmp_path, mock_github_env): + """Test that absolute paths are blocked.""" + catalog = PluginCatalog() + + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + + # Try to use absolute path + with pytest.raises(ValueError) as exc_info: + catalog._find_requirements_in_extracted_package( + extract_dir, "my_plugin", "/etc/passwd" + ) + + assert "path traversal attempts are not allowed" in str(exc_info.value) + + def test_find_requirements_path_traversal_mixed_separators(self, tmp_path, mock_github_env): + """Test that mixed path separators are blocked.""" + catalog = PluginCatalog() + + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + + # Try to use backslashes (Windows-style) in suspicious way + with pytest.raises(ValueError) as exc_info: + catalog._find_requirements_in_extracted_package( + extract_dir, "my_plugin", "..\\..\\etc\\passwd" + ) + + assert "path traversal attempts are not allowed" in str(exc_info.value) + + def test_find_requirements_path_traversal_encoded(self, tmp_path, mock_github_env): + """Test that URL-encoded path traversal attempts are blocked.""" + catalog = PluginCatalog() + + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + + # Try various encoded forms + malicious_paths = [ + "..%2F..%2Fetc%2Fpasswd", + "..%5c..%5cetc%5cpasswd", + ] + + for malicious_path in malicious_paths: + with pytest.raises(ValueError) as exc_info: + catalog._find_requirements_in_extracted_package( + extract_dir, "my_plugin", malicious_path + ) + + assert "path traversal" in str(exc_info.value).lower() or "suspicious" in str(exc_info.value).lower() + + def test_find_requirements_defense_in_depth(self, tmp_path, mock_github_env): + """Test defense-in-depth check that file is within extract_dir.""" + catalog = PluginCatalog() + + # Create extract directory + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + + # Create a file outside the extract directory + outside_dir = tmp_path / "outside" + outside_dir.mkdir() + outside_file = outside_dir / "requirements.txt" + outside_file.write_text("malicious\n") + + # Create a symlink inside extract_dir pointing outside + # (This tests the defense-in-depth check) + try: + symlink_path = extract_dir / "requirements.txt" + symlink_path.symlink_to(outside_file) + + # The rglob should find it, but the defense-in-depth check should catch it + with pytest.raises(ValueError) as exc_info: + catalog._find_requirements_in_extracted_package( + extract_dir, "my_plugin", "requirements.txt" + ) + + assert "outside the package directory" in str(exc_info.value) + except OSError: + # Skip test if symlinks aren't supported (e.g., Windows without admin) + pytest.skip("Symlinks not supported on this system") + + def test_find_requirements_nested_directory(self, tmp_path, mock_github_env): + """Test finding requirements file in nested directory structure.""" + catalog = PluginCatalog() + + # Create nested directory structure + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + nested_dir = extract_dir / "plugin" / "subdir" / "config" + nested_dir.mkdir(parents=True) + requirements_file = nested_dir / "requirements.txt" + requirements_file.write_text("pytest>=7.0.0\n") + + # Should find the file in nested structure + result = catalog._find_requirements_in_extracted_package( + extract_dir, "my_plugin", "requirements.txt" + ) + + assert result == requirements_file + assert result.exists() + + def test_find_requirements_multiple_files_returns_first(self, tmp_path, mock_github_env): + """Test that when multiple matching files exist, the first one is returned.""" + catalog = PluginCatalog() + + # Create multiple requirements files + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + + dir1 = extract_dir / "dir1" + dir1.mkdir() + req1 = dir1 / "requirements.txt" + req1.write_text("first\n") + + dir2 = extract_dir / "dir2" + dir2.mkdir() + req2 = dir2 / "requirements.txt" + req2.write_text("second\n") + + # Should return one of them (first found by rglob) + result = catalog._find_requirements_in_extracted_package( + extract_dir, "my_plugin", "requirements.txt" + ) + + assert result in [req1, req2] + assert result.exists() + + def test_find_requirements_custom_filename(self, tmp_path, mock_github_env): + """Test finding a custom requirements filename.""" + catalog = PluginCatalog() + + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + plugin_dir = extract_dir / "my_plugin" + plugin_dir.mkdir() + + # Use a custom requirements filename + custom_req = plugin_dir / "requirements-dev.txt" + custom_req.write_text("pytest>=7.0.0\n") + + result = catalog._find_requirements_in_extracted_package( + extract_dir, "my_plugin", "requirements-dev.txt" + ) + + assert result == custom_req + assert result.exists() + + +class TestPluginCatalogFindAndLoadVersionsJson: + """Tests for _find_and_load_versions_json method.""" + + def test_find_and_load_versions_json_non_isolated_success(self, tmp_path, mock_github_env): + """Test _find_and_load_versions_json for non-isolated plugin with versions.json.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Create a plugin path with versions.json + plugin_path = tmp_path / "plugin_package" + plugin_path.mkdir() + versions_json = plugin_path / "versions.json" + versions_data = {"versions": [{"version": "1.0.0", "date": "2024-01-01"}]} + versions_json.write_text(json.dumps(versions_data)) + + manifest = create_test_manifest(name="test_plugin", kind="native") + + catalog._find_and_load_versions_json(manifest, plugin_path, "test_plugin") + + # Check that versions.json was saved to catalog + catalog_versions = Path(catalog.catalog_folder) / "test_plugin" / "versions.json" + assert catalog_versions.exists() + saved_data = json.loads(catalog_versions.read_text()) + assert saved_data == versions_data + + def test_find_and_load_versions_json_non_isolated_no_file(self, tmp_path, mock_github_env): + """Test _find_and_load_versions_json for non-isolated plugin without versions.json.""" + with patch("cpex.tools.catalog.logger") as mock_logger: + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Create a plugin path without versions.json + plugin_path = tmp_path / "plugin_package" + plugin_path.mkdir() + + manifest = create_test_manifest(name="test_plugin", kind="native") + + catalog._find_and_load_versions_json(manifest, plugin_path, "test_plugin") + + # Check that no versions.json was saved to catalog + catalog_versions = Path(catalog.catalog_folder) / "test_plugin" / "versions.json" + assert not catalog_versions.exists() + mock_logger.debug.assert_called() + + def test_find_and_load_versions_json_isolated_success(self, tmp_path, mock_github_env): + """Test _find_and_load_versions_json for isolated_venv plugin.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Create a venv path + venv_path = tmp_path / ".venv" + venv_path.mkdir() + + # Mock the subprocess call to find package path + mock_package_path = tmp_path / "venv_package" + mock_package_path.mkdir() + versions_json = mock_package_path / "versions.json" + versions_data = {"versions": [{"version": "2.0.0", "date": "2024-02-01"}]} + versions_json.write_text(json.dumps(versions_data)) + + manifest = create_test_manifest(name="test_plugin", kind="isolated_venv") + + with ( + patch.object(catalog, "_get_venv_python_executable", return_value="/fake/python"), + patch("cpex.tools.catalog.subprocess.run") as mock_run, + ): + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = str(mock_package_path) + mock_result.stderr = "" + mock_run.return_value = mock_result + + catalog._find_and_load_versions_json(manifest, venv_path, "test_plugin") + + # Check that versions.json was saved to catalog + catalog_versions = Path(catalog.catalog_folder) / "test_plugin" / "versions.json" + assert catalog_versions.exists() + saved_data = json.loads(catalog_versions.read_text()) + assert saved_data == versions_data + + def test_find_and_load_versions_json_isolated_subprocess_failure(self, tmp_path, mock_github_env): + """Test _find_and_load_versions_json for isolated_venv when subprocess fails.""" + with patch("cpex.tools.catalog.logger") as mock_logger: + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + venv_path = tmp_path / ".venv" + venv_path.mkdir() + + manifest = create_test_manifest(name="test_plugin", kind="isolated_venv") + + with patch("cpex.tools.catalog.subprocess.run") as mock_run: + mock_result = Mock() + mock_result.returncode = 1 + mock_result.stdout = "" + mock_result.stderr = "NOT_FOUND" + mock_run.return_value = mock_result + + catalog._find_and_load_versions_json(manifest, venv_path, "test_plugin") + + # Check that no versions.json was saved + catalog_versions = Path(catalog.catalog_folder) / "test_plugin" / "versions.json" + assert not catalog_versions.exists() + mock_logger.warning.assert_called() + + def test_find_and_load_versions_json_none_plugin_path(self, tmp_path, mock_github_env): + """Test _find_and_load_versions_json with None plugin_path.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + manifest = create_test_manifest(name="test_plugin", kind="native") + + # Should handle None gracefully + catalog._find_and_load_versions_json(manifest, None, "test_plugin") + + catalog_versions = Path(catalog.catalog_folder) / "test_plugin" / "versions.json" + assert not catalog_versions.exists() + + def test_find_and_load_versions_json_exception_handling(self, tmp_path, mock_github_env): + """Test _find_and_load_versions_json handles exceptions gracefully.""" + with patch("cpex.tools.catalog.logger") as mock_logger: + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + plugin_path = tmp_path / "plugin_package" + plugin_path.mkdir() + + manifest = create_test_manifest(name="test_plugin", kind="native") + + # Create a versions.json that will cause an error when reading + versions_json = plugin_path / "versions.json" + versions_json.write_text("invalid json {{{") + + catalog._find_and_load_versions_json(manifest, plugin_path, "test_plugin") + + mock_logger.warning.assert_called() + + +class TestPluginCatalogGetVenvPythonExecutable: + """Tests for _get_venv_python_executable method.""" + + def test_get_venv_python_executable_unix(self, tmp_path, mock_github_env): + """Test _get_venv_python_executable on Unix-like systems.""" + catalog = PluginCatalog() + + 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"): + result = catalog._get_venv_python_executable(venv_path) + assert result == str(python_exe) + + def test_get_venv_python_executable_windows(self, tmp_path, mock_github_env): + """Test _get_venv_python_executable on Windows.""" + catalog = PluginCatalog() + + 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"): + result = catalog._get_venv_python_executable(venv_path) + assert result == str(python_exe) + + def test_get_venv_python_executable_not_found(self, tmp_path, mock_github_env): + """Test _get_venv_python_executable when executable doesn't exist.""" + catalog = PluginCatalog() + + venv_path = tmp_path / ".venv" + venv_path.mkdir() + + with pytest.raises(FileNotFoundError, match="Python executable not found"): + catalog._get_venv_python_executable(venv_path) + + +class TestPluginCatalogInstallFromPypiWithVersionsJson: + """Tests for install_from_pypi integration with versions.json.""" + + def test_install_from_pypi_calls_find_and_load_versions_json(self, tmp_path, mock_github_env): + """Test that install_from_pypi calls _find_and_load_versions_json.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Create temporary package structure + temp_extract = tmp_path / "temp_extract" + temp_extract.mkdir() + package_dir = temp_extract / "test_plugin" + package_dir.mkdir() + + manifest_path = package_dir / "plugin-manifest.yaml" + manifest_data = { + "name": "test_plugin", + "version": "1.0.0", + "kind": "native", + "description": "Test", + "author": "Test", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {} + } + manifest_path.write_text(yaml.dump(manifest_data)) + + with ( + patch.object(catalog, "_download_package_to_temp", return_value=temp_extract), + patch.object(catalog, "_install_package"), + patch("cpex.tools.catalog.find_package_path", return_value=package_dir), + patch.object(catalog, "_find_and_load_versions_json") as mock_find_versions, + patch.object(catalog, "update_plugin_version_registry"), + ): + manifest, plugin_path = catalog.install_from_pypi("test_plugin") + + # Verify _find_and_load_versions_json was called + mock_find_versions.assert_called_once() + call_args = mock_find_versions.call_args + assert call_args[0][0].name == "test_plugin" # manifest + assert call_args[0][1] == package_dir # plugin_path + assert call_args[0][2] == "test_plugin" # package_name + + +# Made with Bob + + +class TestPluginCatalogInstallFromLocal: + """Tests for PluginCatalog.install_from_local method.""" + + def test_install_from_local_manifest_in_root(self, tmp_path, mock_github_env): + """Test installing from local source with manifest in root directory.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Create source directory with pyproject and manifest in root + source_dir = tmp_path / "my_plugin" + source_dir.mkdir() + (source_dir / "pyproject.toml").write_text('[project]\nname = "my_plugin"\nversion = "1.0.0"\n') + + manifest_data = { + "name": "my_plugin", + "version": "1.0.0", + "kind": "native", + "description": "Test plugin", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {}, + } + manifest_file = source_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + with ( + patch("cpex.tools.catalog.subprocess.run") as mock_subprocess, + patch("cpex.tools.catalog.find_package_path", return_value=source_dir), + patch.object(catalog, "_persist_manifest"), + patch.object(catalog, "_find_and_load_versions_json", return_value=source_dir), + patch.object(catalog, "update_plugin_version_registry"), + ): + manifest, plugin_path = catalog.install_from_local(source_dir) + + # Verify subprocess was called with pip install -e + mock_subprocess.assert_called_once() + call_args = mock_subprocess.call_args[0][0] + assert "-m" in call_args + assert "pip" in call_args + assert "install" in call_args + assert "-e" in call_args + assert str(source_dir) in call_args + + assert manifest.name == "my_plugin" + assert manifest.kind == "native" + assert plugin_path == source_dir + + def test_install_from_local_manifest_in_subdirectory(self, tmp_path, mock_github_env): + """Test installing from local source with manifest in subdirectory.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Create source directory with pyproject and manifest in subdirectory + source_dir = tmp_path / "my_plugin_project" + source_dir.mkdir() + plugin_subdir = source_dir / "my_plugin" + plugin_subdir.mkdir() + (plugin_subdir / "pyproject.toml").write_text('[project]\nname = "my_plugin"\nversion = "1.0.0"\n') + + manifest_data = { + "name": "my_plugin", + "version": "1.0.0", + "kind": "native", + "description": "Test plugin", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {}, + } + manifest_file = plugin_subdir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + with ( + patch("cpex.tools.catalog.subprocess.run") as mock_subprocess, + patch("cpex.tools.catalog.find_package_path", return_value=source_dir), + patch.object(catalog, "_persist_manifest"), + patch.object(catalog, "_find_and_load_versions_json", return_value=source_dir), + patch.object(catalog, "update_plugin_version_registry"), + ): + manifest, plugin_path = catalog.install_from_local(source_dir) + + assert manifest.name == "my_plugin" + mock_subprocess.assert_called_once() + + def test_install_from_local_manifest_not_found(self, tmp_path, mock_github_env): + """Test error when manifest is not found in source or subdirectories.""" + catalog = PluginCatalog() + + # Create source directory without pyproject or manifest + source_dir = tmp_path / "my_plugin" + source_dir.mkdir() + + with pytest.raises(FileNotFoundError, match="pyproject.toml not found"): + catalog.install_from_local(source_dir) + + def test_install_from_local_isolated_venv(self, tmp_path, mock_github_env): + """Test installing an isolated_venv plugin from local source.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + catalog.plugin_folder = str(tmp_path / "plugins") + + # Create source directory with isolated_venv pyproject and manifest + source_dir = tmp_path / "my_isolated_plugin" + source_dir.mkdir() + (source_dir / "pyproject.toml").write_text('[project]\nname = "my_isolated_plugin"\nversion = "1.0.0"\n') + + manifest_data = { + "name": "my_isolated_plugin", + "version": "1.0.0", + "kind": "isolated_venv", + "description": "Test isolated plugin", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {"requirements_file": "requirements.txt"}, + } + manifest_file = source_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + # Mock IsolatedVenvPlugin + mock_isolated_plugin = Mock() + mock_isolated_plugin.plugin_path = tmp_path / "plugins" / "my_isolated_plugin" + mock_isolated_plugin.plugin_path.mkdir(parents=True, exist_ok=True) + venv_path = mock_isolated_plugin.plugin_path / ".venv" + venv_path.mkdir(parents=True, exist_ok=True) + + # Create mock venv python executable + if sys.platform == "win32": + python_exe = venv_path / "Scripts" / "python.exe" + else: + python_exe = venv_path / "bin" / "python" + python_exe.parent.mkdir(parents=True, exist_ok=True) + python_exe.touch() + + with ( + patch("cpex.framework.isolated.client.IsolatedVenvPlugin", return_value=mock_isolated_plugin), + patch("asyncio.run"), + patch("cpex.tools.catalog.subprocess.run") as mock_subprocess, + patch.object(catalog, "_persist_manifest"), + patch.object(catalog, "_find_and_load_versions_json", return_value=mock_isolated_plugin.plugin_path), + patch.object(catalog, "update_plugin_version_registry"), + ): + manifest, plugin_path = catalog.install_from_local(source_dir) + + # Verify subprocess was called with venv python + mock_subprocess.assert_called_once() + call_args = mock_subprocess.call_args[0][0] + assert str(python_exe) == call_args[0] + assert "-m" in call_args + assert "pip" in call_args + assert "install" in call_args + assert "-e" in call_args + assert str(source_dir) in call_args + + assert manifest.name == "my_isolated_plugin" + assert manifest.kind == "isolated_venv" + assert plugin_path == mock_isolated_plugin.plugin_path + + def test_install_from_local_subprocess_error(self, tmp_path, mock_github_env): + """Test error handling when pip install fails.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Create source directory with pyproject and manifest + source_dir = tmp_path / "my_plugin" + source_dir.mkdir() + (source_dir / "pyproject.toml").write_text('[project]\nname = "my_plugin"\nversion = "1.0.0"\n') + + manifest_data = { + "name": "my_plugin", + "version": "1.0.0", + "kind": "native", + "description": "Test plugin", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {}, + } + manifest_file = source_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + with patch("cpex.tools.catalog.subprocess.run") as mock_subprocess: + mock_subprocess.side_effect = subprocess.CalledProcessError( + 1, ["pip", "install"], stderr="Installation failed" + ) + + with pytest.raises(RuntimeError, match="Failed to install plugin from"): + catalog.install_from_local(source_dir) + + def test_install_from_local_invalid_manifest(self, tmp_path, mock_github_env): + """Test error handling when manifest is invalid.""" + catalog = PluginCatalog() + + # Create source directory with pyproject and invalid manifest + source_dir = tmp_path / "my_plugin" + source_dir.mkdir() + (source_dir / "pyproject.toml").write_text('[project]\nname = "my_plugin"\nversion = "1.0.0"\n') + + manifest_file = source_dir / "plugin-manifest.yaml" + manifest_file.write_text("invalid: yaml: content:") + + with pytest.raises(RuntimeError, match="Failed to parse manifest YAML"): + catalog.install_from_local(source_dir) + + def test_install_from_local_calls_persist_and_registry(self, tmp_path, mock_github_env): + """Test that install_from_local calls persist_manifest and update_plugin_version_registry.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Create source directory with pyproject and manifest + source_dir = tmp_path / "my_plugin" + source_dir.mkdir() + (source_dir / "pyproject.toml").write_text('[project]\nname = "my_plugin"\nversion = "1.0.0"\n') + + manifest_data = { + "name": "my_plugin", + "version": "1.0.0", + "kind": "native", + "description": "Test plugin", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {}, + } + manifest_file = source_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + with ( + patch("cpex.tools.catalog.subprocess.run"), + patch("cpex.tools.catalog.find_package_path", return_value=source_dir), + patch.object(catalog, "_persist_manifest") as mock_persist, + patch.object(catalog, "_find_and_load_versions_json", return_value=source_dir) as mock_versions, + patch.object(catalog, "update_plugin_version_registry") as mock_registry, + ): + manifest, plugin_path = catalog.install_from_local(source_dir) + + # Verify all post-install steps were called + mock_persist.assert_called_once() + mock_versions.assert_called_once() + mock_registry.assert_called_once() + + # Verify the manifest was passed correctly + persist_call_args = mock_persist.call_args[0] + assert persist_call_args[0].name == "my_plugin" + + def test_install_from_local_isolated_venv_initialization_error(self, tmp_path, mock_github_env): + """Test error handling when isolated venv initialization fails.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + catalog.plugin_folder = str(tmp_path / "plugins") + + # Create source directory with isolated_venv pyproject and manifest + source_dir = tmp_path / "my_isolated_plugin" + source_dir.mkdir() + (source_dir / "pyproject.toml").write_text('[project]\nname = "my_isolated_plugin"\nversion = "1.0.0"\n') + + manifest_data = { + "name": "my_isolated_plugin", + "version": "1.0.0", + "kind": "isolated_venv", + "description": "Test isolated plugin", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {"requirements_file": "requirements.txt"}, + } + manifest_file = source_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + with ( + patch("cpex.framework.isolated.client.IsolatedVenvPlugin") as mock_plugin_class, + patch("asyncio.run") as mock_asyncio_run, + ): + mock_asyncio_run.side_effect = Exception("Venv initialization failed") + + with pytest.raises(RuntimeError, match="Failed to install isolated_venv plugin"): + catalog.install_from_local(source_dir) + + def test_install_from_local_fallback_to_source_path(self, tmp_path, mock_github_env): + """Test that source path is used as fallback when find_package_path returns None.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Create source directory with pyproject and manifest + source_dir = tmp_path / "my_plugin" + source_dir.mkdir() + (source_dir / "pyproject.toml").write_text('[project]\nname = "my_plugin"\nversion = "1.0.0"\n') + + manifest_data = { + "name": "my_plugin", + "version": "1.0.0", + "kind": "native", + "description": "Test plugin", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {}, + } + manifest_file = source_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + with ( + patch("cpex.tools.catalog.subprocess.run"), + patch.object(catalog, "_persist_manifest"), + patch.object(catalog, "_find_and_load_versions_json", return_value=None), + patch.object(catalog, "update_plugin_version_registry"), + ): + manifest, plugin_path = catalog.install_from_local(source_dir) + + # Non-isolated installs now derive plugin_path from manifest location + assert plugin_path == source_dir + + def test_install_from_local_with_versions_json(self, tmp_path, mock_github_env): + """Test that versions.json is found and loaded correctly.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Create source directory with pyproject and manifest + source_dir = tmp_path / "my_plugin" + source_dir.mkdir() + (source_dir / "pyproject.toml").write_text('[project]\nname = "my_plugin"\nversion = "1.0.0"\n') + + manifest_data = { + "name": "my_plugin", + "version": "1.0.0", + "kind": "native", + "description": "Test plugin", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {}, + } + manifest_file = source_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + # Create a different path that versions.json returns + actual_path = tmp_path / "actual_plugin_path" + actual_path.mkdir() + + with ( + patch("cpex.tools.catalog.subprocess.run"), + patch("cpex.tools.catalog.find_package_path", return_value=source_dir), + patch.object(catalog, "_persist_manifest"), + patch.object(catalog, "_find_and_load_versions_json", return_value=actual_path), + patch.object(catalog, "update_plugin_version_registry"), + ): + manifest, plugin_path = catalog.install_from_local(source_dir) + + # Should return the actual path from versions.json + assert plugin_path == actual_path + + + +class TestPluginCatalogInstallFromGit: + """Tests for PluginCatalog.install_from_git method.""" + + def test_install_from_git_success_https(self, tmp_path, mock_github_env): + """Test successful installation from Git using HTTPS URL.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Create mock extracted package with manifest + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + plugin_dir = extract_dir / "test_plugin" + plugin_dir.mkdir() + + manifest_data = { + "name": "test_plugin", + "version": "1.0.0", + "kind": "native", + "description": "Test plugin", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {}, + } + manifest_file = plugin_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + # Create a mock archive + archive_path = tmp_path / "test_plugin-1.0.0.tar.gz" + with tarfile.open(archive_path, "w:gz") as tar: + tar.add(plugin_dir, arcname="test_plugin") + + with ( + patch("cpex.tools.catalog.subprocess.run") as mock_subprocess, + patch("cpex.tools.catalog.tempfile.mkdtemp", return_value=str(tmp_path)), + patch("cpex.tools.catalog.find_package_path", return_value=plugin_dir), + patch.object(catalog, "_persist_manifest"), + patch.object(catalog, "_find_and_load_versions_json", return_value=plugin_dir), + patch.object(catalog, "update_plugin_version_registry"), + patch("shutil.rmtree"), + ): + # Mock pip download to create the archive + def mock_run(*args, **kwargs): + if "download" in args[0]: + # Simulate pip download creating the archive + pass + return Mock(returncode=0) + + mock_subprocess.side_effect = mock_run + + url = "test_plugin @ git+https://github.com/example/test_plugin.git" + manifest, plugin_path = catalog.install_from_git(url) + + assert manifest.name == "test_plugin" + assert manifest.kind == "native" + assert plugin_path == plugin_dir + # Should call subprocess twice: once for download, once for install + assert mock_subprocess.call_count == 2 + + def test_install_from_git_success_ssh(self, tmp_path, mock_github_env): + """Test successful installation from Git using SSH URL.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + plugin_dir = extract_dir / "test_plugin" + plugin_dir.mkdir() + + manifest_data = { + "name": "test_plugin", + "version": "1.0.0", + "kind": "native", + "description": "Test plugin", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {}, + } + manifest_file = plugin_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + archive_path = tmp_path / "test_plugin-1.0.0.tar.gz" + with tarfile.open(archive_path, "w:gz") as tar: + tar.add(plugin_dir, arcname="test_plugin") + + with ( + patch("cpex.tools.catalog.subprocess.run") as mock_subprocess, + patch("cpex.tools.catalog.tempfile.mkdtemp", return_value=str(tmp_path)), + patch("cpex.tools.catalog.find_package_path", return_value=plugin_dir), + patch.object(catalog, "_persist_manifest"), + patch.object(catalog, "_find_and_load_versions_json", return_value=plugin_dir), + patch.object(catalog, "update_plugin_version_registry"), + patch("shutil.rmtree"), + ): + mock_subprocess.return_value = Mock(returncode=0) + + # Use git@ format which is the standard SSH format + url = "test_plugin @ git+git@github.com:example/test_plugin.git" + manifest, plugin_path = catalog.install_from_git(url) + + assert manifest.name == "test_plugin" + assert plugin_path == plugin_dir + + def test_install_from_git_with_branch(self, tmp_path, mock_github_env): + """Test installation from Git with specific branch.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + plugin_dir = extract_dir / "test_plugin" + plugin_dir.mkdir() + + manifest_data = { + "name": "test_plugin", + "version": "1.0.0", + "kind": "native", + "description": "Test plugin", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {}, + } + manifest_file = plugin_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + archive_path = tmp_path / "test_plugin-1.0.0.tar.gz" + with tarfile.open(archive_path, "w:gz") as tar: + tar.add(plugin_dir, arcname="test_plugin") + + with ( + patch("cpex.tools.catalog.subprocess.run") as mock_subprocess, + patch("cpex.tools.catalog.tempfile.mkdtemp", return_value=str(tmp_path)), + patch("cpex.tools.catalog.find_package_path", return_value=plugin_dir), + patch.object(catalog, "_persist_manifest"), + patch.object(catalog, "_find_and_load_versions_json", return_value=plugin_dir), + patch.object(catalog, "update_plugin_version_registry"), + patch("shutil.rmtree"), + ): + mock_subprocess.return_value = Mock(returncode=0) + + url = "test_plugin @ git+https://github.com/example/test_plugin.git@master" + manifest, plugin_path = catalog.install_from_git(url) + + assert manifest.name == "test_plugin" + # Verify that the branch was included in the pip install command + install_call = [call for call in mock_subprocess.call_args_list if "install" in str(call)] + assert len(install_call) > 0 + + def test_install_from_git_isolated_venv(self, tmp_path, mock_github_env): + """Test installation of isolated_venv plugin from Git.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + plugin_dir = extract_dir / "test_plugin" + plugin_dir.mkdir() + + manifest_data = { + "name": "test_plugin", + "version": "1.0.0", + "kind": "isolated_venv", + "description": "Test isolated plugin", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {"requirements_file": "requirements.txt"}, + } + manifest_file = plugin_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + # Create requirements file + requirements_file = plugin_dir / "requirements.txt" + requirements_file.write_text("pytest>=7.0.0\n") + + archive_path = tmp_path / "test_plugin-1.0.0.tar.gz" + with tarfile.open(archive_path, "w:gz") as tar: + tar.add(plugin_dir, arcname="test_plugin") + tar.add(requirements_file, arcname="test_plugin/requirements.txt") + + venv_path = tmp_path / "venv_path" + venv_path.mkdir() + venv_bin = venv_path / "venv" / "bin" + venv_bin.mkdir(parents=True) + venv_python = venv_bin / "python" + + with ( + patch("cpex.tools.catalog.subprocess.run") as mock_subprocess, + patch("cpex.tools.catalog.tempfile.mkdtemp", return_value=str(tmp_path)), + patch.object(catalog, "_initialize_isolated_venv", return_value=venv_path), + patch.object(catalog, "_get_venv_python_executable", return_value=str(venv_python)), + patch.object(catalog, "_persist_manifest"), + patch.object(catalog, "_find_and_load_versions_json", return_value=venv_path), + patch.object(catalog, "update_plugin_version_registry"), + patch("shutil.rmtree"), + ): + mock_subprocess.return_value = Mock(returncode=0) + + url = "test_plugin @ git+https://github.com/example/test_plugin.git" + manifest, plugin_path = catalog.install_from_git(url) + + assert manifest.kind == "isolated_venv" + assert plugin_path == venv_path + # Should call subprocess twice: download and install into isolated venv + assert mock_subprocess.call_count == 2 + # Verify install was called with venv python (not download which also contains "install") + install_calls = [call for call in mock_subprocess.call_args_list if "pip', 'install" in str(call)] + assert len(install_calls) == 1 + assert str(venv_python) in str(install_calls[0]) + + def test_install_from_git_invalid_url_format(self, mock_github_env): + """Test error when URL format is invalid (missing @).""" + catalog = PluginCatalog() + + with pytest.raises(ValueError) as exc_info: + catalog.install_from_git("test_plugin") + + assert "Invalid Git URL format" in str(exc_info.value) + assert "Expected format" in str(exc_info.value) + + def test_install_from_git_missing_git_prefix(self, mock_github_env): + """Test error when git+ prefix is missing.""" + catalog = PluginCatalog() + + with pytest.raises(ValueError) as exc_info: + catalog.install_from_git("test_plugin @ https://github.com/example/test_plugin.git") + + assert "Git URL must start with 'git+'" in str(exc_info.value) + + def test_install_from_git_invalid_git_url(self, mock_github_env): + """Test error when Git URL is invalid.""" + catalog = PluginCatalog() + + with pytest.raises(ValueError) as exc_info: + catalog.install_from_git("test_plugin @ git+invalid://not-a-valid-url") + + assert "Invalid Git repository URL" in str(exc_info.value) + + def test_install_from_git_download_failure(self, tmp_path, mock_github_env): + """Test error when pip download fails.""" + catalog = PluginCatalog() + + with ( + patch("cpex.tools.catalog.subprocess.run") as mock_subprocess, + patch("cpex.tools.catalog.tempfile.mkdtemp", return_value=str(tmp_path)), + patch("shutil.rmtree"), + ): + mock_subprocess.side_effect = subprocess.CalledProcessError( + 1, ["pip", "download"], stderr="Download failed" + ) + + with pytest.raises(RuntimeError) as exc_info: + catalog.install_from_git("test_plugin @ git+https://github.com/example/test_plugin.git") + + assert "Failed to install test_plugin from Git" in str(exc_info.value) + + def test_install_from_git_no_archive_found(self, tmp_path, mock_github_env): + """Test error when no archive is found after download.""" + catalog = PluginCatalog() + + with ( + patch("cpex.tools.catalog.subprocess.run") as mock_subprocess, + patch("cpex.tools.catalog.tempfile.mkdtemp", return_value=str(tmp_path)), + patch("shutil.rmtree"), + ): + mock_subprocess.return_value = Mock(returncode=0) + + with pytest.raises(RuntimeError) as exc_info: + catalog.install_from_git("test_plugin @ git+https://github.com/example/test_plugin.git") + + assert "No package archive found" in str(exc_info.value) + + def test_install_from_git_manifest_not_found(self, tmp_path, mock_github_env): + """Test error when manifest is not found in package.""" + catalog = PluginCatalog() + + # Create archive without manifest + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + plugin_dir = extract_dir / "test_plugin" + plugin_dir.mkdir() + + archive_path = tmp_path / "test_plugin-1.0.0.tar.gz" + with tarfile.open(archive_path, "w:gz") as tar: + tar.add(plugin_dir, arcname="test_plugin") + + with ( + patch("cpex.tools.catalog.subprocess.run") as mock_subprocess, + patch("cpex.tools.catalog.tempfile.mkdtemp", return_value=str(tmp_path)), + patch("shutil.rmtree"), + ): + mock_subprocess.return_value = Mock(returncode=0) + + # The method wraps FileNotFoundError in RuntimeError + with pytest.raises(RuntimeError) as exc_info: + catalog.install_from_git("test_plugin @ git+https://github.com/example/test_plugin.git") + + assert "Unexpected error installing test_plugin from Git" in str(exc_info.value) + assert "plugin-manifest.yaml not found" in str(exc_info.value) + + def test_install_from_git_install_failure(self, tmp_path, mock_github_env): + """Test error when pip install fails.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + plugin_dir = extract_dir / "test_plugin" + plugin_dir.mkdir() + + manifest_data = { + "name": "test_plugin", + "version": "1.0.0", + "kind": "native", + "description": "Test plugin", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {}, + } + manifest_file = plugin_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + archive_path = tmp_path / "test_plugin-1.0.0.tar.gz" + with tarfile.open(archive_path, "w:gz") as tar: + tar.add(plugin_dir, arcname="test_plugin") + + with ( + patch("cpex.tools.catalog.subprocess.run") as mock_subprocess, + patch("cpex.tools.catalog.tempfile.mkdtemp", return_value=str(tmp_path)), + patch("shutil.rmtree"), + ): + # First call (download) succeeds, second call (install) fails + mock_subprocess.side_effect = [ + Mock(returncode=0), # download succeeds + subprocess.CalledProcessError(1, ["pip", "install"], stderr="Install failed"), # install fails + ] + + with pytest.raises(RuntimeError) as exc_info: + catalog.install_from_git("test_plugin @ git+https://github.com/example/test_plugin.git") + + assert "Failed to install test_plugin from Git" in str(exc_info.value) + + def test_install_from_git_cleanup_on_error(self, tmp_path, mock_github_env): + """Test that temporary directory is cleaned up even on error.""" + catalog = PluginCatalog() + + with ( + patch("cpex.tools.catalog.subprocess.run") as mock_subprocess, + patch("cpex.tools.catalog.tempfile.mkdtemp", return_value=str(tmp_path)), + patch("shutil.rmtree") as mock_rmtree, + ): + mock_subprocess.side_effect = subprocess.CalledProcessError( + 1, ["pip", "download"], stderr="Download failed" + ) + + with pytest.raises(RuntimeError): + catalog.install_from_git("test_plugin @ git+https://github.com/example/test_plugin.git") + + # Verify cleanup was called + mock_rmtree.assert_called_once() + assert str(tmp_path) in str(mock_rmtree.call_args) + + def test_install_from_git_with_zip_archive(self, tmp_path, mock_github_env): + """Test installation from Git with zip archive.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + plugin_dir = extract_dir / "test_plugin" + plugin_dir.mkdir() + + manifest_data = { + "name": "test_plugin", + "version": "1.0.0", + "kind": "native", + "description": "Test plugin", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {}, + } + manifest_file = plugin_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + # Create a zip archive + archive_path = tmp_path / "test_plugin-1.0.0.zip" + with zipfile.ZipFile(archive_path, "w") as zipf: + zipf.write(manifest_file, arcname="test_plugin/plugin-manifest.yaml") + + with ( + patch("cpex.tools.catalog.subprocess.run") as mock_subprocess, + patch("cpex.tools.catalog.tempfile.mkdtemp", return_value=str(tmp_path)), + patch("cpex.tools.catalog.find_package_path", return_value=plugin_dir), + patch.object(catalog, "_persist_manifest"), + patch.object(catalog, "_find_and_load_versions_json", return_value=plugin_dir), + patch.object(catalog, "update_plugin_version_registry"), + patch("shutil.rmtree"), + ): + mock_subprocess.return_value = Mock(returncode=0) + + url = "test_plugin @ git+https://github.com/example/test_plugin.git" + manifest, plugin_path = catalog.install_from_git(url) + + assert manifest.name == "test_plugin" + + def test_install_from_git_with_wheel(self, tmp_path, mock_github_env): + """Test installation from Git with wheel archive.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + extract_dir = tmp_path / "extracted" + extract_dir.mkdir() + plugin_dir = extract_dir / "test_plugin" + plugin_dir.mkdir() + + manifest_data = { + "name": "test_plugin", + "version": "1.0.0", + "kind": "native", + "description": "Test plugin", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {}, + } + manifest_file = plugin_dir / "plugin-manifest.yaml" + manifest_file.write_text(yaml.safe_dump(manifest_data)) + + # Create a wheel archive (which is a zip file) + archive_path = tmp_path / "test_plugin-1.0.0-py3-none-any.whl" + with zipfile.ZipFile(archive_path, "w") as zipf: + zipf.write(manifest_file, arcname="test_plugin/plugin-manifest.yaml") + + with ( + patch("cpex.tools.catalog.subprocess.run") as mock_subprocess, + patch("cpex.tools.catalog.tempfile.mkdtemp", return_value=str(tmp_path)), + patch("cpex.tools.catalog.find_package_path", return_value=plugin_dir), + patch.object(catalog, "_persist_manifest"), + patch.object(catalog, "_find_and_load_versions_json", return_value=plugin_dir), + patch.object(catalog, "update_plugin_version_registry"), + patch("shutil.rmtree"), + ): + mock_subprocess.return_value = Mock(returncode=0) + + url = "test_plugin @ git+https://github.com/example/test_plugin.git" + manifest, plugin_path = catalog.install_from_git(url) + + assert manifest.name == "test_plugin" + + +# --------------------------------------------------------------------------- +# _extract_package_archive — path traversal guards +# --------------------------------------------------------------------------- + +class TestExtractPackageArchivePathTraversal: + """Verify that _extract_package_archive rejects archives with unsafe member paths.""" + + @pytest.fixture() + def catalog(self): + with patch("cpex.tools.catalog.PluginCatalog.__init__", return_value=None): + c = PluginCatalog.__new__(PluginCatalog) + c.python_executable = sys.executable + return c + + # --- tar.gz ----------------------------------------------------------- + + def test_tar_traversal_rejected(self, catalog, tmp_path): + """A tar member whose path escapes extract_dir raises and writes nothing.""" + import io + archive = tmp_path / "evil.tar.gz" + with tarfile.open(archive, "w:gz") as tf: + data = b"pwned" + info = tarfile.TarInfo(name="../evil.txt") + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + + extract_dir = tmp_path / "out" + extract_dir.mkdir() + + with pytest.raises(Exception): + catalog._extract_package_archive(archive, extract_dir) + + assert not (tmp_path / "evil.txt").exists() + + def test_tar_benign_succeeds(self, catalog, tmp_path): + """A well-formed tar.gz extracts correctly.""" + import io + archive = tmp_path / "good.tar.gz" + with tarfile.open(archive, "w:gz") as tf: + data = b"hello" + info = tarfile.TarInfo(name="subdir/hello.txt") + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + + extract_dir = tmp_path / "out" + extract_dir.mkdir() + catalog._extract_package_archive(archive, extract_dir) + + assert (extract_dir / "subdir" / "hello.txt").read_bytes() == b"hello" + + # --- zip / .whl ------------------------------------------------------- + + def test_zip_traversal_rejected(self, catalog, tmp_path): + """A zip member whose path escapes extract_dir raises and writes nothing.""" + archive = tmp_path / "evil.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("../evil.txt", "pwned") + + extract_dir = tmp_path / "out" + extract_dir.mkdir() + + with pytest.raises(RuntimeError, match="Unsafe path"): + catalog._extract_package_archive(archive, extract_dir) + + assert not (tmp_path / "evil.txt").exists() + + def test_whl_traversal_rejected(self, catalog, tmp_path): + """A .whl (zip) member whose path escapes extract_dir raises and writes nothing.""" + archive = tmp_path / "evil-1.0.0-py3-none-any.whl" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("../evil.txt", "pwned") + + extract_dir = tmp_path / "out" + extract_dir.mkdir() + + with pytest.raises(RuntimeError, match="Unsafe path"): + catalog._extract_package_archive(archive, extract_dir) + + assert not (tmp_path / "evil.txt").exists() + + def test_zip_absolute_path_rejected(self, catalog, tmp_path): + """A zip member with an absolute path raises before any extraction.""" + archive = tmp_path / "absolute.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("/etc/passwd", "root:x:0:0") + + extract_dir = tmp_path / "out" + extract_dir.mkdir() + + with pytest.raises(RuntimeError, match="Unsafe path"): + catalog._extract_package_archive(archive, extract_dir) + + def test_zip_benign_succeeds(self, catalog, tmp_path): + """A well-formed zip extracts correctly.""" + archive = tmp_path / "good.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("pkg/hello.txt", "world") + + extract_dir = tmp_path / "out" + extract_dir.mkdir() + catalog._extract_package_archive(archive, extract_dir) + + assert (extract_dir / "pkg" / "hello.txt").read_text() == "world" + +class TestPluginCatalogUpdatePluginVersionRegistry: + """Tests for PluginCatalog.update_plugin_version_registry method.""" + + def test_update_plugin_version_registry_creates_new_file(self, tmp_path, mock_github_env): + """Test creating a new versions.json file when none exists.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + manifest = create_test_manifest(name="test_plugin", version="1.0.0") + relpath = Path("plugins/test_plugin") + + catalog.update_plugin_version_registry(manifest, relpath) + + # Verify file was created + versions_file = tmp_path / "catalog" / "test_plugin" / "versions.json" + assert versions_file.exists() + + # Verify content + with versions_file.open("r") as f: + data = json.load(f) + + assert len(data["versions"]) == 1 + assert data["versions"][0]["version"] == "1.0.0" + assert data["versions"][0]["manifest_file"] == str(relpath) + assert data["latest"]["version"] == "1.0.0" + + def test_update_plugin_version_registry_adds_new_version(self, tmp_path, mock_github_env): + """Test adding a new version to existing registry.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Create initial version + manifest1 = create_test_manifest(name="test_plugin", version="1.0.0") + relpath1 = Path("plugins/test_plugin") + catalog.update_plugin_version_registry(manifest1, relpath1) + + # Add new version + manifest2 = create_test_manifest(name="test_plugin", version="2.0.0") + relpath2 = Path("plugins/test_plugin") + catalog.update_plugin_version_registry(manifest2, relpath2) + + # Verify both versions exist + versions_file = tmp_path / "catalog" / "test_plugin" / "versions.json" + with versions_file.open("r") as f: + data = json.load(f) + + assert len(data["versions"]) == 2 + versions = [v["version"] for v in data["versions"]] + assert "1.0.0" in versions + assert "2.0.0" in versions + assert data["latest"]["version"] == "2.0.0" + + def test_update_plugin_version_registry_handles_duplicate_version(self, tmp_path, mock_github_env): + """Test that duplicate versions are not added.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + manifest = create_test_manifest(name="test_plugin", version="1.0.0") + relpath = Path("plugins/test_plugin") + + # Add same version twice + catalog.update_plugin_version_registry(manifest, relpath) + catalog.update_plugin_version_registry(manifest, relpath) + + # Verify only one version exists + versions_file = tmp_path / "catalog" / "test_plugin" / "versions.json" + with versions_file.open("r") as f: + data = json.load(f) + + assert len(data["versions"]) == 1 + assert data["versions"][0]["version"] == "1.0.0" + + def test_update_plugin_version_registry_updates_latest_correctly(self, tmp_path, mock_github_env): + """Test that latest version is updated correctly when adding versions out of order.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Add version 2.0.0 first + manifest2 = create_test_manifest(name="test_plugin", version="2.0.0") + catalog.update_plugin_version_registry(manifest2, Path("plugins/test_plugin")) + + # Add version 1.0.0 + manifest1 = create_test_manifest(name="test_plugin", version="1.0.0") + catalog.update_plugin_version_registry(manifest1, Path("plugins/test_plugin")) + + # Add version 3.0.0 + manifest3 = create_test_manifest(name="test_plugin", version="3.0.0") + catalog.update_plugin_version_registry(manifest3, Path("plugins/test_plugin")) + + # Verify latest is 3.0.0 + versions_file = tmp_path / "catalog" / "test_plugin" / "versions.json" + with versions_file.open("r") as f: + data = json.load(f) + + assert data["latest"]["version"] == "3.0.0" + assert len(data["versions"]) == 3 + + def test_update_plugin_version_registry_with_prerelease_versions(self, tmp_path, mock_github_env): + """Test handling of pre-release versions.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Add stable version + manifest1 = create_test_manifest(name="test_plugin", version="1.0.0") + catalog.update_plugin_version_registry(manifest1, Path("plugins/test_plugin")) + + # Add pre-release version + manifest2 = create_test_manifest(name="test_plugin", version="2.0.0rc1") + catalog.update_plugin_version_registry(manifest2, Path("plugins/test_plugin")) + + # Verify latest is the rc version (higher version number) + versions_file = tmp_path / "catalog" / "test_plugin" / "versions.json" + with versions_file.open("r") as f: + data = json.load(f) + + assert len(data["versions"]) == 2 + assert data["latest"]["version"] == "2.0.0rc1" + + def test_update_plugin_version_registry_with_dev_versions(self, tmp_path, mock_github_env): + """Test handling of development versions.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Add dev version + manifest1 = create_test_manifest(name="test_plugin", version="1.0.0.dev1") + catalog.update_plugin_version_registry(manifest1, Path("plugins/test_plugin")) + + # Add stable version + manifest2 = create_test_manifest(name="test_plugin", version="1.0.0") + catalog.update_plugin_version_registry(manifest2, Path("plugins/test_plugin")) + + # Verify latest is stable version + versions_file = tmp_path / "catalog" / "test_plugin" / "versions.json" + with versions_file.open("r") as f: + data = json.load(f) + + assert len(data["versions"]) == 2 + assert data["latest"]["version"] == "1.0.0" + + def test_update_plugin_version_registry_preserves_existing_data(self, tmp_path, mock_github_env): + """Test that existing version data is preserved when adding new versions.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Manually create a versions.json with additional metadata + versions_dir = tmp_path / "catalog" / "test_plugin" + versions_dir.mkdir(parents=True) + versions_file = versions_dir / "versions.json" + + initial_data = { + "latest": { + "version": "1.0.0", + "released": "2024-01-01T00:00:00Z", + "manifest_file": "plugins/test_plugin", + "deprecated": False, + "breaking_changes": False, + "changelog": "Initial release" + }, + "versions": [ + { + "version": "1.0.0", + "released": "2024-01-01T00:00:00Z", + "manifest_file": "plugins/test_plugin", + "deprecated": False, + "breaking_changes": False, + "changelog": "Initial release" + } + ] + } + versions_file.write_text(json.dumps(initial_data, indent=2)) + + # Add new version + manifest2 = create_test_manifest(name="test_plugin", version="2.0.0") + catalog.update_plugin_version_registry(manifest2, Path("plugins/test_plugin")) + + # Verify old version data is preserved + with versions_file.open("r") as f: + data = json.load(f) + + assert len(data["versions"]) == 2 + old_version = next(v for v in data["versions"] if v["version"] == "1.0.0") + assert old_version["changelog"] == "Initial release" + assert old_version["released"] == "2024-01-01T00:00:00Z" + + def test_update_plugin_version_registry_with_complex_version_ordering(self, tmp_path, mock_github_env): + """Test version ordering with complex version strings.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Add versions in random order + versions = ["1.0.0", "2.0.0rc1", "1.5.0", "2.0.0", "1.0.1", "2.1.0a1"] + for version in versions: + manifest = create_test_manifest(name="test_plugin", version=version) + catalog.update_plugin_version_registry(manifest, Path("plugins/test_plugin")) + + # Verify latest is 2.1.0a1 (highest version) + versions_file = tmp_path / "catalog" / "test_plugin" / "versions.json" + with versions_file.open("r") as f: + data = json.load(f) + + assert len(data["versions"]) == 6 + assert data["latest"]["version"] == "2.1.0a1" + + def test_update_plugin_version_registry_creates_parent_directories(self, tmp_path, mock_github_env): + """Test that parent directories are created if they don't exist.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Don't create the directory beforehand + manifest = create_test_manifest(name="test_plugin", version="1.0.0") + catalog.update_plugin_version_registry(manifest, Path("plugins/test_plugin")) + + # Verify directory and file were created + versions_file = tmp_path / "catalog" / "test_plugin" / "versions.json" + assert versions_file.exists() + assert versions_file.parent.exists() + + def test_update_plugin_version_registry_with_invalid_existing_json(self, tmp_path, mock_github_env): + """Test handling of corrupted existing versions.json file.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Create corrupted versions.json + versions_dir = tmp_path / "catalog" / "test_plugin" + versions_dir.mkdir(parents=True) + versions_file = versions_dir / "versions.json" + versions_file.write_text("invalid json content") + + # Attempt to update should raise an error + manifest = create_test_manifest(name="test_plugin", version="1.0.0") + with pytest.raises(json.JSONDecodeError): + catalog.update_plugin_version_registry(manifest, Path("plugins/test_plugin")) + + def test_update_plugin_version_registry_timestamp_format(self, tmp_path, mock_github_env): + """Test that timestamp is in correct ISO format with Z suffix.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + manifest = create_test_manifest(name="test_plugin", version="1.0.0") + catalog.update_plugin_version_registry(manifest, Path("plugins/test_plugin")) + + versions_file = tmp_path / "catalog" / "test_plugin" / "versions.json" + with versions_file.open("r") as f: + data = json.load(f) + + released = data["versions"][0]["released"] + # Verify format: ends with Z and contains T + assert released.endswith("Z") + assert "T" in released + # Verify it's a valid ISO format + from datetime import datetime + datetime.fromisoformat(released.replace("Z", "+00:00")) + + def test_update_plugin_version_registry_with_epoch_versions(self, tmp_path, mock_github_env): + """Test handling of versions with epochs.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + # Add version with epoch + manifest1 = create_test_manifest(name="test_plugin", version="1!1.0.0") + catalog.update_plugin_version_registry(manifest1, Path("plugins/test_plugin")) + + # Add version without epoch (should be lower) + manifest2 = create_test_manifest(name="test_plugin", version="2.0.0") + catalog.update_plugin_version_registry(manifest2, Path("plugins/test_plugin")) + + # Verify epoch version is latest + versions_file = tmp_path / "catalog" / "test_plugin" / "versions.json" + with versions_file.open("r") as f: + data = json.load(f) + + assert data["latest"]["version"] == "1!1.0.0" + + def test_update_plugin_version_registry_relpath_stored_correctly(self, tmp_path, mock_github_env): + """Test that relative path is stored correctly in manifest_file.""" + catalog = PluginCatalog() + catalog.catalog_folder = str(tmp_path / "catalog") + + manifest = create_test_manifest(name="test_plugin", version="1.0.0") + relpath = Path("custom/path/to/plugin") + + catalog.update_plugin_version_registry(manifest, relpath) + + versions_file = tmp_path / "catalog" / "test_plugin" / "versions.json" + with versions_file.open("r") as f: + data = json.load(f) + + + +class TestPluginCatalogVerMethod: + """Tests for PluginCatalog._ver method.""" + + def test_ver_valid_simple_version(self, mock_github_env): + """Test parsing a simple valid version string.""" + catalog = PluginCatalog() + version = catalog._ver("1.0.0") + assert str(version) == "1.0.0" + + def test_ver_valid_complex_version(self, mock_github_env): + """Test parsing a complex valid version string.""" + catalog = PluginCatalog() + version = catalog._ver("1.2.3") + assert str(version) == "1.2.3" + + def test_ver_valid_version_with_epoch(self, mock_github_env): + """Test parsing a version with epoch.""" + catalog = PluginCatalog() + version = catalog._ver("1!2.0.0") + assert str(version) == "1!2.0.0" + + def test_ver_valid_prerelease_version(self, mock_github_env): + """Test parsing a pre-release version.""" + catalog = PluginCatalog() + version = catalog._ver("1.0.0rc1") + assert str(version) == "1.0.0rc1" + + def test_ver_valid_post_release_version(self, mock_github_env): + """Test parsing a post-release version.""" + catalog = PluginCatalog() + version = catalog._ver("1.0.0.post1") + assert str(version) == "1.0.0.post1" + + def test_ver_valid_dev_version(self, mock_github_env): + """Test parsing a development version.""" + catalog = PluginCatalog() + version = catalog._ver("1.0.0.dev1") + assert str(version) == "1.0.0.dev1" + + def test_ver_invalid_version_returns_zero(self, mock_github_env): + """Test that invalid version strings return Version('0').""" + catalog = PluginCatalog() + version = catalog._ver("invalid.version") + assert str(version) == "0" + + def test_ver_invalid_version_with_special_chars(self, mock_github_env): + """Test that version with special characters returns Version('0').""" + catalog = PluginCatalog() + version = catalog._ver("1.0.0@beta") + assert str(version) == "0" + + def test_ver_empty_string_returns_zero(self, mock_github_env): + """Test that empty string returns Version('0').""" + catalog = PluginCatalog() + version = catalog._ver("") + assert str(version) == "0" + + def test_ver_invalid_semantic_version(self, mock_github_env): + """Test that version with 'v' prefix is actually valid (packaging strips it).""" + catalog = PluginCatalog() + version = catalog._ver("v1.0") + # packaging.version.Version actually accepts and strips 'v' prefix + assert str(version) == "1.0" + + def test_ver_version_with_local_identifier(self, mock_github_env): + """Test parsing a version with local identifier.""" + catalog = PluginCatalog() + version = catalog._ver("1.0.0+local.build") + assert str(version) == "1.0.0+local.build" + + def test_ver_logs_debug_on_invalid_version(self, mock_github_env, caplog): + """Test that debug log is created for invalid versions.""" + import logging + catalog = PluginCatalog() + + with caplog.at_level(logging.DEBUG): + catalog._ver("not-a-version") + + assert "Could not parse version" in caplog.text + assert "treating as lowest" in caplog.text + + def test_ver_whitespace_version(self, mock_github_env): + """Test that whitespace-only version returns Version('0').""" + catalog = PluginCatalog() + version = catalog._ver(" ") + assert str(version) == "0" + + def test_ver_version_with_v_prefix(self, mock_github_env): + """Test that version with 'v' prefix is valid (packaging strips it).""" + catalog = PluginCatalog() + version = catalog._ver("v1.0.0") + # packaging.version.Version actually accepts and strips 'v' prefix + assert str(version) == "1.0.0" + + def test_ver_numeric_only_version(self, mock_github_env): + """Test parsing a single numeric version.""" + catalog = PluginCatalog() + version = catalog._ver("1") + assert str(version) == "1" + + def test_ver_comparison_works_correctly(self, mock_github_env): + """Test that version comparison works as expected.""" + catalog = PluginCatalog() + v1 = catalog._ver("1.0.0") + v2 = catalog._ver("2.0.0") + v_invalid = catalog._ver("invalid") + + assert v1 < v2 + assert v_invalid < v1 + assert v_invalid == catalog._ver("0") + + def test_ver_prerelease_comparison(self, mock_github_env): + """Test that pre-release versions compare correctly.""" + catalog = PluginCatalog() + v_stable = catalog._ver("1.0.0") + v_rc = catalog._ver("1.0.0rc1") + v_dev = catalog._ver("1.0.0.dev1") + + assert v_dev < v_rc < v_stable + + def test_ver_epoch_comparison(self, mock_github_env): + """Test that epoch versions compare correctly.""" + catalog = PluginCatalog() + v_no_epoch = catalog._ver("2.0.0") + v_with_epoch = catalog._ver("1!1.0.0") + + # Epoch takes precedence + assert v_with_epoch > v_no_epoch diff --git a/tests/unit/cpex/tools/test_cli.py b/tests/unit/cpex/tools/test_cli.py index b9cef5fe..9cc418b0 100644 --- a/tests/unit/cpex/tools/test_cli.py +++ b/tests/unit/cpex/tools/test_cli.py @@ -8,9 +8,12 @@ """ # Standard +import json +import tempfile from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch, mock_open +import pytest # We use typer's CliRunner for testing typer apps import click from typer.testing import CliRunner @@ -26,7 +29,18 @@ command_exists, git_user_email, git_user_name, + list_registered_plugins, + install_from_manifest, + install, + search, + info, + instance_name_is_unique, + update_plugins_config_yaml, + remove_from_plugins_config_yaml, + uninstall, ) +from cpex.tools.plugin_registry import PluginRegistry +from cpex.framework.models import PluginManifest, Monorepo, Config, PluginConfig, PluginMode, PyPiRepo runner = CliRunner() @@ -34,6 +48,37 @@ _CC_PATCH_TARGET = "cookiecutter.main.cookiecutter" +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def temp_registry_dir(tmp_path, monkeypatch): + """Fixture to ensure all tests use a temporary directory for the plugin registry.""" + registry_dir = tmp_path / "test_registry" + registry_dir.mkdir(exist_ok=True) + monkeypatch.setenv("PLUGIN_REGISTRY_FILE", str(registry_dir)) + return registry_dir + + +# Helper function to create test manifests +def create_test_manifest(**kwargs): + """Create a test PluginManifest with default values.""" + defaults = { + "name": "test_plugin", + "version": "1.0.0", + "kind": "native", + "description": "Test plugin description", + "author": "Test Author", + "tags": ["test"], + "available_hooks": ["tools"], + "default_config": {}, + "monorepo": Monorepo(package_source="https://example.com/repo#subdirectory=plugin", repo_url="https://example.com/repo", package_folder="plugin"), + } + defaults.update(kwargs) + return PluginManifest(**defaults) + + # --------------------------------------------------------------------------- # Utility function tests # --------------------------------------------------------------------------- @@ -350,10 +395,12 @@ def test_warns_when_no_local_and_no_git(self): patch("cpex.tools.cli.command_exists", return_value=False), patch(_CC_PATCH_TARGET) as mock_cc, patch("cpex.tools.cli.logger") as mock_logger, + patch("cpex.tools.cli.console") as mock_console, ): - runner.invoke(app, ["bootstrap", "-d", "/tmp/test_nogit", "--no_input"]) + result = runner.invoke(app, ["bootstrap", "-d", "/tmp/test_nogit", "--no_input"]) + assert result.exit_code == 4 # EXIT_OPERATION_FAILED mock_cc.assert_not_called() - mock_logger.warning.assert_called_once() + mock_logger.error.assert_called_once() class TestBootstrapErrorHandling: @@ -363,9 +410,10 @@ def test_logs_exception_on_cookiecutter_error(self): with ( patch(_CC_PATCH_TARGET, side_effect=RuntimeError("template error")), patch("cpex.tools.cli.logger") as mock_logger, + patch("cpex.tools.cli.console") as mock_console, ): result = runner.invoke(app, ["bootstrap", "-d", "/tmp/test_err", "--no_input"]) - assert result.exit_code == 0 # error is caught and logged + assert result.exit_code == 4 # EXIT_OPERATION_FAILED mock_logger.exception.assert_called_once() @@ -412,3 +460,1565 @@ def test_main_invokes_app(self): main() mock_app.assert_called_once() + + + +# --------------------------------------------------------------------------- +# Plugin management function tests +# --------------------------------------------------------------------------- + + +class TestListFunction: + """Tests for the list() function.""" + + def test_list_with_no_registry_file(self, temp_registry_dir): + """Test list when registry file doesn't exist.""" + with patch("cpex.tools.cli.logger") as mock_logger: + list_registered_plugins("all") + mock_logger.info.assert_called_with("No plugins registered.") + + def test_list_with_existing_plugins(self, temp_registry_dir): + """Test list with existing plugins in registry.""" + registry_file = temp_registry_dir / "installed-plugins.json" + registry_data = { + "plugins": [ + { + "name": "test_plugin", + "kind": "native", + "version": "1.0.0", + "installation_type": "monorepo", + "installation_path": "/path/to/test_plugin", + "installed_at": "2024-01-01T00:00:00.000000Z", + "installed_by": "test_user", + }, + { + "name": "another_plugin", + "kind": "external", + "version": "2.0.0", + "installation_type": "pypi", + "installation_path": "/path/to/another_plugin", + "installed_at": "2024-01-02T00:00:00.000000Z", + "installed_by": "test_user", + }, + ] + } + registry_file.write_text(json.dumps(registry_data)) + + with patch("cpex.tools.cli.console") as mock_console: + list_registered_plugins("all") + assert mock_console.print.call_count == 2 + + +class TestUpdatePluginRegistry: + """Tests for update_plugin_registry() function.""" + + def test_creates_new_registry_if_not_exists(self, temp_registry_dir): + """Test creating a new registry when file doesn't exist.""" + manifest = create_test_manifest() + + mock_catalog = Mock() + + with ( + patch("cpex.tools.cli.git_user_name", return_value="test_user"), + patch("cpex.tools.plugin_registry.find_package_path", return_value=Path("/fake/path/to/plugin")), + ): + plugin_registry = PluginRegistry() + plugin_registry.update(manifest, "monorepo", mock_catalog, "test_user") + registry_file = temp_registry_dir / "installed-plugins.json" + assert registry_file.exists() + + def test_updates_existing_registry(self, temp_registry_dir): + """Test updating an existing registry.""" + registry_file = temp_registry_dir / "installed-plugins.json" + registry_data = {"plugins": []} + registry_file.write_text(json.dumps(registry_data)) + + manifest = create_test_manifest( + name="new_plugin", + version="2.0.0", + kind="external", + monorepo=Monorepo(package_source="https://example.com/repo#subdirectory=new_plugin", repo_url="https://example.com/repo", package_folder="new_plugin"), + ) + + mock_catalog = Mock() + + with ( + patch("cpex.tools.cli.git_user_name", return_value="test_user"), + patch("cpex.tools.plugin_registry.find_package_path", return_value=Path("/fake/path/to/new_plugin")), + ): + plugin_registry = PluginRegistry() + plugin_registry.update(manifest, "monorepo", mock_catalog, "test_user") + updated_data = json.loads(registry_file.read_text()) + assert len(updated_data["plugins"]) == 1 + assert updated_data["plugins"][0]["name"] == "new_plugin" + + +class TestPluginRegistryCoverage: + """Additional tests to increase coverage for PluginRegistry.""" + + def test_update_with_pypi_installation(self, temp_registry_dir): + """Test registry update for the PyPI installation path.""" + manifest = create_test_manifest( + name="pypi_plugin", + monorepo=None, + package_info=PyPiRepo(pypi_package="pypi-plugin", version_constraint=None), + ) + + mock_catalog = Mock() + + with patch("cpex.tools.plugin_registry.find_package_path", return_value=Path("/fake/path/to/pypi_plugin")): + plugin_registry = PluginRegistry() + plugin_registry.update(manifest, "pypi", mock_catalog, "test_user") + + registry_file = temp_registry_dir / "installed-plugins.json" + updated_data = json.loads(registry_file.read_text()) + assert len(updated_data["plugins"]) == 1 + assert updated_data["plugins"][0]["name"] == "pypi_plugin" + assert updated_data["plugins"][0]["package_source"] == "pypi-plugin" + assert updated_data["plugins"][0]["installation_type"] == "pypi" + + def test_update_raises_for_monorepo_without_monorepo_metadata(self, temp_registry_dir): + """Test monorepo update fails when manifest.monorepo is missing.""" + manifest = create_test_manifest(monorepo=None) + + plugin_registry = PluginRegistry() + + with pytest.raises(RuntimeError, match="PluginManifest.monorepo can not be None."): + plugin_registry.update(manifest, "monorepo", Mock(), "test_user") + + def test_update_raises_for_pypi_without_package_info(self, temp_registry_dir): + """Test PyPI update fails when manifest.package_info is missing.""" + manifest = create_test_manifest(monorepo=None) + + plugin_registry = PluginRegistry() + + with pytest.raises(RuntimeError, match="PluginManifest.package_info can not be None."): + plugin_registry.update(manifest, "pypi", Mock(), "test_user") + + def test_update_raises_for_invalid_installation_type(self, temp_registry_dir): + """Test invalid installation types are rejected.""" + manifest = create_test_manifest() + + plugin_registry = PluginRegistry() + + with pytest.raises(ValueError, match="Invalid installation type: invalid"): + plugin_registry.update(manifest, "invalid", Mock(), "test_user") + + def test_update_with_local_installation_and_explicit_plugin_path(self, temp_registry_dir): + """Test registry update for local installation with explicit plugin_path.""" + manifest = create_test_manifest(monorepo=None, package_info=None) + manifest.local = "/tmp/local-plugin-source" + explicit_path = temp_registry_dir / "installed" / "local_plugin" + explicit_path.mkdir(parents=True) + + plugin_registry = PluginRegistry() + plugin_registry.update(manifest, "local", Mock(), "test_user", plugin_path=explicit_path, editable=True) + + registry_file = temp_registry_dir / "installed-plugins.json" + updated_data = json.loads(registry_file.read_text()) + assert len(updated_data["plugins"]) == 1 + assert updated_data["plugins"][0]["name"] == manifest.name + assert updated_data["plugins"][0]["package_source"] == "/tmp/local-plugin-source" + assert updated_data["plugins"][0]["installation_type"] == "local" + assert updated_data["plugins"][0]["installation_path"] == str(explicit_path.resolve()) + assert updated_data["plugins"][0]["editable"] is True + + def test_update_with_local_installation_raises_without_local_metadata(self, temp_registry_dir): + """Test local update fails when manifest.local is missing.""" + manifest = create_test_manifest(monorepo=None, package_info=None) + manifest.local = None + + plugin_registry = PluginRegistry() + + with pytest.raises(RuntimeError, match="PluginManifest local path can not be None."): + plugin_registry.update(manifest, "local", Mock(), "test_user") + + def test_update_uses_find_package_path_when_plugin_path_not_provided(self, temp_registry_dir): + """Test registry update falls back to find_package_path when plugin_path is omitted.""" + manifest = create_test_manifest() + + with patch("cpex.tools.plugin_registry.find_package_path", return_value=Path("/fake/path/from/find_package_path")): + plugin_registry = PluginRegistry() + plugin_registry.update(manifest, "monorepo", Mock(), "test_user") + + registry_file = temp_registry_dir / "installed-plugins.json" + updated_data = json.loads(registry_file.read_text()) + assert updated_data["plugins"][0]["installation_path"] == str(Path("/fake/path/from/find_package_path").resolve()) + + def test_has_returns_true_when_plugin_present(self, temp_registry_dir): + """Test has() returns True for an installed plugin.""" + registry_file = temp_registry_dir / "installed-plugins.json" + registry_data = { + "plugins": [ + { + "name": "test_plugin", + "version": "1.0.0", + "kind": "native", + "installation_type": "monorepo", + "installation_path": "/path/to/test_plugin", + "installed_at": "2024-01-01T00:00:00.000000Z", + "installed_by": "test_user", + "package_source": "https://example.com/repo/plugin", + "editable": False, + } + ] + } + registry_file.write_text(json.dumps(registry_data)) + + plugin_registry = PluginRegistry() + + assert plugin_registry.has("test_plugin") is True + + def test_has_returns_false_when_plugin_absent(self, temp_registry_dir): + """Test has() returns False for a missing plugin.""" + registry_file = temp_registry_dir / "installed-plugins.json" + registry_file.write_text(json.dumps({"plugins": []})) + + plugin_registry = PluginRegistry() + + assert plugin_registry.has("missing_plugin") is False + + +class TestInstanceNameIsUnique: + """Tests for instance_name_is_unique() function.""" + + def test_returns_true_for_unique_name(self): + """Test that unique names return True.""" + existing_plugin = PluginConfig( + name="existing_plugin", + kind="test.plugin", + mode=PluginMode.SEQUENTIAL, + priority=100 + ) + + config = Config(plugins=[existing_plugin]) + assert instance_name_is_unique(config, "new_plugin") is True + + def test_returns_false_for_duplicate_name(self): + """Test that duplicate names return False.""" + existing_plugin = PluginConfig( + name="existing_plugin", + kind="test.plugin", + mode=PluginMode.SEQUENTIAL, + priority=100 + ) + + config = Config(plugins=[existing_plugin]) + assert instance_name_is_unique(config, "existing_plugin") is False + + def test_returns_true_for_empty_config(self): + """Test that any name is unique in empty config.""" + config = Config(plugins=[]) + assert instance_name_is_unique(config, "any_plugin") is True + + +class TestUpdatePluginsConfigYaml: + """Tests for update_plugins_config_yaml() function.""" + + def test_updates_config_with_unique_name(self, tmp_path): + """Test updating config with a unique plugin name.""" + manifest = create_test_manifest() + config_file = tmp_path / "config.yaml" + + mock_config = Config(plugins=[]) + + with ( + patch("cpex.tools.cli.ConfigLoader.load_config", return_value=mock_config), + patch("cpex.tools.cli.ConfigSaver.save_config") as mock_save, + patch.object(type(manifest), "suggest_instance_name", return_value="test_plugin"), + patch.object(type(manifest), "create_instance_config", return_value=PluginConfig( + name="test_plugin", + kind="test.plugin", + mode=PluginMode.SEQUENTIAL, + priority=100 + )), + ): + update_plugins_config_yaml(manifest) + mock_save.assert_called_once() + # Verify a plugin was added to the config + assert mock_config.plugins is not None + assert len(mock_config.plugins) == 1 + + def test_generates_unique_name_when_duplicate(self, tmp_path): + """Test that duplicate names get suffixed with counter.""" + manifest = create_test_manifest(name="test_plugin") + config_file = tmp_path / "config.yaml" + + # Create existing plugin with same suggested name + existing_plugin = PluginConfig( + name="test_plugin", + kind="test.plugin", + mode=PluginMode.SEQUENTIAL, + priority=100 + ) + mock_config = Config(plugins=[existing_plugin]) + + with ( + patch("cpex.tools.cli.ConfigLoader.load_config", return_value=mock_config), + patch("cpex.tools.cli.ConfigSaver.save_config") as mock_save, + patch.object(type(manifest), "suggest_instance_name", return_value="test_plugin"), + patch.object(type(manifest), "create_instance_config", return_value=PluginConfig( + name="test_plugin_1", + kind="test.plugin", + mode=PluginMode.SEQUENTIAL, + priority=100 + )), + ): + update_plugins_config_yaml(manifest) + mock_save.assert_called_once() + # Verify a new plugin was added + assert mock_config.plugins is not None + assert len(mock_config.plugins) == 2 + # The new plugin should have a different name (with suffix) + assert mock_config.plugins[1].name != "test_plugin" + + +class TestInstallFromManifest: + """Tests for install_from_manifest() function.""" + + def test_install_from_monorepo(self, temp_registry_dir): + """Test installing from monorepo.""" + manifest = create_test_manifest() + + mock_catalog = Mock() + mock_catalog.install_folder_via_pip = Mock() + + with ( + patch("cpex.tools.cli.git_user_name", return_value="test_user"), + patch("cpex.tools.cli.update_plugins_config_yaml"), + patch("cpex.tools.plugin_registry.find_package_path", return_value=Path("/fake/path/to/plugin")), + ): + install_from_manifest(manifest, "monorepo", mock_catalog) + mock_catalog.install_folder_via_pip.assert_called_once_with(manifest) + + +class TestInstallFunction: + """Tests for install() function.""" + + def test_install_git_implementation(self): + """Test that git installation works with install_from_git.""" + mock_catalog = Mock() + test_manifest = create_test_manifest(name="test_plugin", kind="native") + mock_catalog.install_from_git = Mock(return_value=(test_manifest, Path("/path/to/plugin"))) + + with ( + patch("cpex.tools.cli._finalize_installation") as mock_finalize, + patch("cpex.tools.cli.console") as mock_console, + patch("cpex.tools.cli.update_plugins_config_yaml") as mock_update_config, + ): + mock_status = Mock() + mock_status.__enter__ = Mock(return_value=mock_status) + mock_status.__exit__ = Mock(return_value=False) + mock_console.status = Mock(return_value=mock_status) + + install("test_plugin @ git+https://github.com/example/test_plugin.git", "git", mock_catalog) + + # Verify install_from_git was called + mock_catalog.install_from_git.assert_called_once() + # update_plugins_config_yaml is called inside _finalize_installation (mocked), + # not directly from _install_from_git — verify no duplicate direct call. + mock_update_config.assert_not_called() + mock_finalize.assert_called_once() + + def test_install_monorepo_no_plugins_found(self): + """Test monorepo install when no plugins found.""" + mock_catalog = Mock() + mock_catalog.search = Mock(return_value=None) + + with patch("cpex.tools.cli.console") as mock_logger: + install("test_plugin", "monorepo", mock_catalog) + mock_logger.print.assert_called_with("No matching plugins found.") + + def test_install_monorepo_with_available_plugins(self, temp_registry_dir): + """Test monorepo install with available plugins.""" + manifest = create_test_manifest() + + mock_catalog = Mock() + mock_catalog.search = Mock(return_value=[manifest]) + mock_catalog.install_folder_via_pip = Mock() + + with ( + patch("cpex.tools.cli.inquirer.prompt", return_value={"plugins": 0}), + patch("cpex.tools.cli.Console"), + patch("cpex.tools.cli.git_user_name", return_value="test_user"), + patch("cpex.tools.cli.update_plugins_config_yaml"), + patch("cpex.tools.plugin_registry.find_package_path", return_value=Path("/fake/path/to/plugin")), + ): + install("test_plugin", "monorepo", mock_catalog) + mock_catalog.install_folder_via_pip.assert_called_once() + + def test_install_requires_type_parameter(self): + """Test that install raises typer.Exit for unsupported type.""" + mock_catalog = Mock() + with ( + patch("cpex.tools.cli.console") as mock_console, + pytest.raises(click.exceptions.Exit) as exc_info, + ): + install("source", "", mock_catalog) + assert exc_info.value.exit_code == 2 # EXIT_INVALID_ARGS + + +class TestSearchFunction: + """Tests for search() function.""" + + def test_search_with_results(self): + """Test search with matching plugins.""" + manifest = create_test_manifest() + + mock_catalog = Mock() + mock_catalog.search = Mock(return_value=[manifest]) + + with patch("cpex.tools.cli.console") as mock_console: + mock_status = Mock() + mock_status.__enter__ = Mock(return_value=mock_status) + mock_status.__exit__ = Mock(return_value=False) + mock_console.status = Mock(return_value=mock_status) + search("test", mock_catalog) + mock_console.log.assert_called() + + def test_search_with_no_results(self): + """Test search with no matching plugins.""" + mock_catalog = Mock() + mock_catalog.search = Mock(return_value=None) + + with patch("cpex.tools.cli.console") as mock_console: + mock_status = Mock() + mock_status.__enter__ = Mock(return_value=mock_status) + mock_status.__exit__ = Mock(return_value=False) + mock_console.status = Mock(return_value=mock_status) + search("nonexistent", mock_catalog) + mock_console.log.assert_called_with("No plugins found.") + + +class TestInfoFunction: + """Tests for info() function.""" + + def test_info_with_no_registry(self, temp_registry_dir): + """Test info when registry doesn't exist.""" + with patch("cpex.tools.cli.console") as mock_console: + info(None) + mock_console.print.assert_called_with("No plugins found") + + def test_info_list_all_plugins(self, temp_registry_dir): + """Test info listing all plugins.""" + registry_file = temp_registry_dir / "installed-plugins.json" + registry_data = { + "plugins": [ + { + "name": "test_plugin", + "version": "1.0.0", + "kind": "native", + "installation_type": "monorepo", + "installation_path": "plugins", + "installed_at": "2024-01-01T00:00:00Z", + "installed_by": "test_user", + "package_source": "https://example.com/repo/plugin", + "editable": False, + } + ] + } + registry_file.write_text(json.dumps(registry_data)) + + with patch("cpex.tools.cli.console") as mock_console: + info(None) + mock_console.print_json.assert_called_once() + + def test_info_search_specific_plugin(self, temp_registry_dir): + """Test info searching for specific plugin.""" + registry_file = temp_registry_dir / "installed-plugins.json" + registry_data = { + "plugins": [ + { + "name": "test_plugin", + "version": "1.0.0", + "kind": "native", + "installation_type": "monorepo", + "installation_path": "plugins", + "installed_at": "2024-01-01T00:00:00Z", + "installed_by": "test_user", + "package_source": "https://example.com/repo/plugin", + "editable": False, + }, + { + "name": "another_plugin", + "version": "2.0.0", + "kind": "external", + "installation_type": "pypi", + "installation_path": "plugins", + "installed_at": "2024-01-01T00:00:00Z", + "installed_by": "test_user", + "package_source": "https://pypi.org/project/another_plugin", + "editable": False, + }, + ] + } + registry_file.write_text(json.dumps(registry_data)) + + with patch("cpex.tools.cli.console") as mock_console: + info("test") + mock_console.print_json.assert_called_once() + + +class TestPluginCommand: + """Tests for the plugin() command.""" + + def test_plugin_info_command(self, temp_registry_dir): + """Test plugin info command.""" + with patch("cpex.tools.cli.Console"): + result = runner.invoke(app, ["plugin", "info"]) + assert result.exit_code == 0 + + def test_plugin_list_command(self, temp_registry_dir): + """Test plugin list command.""" + with ( + patch("cpex.tools.cli.PluginCatalog") as mock_catalog_class, + patch("cpex.tools.cli.Console"), + ): + mock_catalog = Mock() + mock_catalog.update_catalog_with_pyproject = Mock() + mock_catalog_class.return_value = mock_catalog + + result = runner.invoke(app, ["plugin", "list"]) + assert result.exit_code == 0 + mock_catalog.update_catalog_with_pyproject.assert_called_once() + + def test_plugin_search_command(self, temp_registry_dir): + """Test plugin search command.""" + manifest = create_test_manifest() + + with ( + patch("cpex.tools.cli.PluginCatalog") as mock_catalog_class, + patch("cpex.tools.cli.Console"), + ): + mock_catalog = Mock() + mock_catalog.update_catalog_with_pyproject = Mock() + mock_catalog.search = Mock(return_value=[manifest]) + mock_catalog_class.return_value = mock_catalog + + result = runner.invoke(app, ["plugin", "search", "test"]) + assert result.exit_code == 0 + mock_catalog.search.assert_called_once_with("test") + + def test_plugin_install_command(self, temp_registry_dir): + """Test plugin install command.""" + manifest = create_test_manifest() + + with ( + patch("cpex.tools.cli.PluginCatalog") as mock_catalog_class, + patch("cpex.tools.cli.Console"), + patch("cpex.tools.cli.inquirer.prompt", return_value={"plugins": 0}), + patch("cpex.tools.cli.git_user_name", return_value="test_user"), + patch("cpex.tools.cli.update_plugins_config_yaml"), + patch("cpex.tools.plugin_registry.find_package_path", return_value=Path("/fake/path/to/plugin")), + ): + mock_catalog = Mock() + mock_catalog.update_catalog_with_pyproject = Mock() + mock_catalog.search = Mock(return_value=[manifest]) + mock_catalog.install_folder_via_pip = Mock() + mock_catalog_class.return_value = mock_catalog + + result = runner.invoke(app, ["plugin", "install", "test_plugin", "--type", "monorepo"]) + assert result.exit_code == 0 + + +class TestCallbackFunction: + """Tests for the callback() function.""" + + def test_callback_exists(self): + """Test that callback function exists.""" + from cpex.tools.cli import callback + + # callback should be callable and do nothing + callback() + + + +class TestRemoveFromPluginsConfigYaml: + """Tests for remove_from_plugins_config_yaml() function.""" + + def test_removes_plugin_from_config(self, tmp_path): + """Test removing a plugin from config.""" + config_file = tmp_path / "config.yaml" + + plugin1 = PluginConfig( + name="plugin_to_remove", + kind="test.plugin.remove", + mode=PluginMode.SEQUENTIAL, + priority=100 + ) + plugin2 = PluginConfig( + name="plugin_to_keep", + kind="test.plugin.keep", + mode=PluginMode.SEQUENTIAL, + priority=100 + ) + mock_config = Config(plugins=[plugin1, plugin2]) + + # Create a manifest with matching kind + manifest = create_test_manifest(name="plugin_to_remove", kind="test.plugin.remove") + + with ( + patch("cpex.tools.cli.ConfigLoader.load_config", return_value=mock_config), + patch("cpex.tools.cli.ConfigSaver.save_config") as mock_save, + ): + result = remove_from_plugins_config_yaml(manifest) + assert result is True + mock_save.assert_called_once() + assert len(mock_config.plugins) == 1 + assert mock_config.plugins[0].kind == "test.plugin.keep" + + def test_removes_isolated_venv_plugin_from_config(self, tmp_path): + """Test removing a plugin from config.""" + config_file = tmp_path / "config.yaml" + + config_1 = { + "class_name": "test.plugin.remove", + "requirements_file": "requirements.txt" + } + + config_2 = { + "class_name": "test.plugin.keep", + "requirements_file": "requirements.txt" + } + + plugin1 = PluginConfig( + name="plugin_to_remove", + kind="isolated_venv", + mode=PluginMode.SEQUENTIAL, + priority=100, + config=config_1 + ) + plugin2 = PluginConfig( + name="plugin_to_keep", + kind="test.plugin.keep", + mode=PluginMode.SEQUENTIAL, + priority=100, + config=config_2 + ) + mock_config = Config(plugins=[plugin1, plugin2]) + + # Create a manifest with matching kind + manifest = create_test_manifest( + name="plugin_to_remove", + kind="isolated_venv", + default_config=config_1 + ) + + with ( + patch("cpex.tools.cli.ConfigLoader.load_config", return_value=mock_config), + patch("cpex.tools.cli.ConfigSaver.save_config") as mock_save, + ): + result = remove_from_plugins_config_yaml(manifest) + assert result is True + mock_save.assert_called_once() + assert len(mock_config.plugins) == 1 + assert mock_config.plugins[0].kind == "test.plugin.keep" + + + def test_returns_false_when_plugin_not_found(self, tmp_path): + """Test that function returns False when plugin not found.""" + plugin1 = PluginConfig( + name="existing_plugin", + kind="test.plugin.existing", + mode=PluginMode.SEQUENTIAL, + priority=100 + ) + mock_config = Config(plugins=[plugin1]) + + # Create a manifest with non-matching kind + manifest = create_test_manifest(name="nonexistent_plugin", kind="test.plugin.nonexistent") + + with ( + patch("cpex.tools.cli.ConfigLoader.load_config", return_value=mock_config), + patch("cpex.tools.cli.ConfigSaver.save_config") as mock_save, + ): + result = remove_from_plugins_config_yaml(manifest) + assert result is False + mock_save.assert_not_called() + + def test_returns_false_when_no_plugins_in_config(self, tmp_path): + """Test that function returns False when config has no plugins.""" + mock_config = Config(plugins=None) + manifest = create_test_manifest(name="any_plugin") + + with patch("cpex.tools.cli.ConfigLoader.load_config", return_value=mock_config): + result = remove_from_plugins_config_yaml(manifest) + assert result is False + + def test_handles_exception_gracefully(self, tmp_path): + """Test that function handles exceptions gracefully.""" + manifest = create_test_manifest(name="any_plugin") + + with ( + patch("cpex.tools.cli.ConfigLoader.load_config", side_effect=Exception("Config error")), + patch("cpex.tools.cli.logger") as mock_logger, + ): + result = remove_from_plugins_config_yaml(manifest) + assert result is False + mock_logger.error.assert_called_once() + + +class TestUninstallFunction: + """Tests for uninstall() function.""" + + def test_uninstall_plugin_not_found(self, temp_registry_dir): + """Test uninstalling a plugin that is not installed.""" + mock_catalog = Mock() + + with ( + patch("cpex.tools.cli.console") as mock_console, + pytest.raises(click.exceptions.Exit) as exc_info, + ): + uninstall("nonexistent_plugin", mock_catalog) + assert exc_info.value.exit_code == 3 # EXIT_NOT_FOUND + mock_console.print.assert_called_with(":x: Plugin 'nonexistent_plugin' is not installed.") + + def test_uninstall_cancelled_by_user(self, temp_registry_dir): + """Test uninstall cancelled by user.""" + registry_file = temp_registry_dir / "installed-plugins.json" + registry_data = { + "plugins": [ + { + "name": "test_plugin", + "version": "1.0.0", + "kind": "native", + "installation_type": "monorepo", + "installation_path": "/path/to/test_plugin", + "installed_at": "2024-01-01T00:00:00.000000Z", + "installed_by": "test_user", + "package_source": "https://example.com/repo/plugin", + "editable": False, + } + ] + } + registry_file.write_text(json.dumps(registry_data)) + + mock_catalog = Mock() + + with ( + patch("cpex.tools.cli.inquirer.prompt", return_value={"confirm": False}), + patch("cpex.tools.cli.console") as mock_console, + ): + uninstall("test_plugin", mock_catalog) + mock_console.print.assert_any_call("Uninstall cancelled.") + + def test_uninstall_success(self, temp_registry_dir): + """Test successful plugin uninstallation.""" + registry_file = temp_registry_dir / "installed-plugins.json" + registry_data = { + "plugins": [ + { + "name": "test_plugin", + "version": "1.0.0", + "kind": "native", + "installation_type": "monorepo", + "installation_path": "/path/to/test_plugin", + "installed_at": "2024-01-01T00:00:00.000000Z", + "installed_by": "test_user", + "package_source": "https://example.com/repo/plugin", + "editable": False, + } + ] + } + registry_file.write_text(json.dumps(registry_data)) + + mock_catalog = Mock() + + # Create a manifest to return from find + test_manifest = create_test_manifest(name="test_plugin", kind="native") + + with ( + patch("cpex.tools.cli.inquirer.prompt", return_value={"confirm": True}), + patch("cpex.tools.cli.console") as mock_console, + patch("cpex.tools.cli.remove_from_plugins_config_yaml", return_value=True) as mock_remove, + patch("cpex.tools.cli.PluginCatalog") as mock_catalog_class, + ): + # Mock the catalog instance created inside uninstall() + mock_catalog_instance = Mock() + mock_catalog_instance.find = Mock(return_value=test_manifest) + mock_catalog_instance.uninstall_package = Mock() + mock_catalog_class.return_value = mock_catalog_instance + + mock_status = Mock() + mock_status.__enter__ = Mock(return_value=mock_status) + mock_status.__exit__ = Mock(return_value=False) + mock_console.status = Mock(return_value=mock_status) + + uninstall("test_plugin", mock_catalog) + + # Verify uninstall_package was called with both plugin_name and manifest + mock_catalog_instance.uninstall_package.assert_called_once_with("test_plugin", test_manifest) + mock_remove.assert_called_once_with(test_manifest) + mock_console.print.assert_any_call(":white_heavy_check_mark: test_plugin uninstalled successfully.") + + def test_uninstall_handles_exception(self, temp_registry_dir): + """Test uninstall handles exceptions gracefully.""" + registry_file = temp_registry_dir / "installed-plugins.json" + registry_data = { + "plugins": [ + { + "name": "test_plugin", + "version": "1.0.0", + "kind": "native", + "installation_type": "monorepo", + "installation_path": "/path/to/test_plugin", + "installed_at": "2024-01-01T00:00:00.000000Z", + "installed_by": "test_user", + "package_source": "https://example.com/repo/plugin", + "editable": False, + } + ] + } + registry_file.write_text(json.dumps(registry_data)) + + mock_catalog = Mock() + + # Create a manifest to return from find + test_manifest = create_test_manifest(name="test_plugin", kind="native") + + with ( + patch("cpex.tools.cli.inquirer.prompt", return_value={"confirm": True}), + patch("cpex.tools.cli.console") as mock_console, + patch("cpex.tools.cli.logger") as mock_logger, + patch("cpex.tools.cli.PluginCatalog") as mock_catalog_class, + pytest.raises(click.exceptions.Exit) as exc_info, + ): + # Mock the catalog instance created inside uninstall() + mock_catalog_instance = Mock() + mock_catalog_instance.find = Mock(return_value=test_manifest) + mock_catalog_instance.uninstall_package = Mock(side_effect=RuntimeError("Uninstall failed")) + mock_catalog_class.return_value = mock_catalog_instance + + mock_status = Mock() + mock_status.__enter__ = Mock(return_value=mock_status) + mock_status.__exit__ = Mock(return_value=False) + mock_console.status = Mock(return_value=mock_status) + + uninstall("test_plugin", mock_catalog) + + assert exc_info.value.exit_code == 4 # EXIT_OPERATION_FAILED + mock_console.print.assert_any_call(":x: Failed to uninstall test_plugin: Uninstall failed") + mock_logger.error.assert_called_once() + + +class TestPluginUninstallCommand: + """Tests for the plugin uninstall command.""" + + def test_plugin_uninstall_command_without_plugin_name(self, temp_registry_dir): + """Test plugin uninstall command without specifying plugin name.""" + with ( + patch("cpex.tools.cli.PluginCatalog") as mock_catalog_class, + patch("cpex.tools.cli.console") as mock_console, + ): + mock_catalog = Mock() + mock_catalog_class.return_value = mock_catalog + + result = runner.invoke(app, ["plugin", "uninstall"]) + assert result.exit_code == 2 # EXIT_INVALID_ARGS + mock_console.print.assert_called_with(":x: Please specify a plugin name to uninstall.") + + def test_plugin_uninstall_command_success(self, temp_registry_dir): + """Test successful plugin uninstall command.""" + registry_file = temp_registry_dir / "installed-plugins.json" + registry_data = { + "plugins": [ + { + "name": "test_plugin", + "version": "1.0.0", + "kind": "native", + "installation_type": "monorepo", + "installation_path": "/path/to/test_plugin", + "installed_at": "2024-01-01T00:00:00.000000Z", + "installed_by": "test_user", + "package_source": "https://example.com/repo/plugin", + "editable": False, + } + ] + } + registry_file.write_text(json.dumps(registry_data)) + + # Create a manifest to return from find + test_manifest = create_test_manifest(name="test_plugin", kind="native") + + with ( + patch("cpex.tools.cli.PluginCatalog") as mock_catalog_class, + patch("cpex.tools.cli.inquirer.prompt", return_value={"confirm": True}), + patch("cpex.tools.cli.console") as mock_console, + patch("cpex.tools.cli.remove_from_plugins_config_yaml", return_value=True), + ): + mock_catalog = Mock() + mock_catalog.uninstall_package = Mock() + mock_catalog.find = Mock(return_value=test_manifest) + mock_catalog_class.return_value = mock_catalog + + mock_status = Mock() + mock_status.__enter__ = Mock(return_value=mock_status) + mock_status.__exit__ = Mock(return_value=False) + mock_console.status = Mock(return_value=mock_status) + + result = runner.invoke(app, ["plugin", "uninstall", "test_plugin"]) + assert result.exit_code == 0 + # Verify uninstall_package was called with both plugin_name and manifest + mock_catalog.uninstall_package.assert_called_once_with("test_plugin", test_manifest) + + def test_plugin_uninstall_command_not_found(self, temp_registry_dir): + """Test plugin uninstall command when plugin not found.""" + with ( + patch("cpex.tools.cli.PluginCatalog") as mock_catalog_class, + patch("cpex.tools.cli.console") as mock_console, + ): + mock_catalog = Mock() + mock_catalog_class.return_value = mock_catalog + + result = runner.invoke(app, ["plugin", "uninstall", "nonexistent_plugin"]) + assert result.exit_code == 3 # EXIT_NOT_FOUND + mock_console.print.assert_called_with(":x: Plugin 'nonexistent_plugin' is not installed.") + + +class TestCatalogUninstallPackage: + """Tests for PluginCatalog.uninstall_package() method.""" + + def test_uninstall_package_success(self, temp_registry_dir): + """Test successful package uninstallation.""" + from cpex.tools.catalog import PluginCatalog + + # Create a test manifest + test_manifest = create_test_manifest(name="test_package", kind="native") + + with ( + patch.dict("os.environ", {"PLUGINS_GITHUB_TOKEN": "test_token"}), + patch("cpex.tools.catalog.Github"), + patch("cpex.tools.catalog.subprocess.run") as mock_subprocess, + ): + catalog = PluginCatalog() + result = catalog.uninstall_package("test_package", test_manifest) + + assert result is True + mock_subprocess.assert_called_once() + call_args = mock_subprocess.call_args + assert "pip" in call_args[0][0] + assert "uninstall" in call_args[0][0] + assert "-y" in call_args[0][0] + assert "test_package" in call_args[0][0] + + def test_uninstall_package_subprocess_error(self, temp_registry_dir): + """Test package uninstallation with subprocess error.""" + from cpex.tools.catalog import PluginCatalog + import subprocess + + # Create a test manifest + test_manifest = create_test_manifest(name="test_package", kind="native") + + with ( + patch.dict("os.environ", {"PLUGINS_GITHUB_TOKEN": "test_token"}), + patch("cpex.tools.catalog.Github"), + patch("cpex.tools.catalog.subprocess.run", side_effect=subprocess.CalledProcessError(1, ["pip"], stderr="Error")), + ): + catalog = PluginCatalog() + + with pytest.raises(RuntimeError, match="Failed to uninstall"): + catalog.uninstall_package("test_package", test_manifest) + + def test_uninstall_package_unexpected_error(self, temp_registry_dir): + """Test package uninstallation with unexpected error.""" + from cpex.tools.catalog import PluginCatalog + + # Create a test manifest + test_manifest = create_test_manifest(name="test_package", kind="native") + + with ( + patch.dict("os.environ", {"PLUGINS_GITHUB_TOKEN": "test_token"}), + patch("cpex.tools.catalog.Github"), + patch("cpex.tools.catalog.subprocess.run", side_effect=Exception("Unexpected error")), + ): + catalog = PluginCatalog() + + with pytest.raises(RuntimeError, match="Unexpected error uninstalling"): + catalog.uninstall_package("test_package", test_manifest) + + +class TestPluginRegistryRemove: + """Tests for PluginRegistry.remove() method.""" + + def test_remove_existing_plugin(self, temp_registry_dir): + """Test removing an existing plugin from registry.""" + registry_file = temp_registry_dir / "installed-plugins.json" + registry_data = { + "plugins": [ + { + "name": "test_plugin", + "version": "1.0.0", + "kind": "native", + "installation_type": "monorepo", + "installation_path": "/path/to/test_plugin", + "installed_at": "2024-01-01T00:00:00.000000Z", + "installed_by": "test_user", + "package_source": "https://example.com/repo/plugin", + "editable": False, + } + ] + } + registry_file.write_text(json.dumps(registry_data)) + + plugin_registry = PluginRegistry() + result = plugin_registry.remove("test_plugin") + + assert result is True + updated_data = json.loads(registry_file.read_text()) + assert len(updated_data["plugins"]) == 0 + + def test_remove_nonexistent_plugin(self, temp_registry_dir): + """Test removing a plugin that doesn't exist.""" + registry_file = temp_registry_dir / "installed-plugins.json" + registry_data = { + "plugins": [ + { + "name": "test_plugin", + "version": "1.0.0", + "kind": "native", + "installation_type": "monorepo", + "installation_path": "/path/to/test_plugin", + "installed_at": "2024-01-01T00:00:00.000000Z", + "installed_by": "test_user", + "package_source": "https://example.com/repo/plugin", + "editable": False, + } + ] + } + registry_file.write_text(json.dumps(registry_data)) + + plugin_registry = PluginRegistry() + result = plugin_registry.remove("nonexistent_plugin") + + assert result is False + updated_data = json.loads(registry_file.read_text()) + assert len(updated_data["plugins"]) == 1 + + +class TestInstalledPluginRegistryUnregister: + """Tests for InstalledPluginRegistry.unregister_plugin() method.""" + + def test_unregister_existing_plugin(self, temp_registry_dir): + """Test unregistering an existing plugin.""" + from cpex.framework.models import InstalledPluginRegistry, InstalledPluginInfo, PluginInstallationType + + registry_file = temp_registry_dir / "installed-plugins.json" + plugin1 = InstalledPluginInfo( + name="plugin1", + kind="native", + version="1.0.0", + installation_type=PluginInstallationType.MONOREPO, + installation_path="/path/to/plugin1", + installed_at="2024-01-01T00:00:00.000000Z", + installed_by="test_user", + package_source="https://example.com/repo/plugin1", + editable=False, + ) + plugin2 = InstalledPluginInfo( + name="plugin2", + kind="native", + version="1.0.0", + installation_type=PluginInstallationType.MONOREPO, + installation_path="/path/to/plugin2", + installed_at="2024-01-01T00:00:00.000000Z", + installed_by="test_user", + package_source="https://example.com/repo/plugin2", + editable=False, + ) + + registry = InstalledPluginRegistry(plugins=[plugin1, plugin2]) + result = registry.unregister_plugin("plugin1") + + assert result is True + assert len(registry.plugins) == 1 + assert registry.plugins[0].name == "plugin2" + + def test_unregister_nonexistent_plugin(self, temp_registry_dir): + """Test unregistering a plugin that doesn't exist.""" + from cpex.framework.models import InstalledPluginRegistry, InstalledPluginInfo, PluginInstallationType + + plugin1 = InstalledPluginInfo( + name="plugin1", + kind="native", + version="1.0.0", + installation_type=PluginInstallationType.MONOREPO, + installation_path="/path/to/plugin1", + installed_at="2024-01-01T00:00:00.000000Z", + installed_by="test_user", + package_source="https://example.com/repo/plugin1", + editable=False, + ) + + registry = InstalledPluginRegistry(plugins=[plugin1]) + result = registry.unregister_plugin("nonexistent") + + assert result is False + assert len(registry.plugins) == 1 + + +class TestInstalledPluginRegistryRegisterDedup: + """Tests for dedup behaviour of InstalledPluginRegistry.register_plugin().""" + + def _make_plugin(self, name="foo", version="1.0.0", path="/path/to/plugin", installed_at="2024-01-01T00:00:00.000000Z"): + from cpex.framework.models import InstalledPluginInfo, PluginInstallationType + return InstalledPluginInfo( + name=name, + kind="native", + version=version, + installation_type=PluginInstallationType.LOCAL, + installation_path=path, + installed_at=installed_at, + installed_by="test_user", + package_source="https://example.com/repo", + editable=False, + ) + + def test_first_register_appends(self, temp_registry_dir): + """Registering into an empty registry results in exactly one entry.""" + import json + from cpex.framework.models import InstalledPluginRegistry + + registry = InstalledPluginRegistry() + registry.register_plugin(self._make_plugin()) + + assert len(registry.plugins) == 1 + registry_file = temp_registry_dir / "installed-plugins.json" + data = json.loads(registry_file.read_text()) + assert len(data["plugins"]) == 1 + assert data["plugins"][0]["name"] == "foo" + + def test_reregister_replaces_existing(self, temp_registry_dir): + """Registering a plugin that is already present replaces the old entry.""" + import json + from cpex.framework.models import InstalledPluginRegistry + + registry = InstalledPluginRegistry() + registry.register_plugin(self._make_plugin(version="1.0.0", installed_at="2024-01-01T00:00:00.000000Z")) + registry.register_plugin(self._make_plugin(version="2.0.0", path="/new/path", installed_at="2025-06-01T00:00:00.000000Z")) + + assert len(registry.plugins) == 1 + assert registry.plugins[0].version == "2.0.0" + assert registry.plugins[0].installation_path == "/new/path" + assert registry.plugins[0].installed_at == "2025-06-01T00:00:00.000000Z" + + registry_file = temp_registry_dir / "installed-plugins.json" + data = json.loads(registry_file.read_text()) + assert len(data["plugins"]) == 1 + assert data["plugins"][0]["version"] == "2.0.0" + + def test_reregister_does_not_affect_other_plugins(self, temp_registry_dir): + """Re-registering one plugin leaves other plugins untouched.""" + from cpex.framework.models import InstalledPluginRegistry + + registry = InstalledPluginRegistry() + registry.register_plugin(self._make_plugin(name="foo", version="1.0.0")) + registry.register_plugin(self._make_plugin(name="bar", version="1.0.0")) + registry.register_plugin(self._make_plugin(name="foo", version="2.0.0")) + + assert len(registry.plugins) == 2 + names = {p.name for p in registry.plugins} + assert names == {"foo", "bar"} + foo = next(p for p in registry.plugins if p.name == "foo") + bar = next(p for p in registry.plugins if p.name == "bar") + assert foo.version == "2.0.0" + assert bar.version == "1.0.0" + + +class TestInstalledPluginRegistrySaveAtomic: + """Tests for atomic write behaviour of InstalledPluginRegistry.save().""" + + def _make_plugin(self): + from cpex.framework.models import InstalledPluginInfo, PluginInstallationType + return InstalledPluginInfo( + name="test_plugin", + kind="native", + version="1.0.0", + installation_type=PluginInstallationType.MONOREPO, + installation_path="/path/to/plugin", + installed_at="2024-01-01T00:00:00.000000Z", + installed_by="test_user", + package_source="https://example.com/repo/plugin", + editable=False, + ) + + def test_happy_path_no_tmp_litter(self, temp_registry_dir): + """save() writes the file and leaves no .tmp siblings.""" + from cpex.framework.models import InstalledPluginRegistry + registry = InstalledPluginRegistry() + registry.register_plugin(self._make_plugin()) + + registry_file = temp_registry_dir / "installed-plugins.json" + assert registry_file.exists() + data = json.loads(registry_file.read_text()) + assert len(data["plugins"]) == 1 + assert data["plugins"][0]["name"] == "test_plugin" + assert [*temp_registry_dir.glob("installed-plugins.*.tmp")] == [] + + def test_crash_mid_rename_preserves_original(self, temp_registry_dir, monkeypatch): + """If os.replace raises, the original file is untouched and no .tmp remains.""" + import os + from cpex.framework.models import InstalledPluginRegistry + + original_content = b'{"plugins":[]}' + registry_file = temp_registry_dir / "installed-plugins.json" + registry_file.write_bytes(original_content) + + def exploding_replace(*args, **kwargs): + raise OSError("simulated mid-rename crash") + + monkeypatch.setattr(os, "replace", exploding_replace) + + registry = InstalledPluginRegistry() + with pytest.raises(OSError, match="simulated mid-rename crash"): + registry.save() + + assert registry_file.read_bytes() == original_content + assert [*temp_registry_dir.glob("installed-plugins.*.tmp")] == [] + + def test_crash_mid_write_cleans_up_tmp(self, temp_registry_dir, monkeypatch): + """If writing to the temp file raises, the temp file is cleaned up.""" + import tempfile as _tempfile + from cpex.framework.models import InstalledPluginRegistry + + original_NamedTemporaryFile = _tempfile.NamedTemporaryFile + + class _ExplodingFile: + def __init__(self, *args, **kwargs): + self._real = original_NamedTemporaryFile(*args, **kwargs) + self.name = self._real.name + + def write(self, data): + raise OSError("simulated write failure") + + def flush(self): + pass + + def fileno(self): + return self._real.fileno() + + def close(self): + self._real.close() + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + + monkeypatch.setattr(_tempfile, "NamedTemporaryFile", _ExplodingFile) + + registry = InstalledPluginRegistry() + with pytest.raises(OSError, match="simulated write failure"): + registry.save() + + assert [*temp_registry_dir.glob("installed-plugins.*.tmp")] == [] + + +class TestSelectPluginFromCatalog: + """Tests for select_plugin_from_catalog() function.""" + + def test_returns_none_for_empty_list(self): + """Test that function returns None when given empty list.""" + from cpex.tools.cli import select_plugin_from_catalog + + result = select_plugin_from_catalog([]) + assert result is None + + def test_returns_none_when_user_cancels(self): + """Test that function returns None when user cancels selection.""" + from cpex.tools.cli import select_plugin_from_catalog + + manifest = create_test_manifest() + + with patch("cpex.tools.cli.inquirer.prompt", return_value=None): + result = select_plugin_from_catalog([manifest]) + assert result is None + + +class TestParsePypiSource: + """Tests for _parse_pypi_source() function.""" + + def test_parse_package_without_version(self): + """Test parsing package name without version constraint.""" + from cpex.tools.cli import _parse_pypi_source + + package_name, version_constraint = _parse_pypi_source("my-package") + assert package_name == "my-package" + assert version_constraint is None + + def test_parse_package_with_version(self): + """Test parsing package name with version constraint.""" + from cpex.tools.cli import _parse_pypi_source + + package_name, version_constraint = _parse_pypi_source("my-package@>=1.0.0") + assert package_name == "my-package" + assert version_constraint == ">=1.0.0" + + +class TestFinalizeInstallation: + """Tests for _finalize_installation() function.""" + + def test_finalize_installation_updates_registry_and_config(self, temp_registry_dir): + """Test that finalize_installation updates registry and config.""" + from cpex.tools.cli import _finalize_installation + from cpex.tools.catalog import PluginCatalog + + manifest = create_test_manifest() + mock_catalog = Mock(spec=PluginCatalog) + + with ( + patch("cpex.tools.cli.PluginRegistry") as mock_registry_class, + patch("cpex.tools.cli.update_plugins_config_yaml") as mock_update_config, + patch("cpex.tools.cli.git_user_name", return_value="test_user"), + ): + mock_registry = Mock() + mock_registry_class.return_value = mock_registry + + _finalize_installation(manifest, "pypi", mock_catalog, Path("/test/path")) + + mock_registry.update.assert_called_once() + mock_update_config.assert_called_once_with(manifest=manifest) + + +class TestInstallFromLocal: + """Tests for _install_from_local() function.""" + + def test_install_from_local_calls_catalog_method(self, temp_registry_dir, tmp_path): + """Test that _install_from_local calls catalog.install_from_local.""" + from cpex.tools.cli import _install_from_local + from cpex.tools.catalog import PluginCatalog + + source_dir = tmp_path / "my_plugin" + source_dir.mkdir() + + manifest = create_test_manifest() + manifest.local = str(source_dir) + mock_catalog = Mock(spec=PluginCatalog) + mock_catalog.install_from_local = Mock(return_value=(manifest, source_dir)) + + with ( + patch("cpex.tools.cli.console") as mock_console, + patch("cpex.tools.cli.update_plugins_config_yaml") as mock_update_config, + patch("cpex.tools.cli._finalize_installation") as mock_finalize, + ): + mock_status = Mock() + mock_status.__enter__ = Mock(return_value=mock_status) + mock_status.__exit__ = Mock(return_value=False) + mock_console.status = Mock(return_value=mock_status) + + _install_from_local(str(source_dir), mock_catalog) + + mock_catalog.install_from_local.assert_called_once() + # update_plugins_config_yaml is called inside _finalize_installation (mocked), + # not directly from _install_from_local — verify no duplicate direct call. + mock_update_config.assert_not_called() + mock_finalize.assert_called_once_with(manifest, "local", mock_catalog, source_dir) + + +class TestInstallFromMonorepo: + """Tests for _install_from_monorepo() function.""" + + def test_returns_early_when_no_plugin_selected(self): + """Test that function returns early when user doesn't select a plugin.""" + from cpex.tools.cli import _install_from_monorepo + from cpex.tools.catalog import PluginCatalog + + manifest = create_test_manifest() + mock_catalog = Mock(spec=PluginCatalog) + mock_catalog.search = Mock(return_value=[manifest]) + + with ( + patch("cpex.tools.cli.select_plugin_from_catalog", return_value=None), + patch("cpex.tools.cli.console"), + ): + # Should return early without error + _install_from_monorepo("test_plugin", mock_catalog) + + +class TestInstallFromPypi: + """Tests for _install_from_pypi() function.""" + + def test_install_from_pypi_handles_none_manifest(self, temp_registry_dir): + """Test that _install_from_pypi handles None manifest gracefully.""" + from cpex.tools.cli import _install_from_pypi + from cpex.tools.catalog import PluginCatalog + + mock_catalog = Mock(spec=PluginCatalog) + mock_catalog.install_from_pypi = Mock(return_value=(None, None)) + + with patch("cpex.tools.cli.console") as mock_console: + mock_status = Mock() + mock_status.__enter__ = Mock(return_value=mock_status) + mock_status.__exit__ = Mock(return_value=False) + mock_console.status = Mock(return_value=mock_status) + + _install_from_pypi("test_package", mock_catalog) + + mock_console.print.assert_called_with(":x: Failed to install test_package") + + +class TestInstallFunctionAdditional: + """Additional tests for install() function.""" + + def test_install_with_unsupported_type_raises_error(self): + """Test that install raises typer.Exit for unsupported installation type.""" + from cpex.tools.cli import install + from cpex.tools.catalog import PluginCatalog + + mock_catalog = Mock(spec=PluginCatalog) + + with ( + patch("cpex.tools.cli.console") as mock_console, + pytest.raises(click.exceptions.Exit) as exc_info, + ): + install("test_plugin", "unsupported_type", mock_catalog) + assert exc_info.value.exit_code == 2 # EXIT_INVALID_ARGS + + +class TestVersionsFunction: + """Tests for versions() function.""" + + def test_versions_calls_search(self): + """Test that versions() function calls search().""" + from cpex.tools.cli import versions + from cpex.tools.catalog import PluginCatalog + + mock_catalog = Mock(spec=PluginCatalog) + mock_catalog.search = Mock(return_value=[]) + + with patch("cpex.tools.cli.console"): + versions("test_plugin", mock_catalog) + mock_catalog.search.assert_called_once_with("test_plugin") + + +class TestUpdatePluginsConfigYamlWithNonePlugins: + """Test update_plugins_config_yaml when config.plugins is None.""" + + def test_creates_plugins_list_when_none(self, tmp_path): + """Test that function creates plugins list when it's None.""" + import yaml as yaml_module + + config_file = tmp_path / "config.yaml" + config_data = { + "plugins": None, # Explicitly None + } + config_file.write_text(yaml_module.safe_dump(config_data)) + + manifest = create_test_manifest(name="test_plugin") + + with ( + patch("cpex.tools.cli.settings") as mock_settings, + patch("cpex.tools.cli.ConfigLoader.load_config") as mock_load, + patch("cpex.tools.cli.ConfigSaver.save_config") as mock_save, + ): + mock_settings.config_file = str(config_file) + + # Create a Config object with plugins=None + config_obj = Config(plugins=None) + mock_load.return_value = config_obj + + update_plugins_config_yaml(manifest) + + # Verify that plugins list was created + mock_save.assert_called_once() + saved_config = mock_save.call_args[0][0] + assert saved_config.plugins is not None + assert len(saved_config.plugins) == 1 + + +class TestPluginCommandCatalogUpdate: + """Tests for plugin command catalog update paths.""" + + def test_plugin_search_updates_catalog(self, temp_registry_dir): + """Test that plugin search command updates catalog.""" + with ( + patch("cpex.tools.cli.PluginCatalog") as mock_catalog_class, + patch("cpex.tools.cli.console") as mock_console, + ): + mock_catalog = Mock() + mock_catalog.update_catalog_with_pyproject = Mock(return_value=False) + mock_catalog.search = Mock(return_value=[]) + mock_catalog_class.return_value = mock_catalog + + mock_status = Mock() + mock_status.__enter__ = Mock(return_value=mock_status) + mock_status.__exit__ = Mock(return_value=False) + mock_console.status = Mock(return_value=mock_status) + + result = runner.invoke(app, ["plugin", "search", "test"]) + assert result.exit_code == 0 + mock_catalog.update_catalog_with_pyproject.assert_called_once() + + def test_plugin_versions_command(self, temp_registry_dir): + """Test plugin versions command.""" + with ( + patch("cpex.tools.cli.PluginCatalog") as mock_catalog_class, + patch("cpex.tools.cli.console") as mock_console, + ): + mock_catalog = Mock() + mock_catalog.update_catalog_with_pyproject = Mock(return_value=False) + mock_catalog.search = Mock(return_value=[]) + mock_catalog_class.return_value = mock_catalog + + mock_status = Mock() + mock_status.__enter__ = Mock(return_value=mock_status) + mock_status.__exit__ = Mock(return_value=False) + mock_console.status = Mock(return_value=mock_status) + + result = runner.invoke(app, ["plugin", "versions", "test_plugin"]) + assert result.exit_code == 0 + mock_catalog.search.assert_called_once_with("test_plugin") + + +class TestUninstallManifestNotFound: + """Test uninstall when manifest is not found in catalog.""" + + def test_uninstall_when_manifest_not_found(self, temp_registry_dir): + """Test uninstall handles case when manifest is not found.""" + registry_file = temp_registry_dir / "installed-plugins.json" + registry_data = { + "plugins": [ + { + "name": "test_plugin", + "version": "1.0.0", + "kind": "native", + "installation_type": "monorepo", + "installation_path": "/path/to/test_plugin", + "installed_at": "2024-01-01T00:00:00.000000Z", + "installed_by": "test_user", + "package_source": "https://example.com/repo/plugin", + "editable": False, + } + ] + } + registry_file.write_text(json.dumps(registry_data)) + + mock_catalog = Mock() + + with ( + patch("cpex.tools.cli.inquirer.prompt", return_value={"confirm": True}), + patch("cpex.tools.cli.console") as mock_console, + patch("cpex.tools.cli.PluginCatalog") as mock_catalog_class, + pytest.raises(click.exceptions.Exit) as exc_info, + ): + # Mock the catalog.find method to return None (manifest not found) + mock_catalog_instance = Mock() + mock_catalog_instance.find = Mock(return_value=None) + mock_catalog_class.return_value = mock_catalog_instance + + mock_status = Mock() + mock_status.__enter__ = Mock(return_value=mock_status) + mock_status.__exit__ = Mock(return_value=False) + mock_console.status = Mock(return_value=mock_status) + + uninstall("test_plugin", mock_catalog) + + assert exc_info.value.exit_code == 3 # EXIT_NOT_FOUND + # When manifest is not found, uninstall should print error and exit + # So uninstall_package should NOT be called + mock_catalog_instance.uninstall_package.assert_not_called() + mock_console.print.assert_any_call(":x: Plugin test_plugin not found in catalog.") + + + +# Made with Bob diff --git a/tests/unit/cpex/tools/test_integrity.py b/tests/unit/cpex/tools/test_integrity.py new file mode 100644 index 00000000..4efd8dc8 --- /dev/null +++ b/tests/unit/cpex/tools/test_integrity.py @@ -0,0 +1,421 @@ +# -*- coding: utf-8 -*- +"""Location: ./tests/unit/cpex/tools/test_integrity.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck + +Unit tests for package integrity verification. +""" + +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from cpex.tools.integrity import ( + IntegrityVerificationError, + compute_file_hash, + fetch_pypi_package_hashes, + find_matching_hash, + verify_package_integrity, +) + + +class TestComputeFileHash: + """Tests for compute_file_hash function.""" + + def test_compute_hash_basic(self, tmp_path): + """Test basic hash computation.""" + test_file = tmp_path / "test.txt" + test_file.write_text("test content") + + hash_value = compute_file_hash(test_file) + + assert isinstance(hash_value, str) + assert len(hash_value) == 64 # SHA256 produces 64 hex characters + # Verify it's a valid hex string + int(hash_value, 16) + + def test_compute_hash_consistency(self, tmp_path): + """Test that same content produces same hash.""" + test_file = tmp_path / "test.txt" + test_file.write_text("consistent content") + + hash1 = compute_file_hash(test_file) + hash2 = compute_file_hash(test_file) + + assert hash1 == hash2 + + def test_compute_hash_different_content(self, tmp_path): + """Test that different content produces different hashes.""" + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + file1.write_text("content 1") + file2.write_text("content 2") + + hash1 = compute_file_hash(file1) + hash2 = compute_file_hash(file2) + + assert hash1 != hash2 + + def test_compute_hash_large_file(self, tmp_path): + """Test hash computation for large files (chunked reading).""" + test_file = tmp_path / "large.txt" + # Create a file larger than chunk size (8KB) + test_file.write_text("x" * 10000) + + hash_value = compute_file_hash(test_file) + + assert isinstance(hash_value, str) + assert len(hash_value) == 64 + + def test_compute_hash_binary_file(self, tmp_path): + """Test hash computation for binary files.""" + test_file = tmp_path / "binary.bin" + test_file.write_bytes(b"\x00\x01\x02\x03\xff\xfe\xfd") + + hash_value = compute_file_hash(test_file) + + assert isinstance(hash_value, str) + assert len(hash_value) == 64 + + def test_compute_hash_nonexistent_file(self, tmp_path): + """Test that nonexistent file raises FileNotFoundError.""" + nonexistent = tmp_path / "nonexistent.txt" + + with pytest.raises(FileNotFoundError, match="File not found"): + compute_file_hash(nonexistent) + + def test_compute_hash_unsupported_algorithm(self, tmp_path): + """Test that unsupported algorithm raises ValueError.""" + test_file = tmp_path / "test.txt" + test_file.write_text("test") + + with pytest.raises(ValueError, match="Unsupported hash algorithm"): + compute_file_hash(test_file, algorithm="invalid_algo") + + +class TestFetchPyPiPackageHashes: + """Tests for fetch_pypi_package_hashes function.""" + + @patch("cpex.tools.integrity.httpx.Client") + def test_fetch_hashes_success(self, mock_client_class): + """Test successful hash fetching from PyPI.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "urls": [ + { + "filename": "package-1.0.0.tar.gz", + "digests": {"sha256": "abc123def456"}, + "url": "https://files.pythonhosted.org/package-1.0.0.tar.gz", + }, + { + "filename": "package-1.0.0-py3-none-any.whl", + "digests": {"sha256": "789ghi012jkl"}, + "url": "https://files.pythonhosted.org/package-1.0.0-py3-none-any.whl", + }, + ] + } + mock_client = MagicMock() + mock_client.get.return_value = mock_response + mock_client.__enter__.return_value = mock_client + mock_client_class.return_value = mock_client + + hashes = fetch_pypi_package_hashes("test-package", "1.0.0") + + assert len(hashes) == 2 + assert "package-1.0.0.tar.gz" in hashes + assert hashes["package-1.0.0.tar.gz"]["sha256"] == "abc123def456" + assert "package-1.0.0-py3-none-any.whl" in hashes + assert hashes["package-1.0.0-py3-none-any.whl"]["sha256"] == "789ghi012jkl" + + @patch("cpex.tools.integrity.httpx.Client") + def test_fetch_hashes_test_pypi(self, mock_client_class): + """Test fetching from test.pypi.org.""" + mock_response = MagicMock() + mock_response.json.return_value = {"urls": []} + mock_client = MagicMock() + mock_client.get.return_value = mock_response + mock_client.__enter__.return_value = mock_client + mock_client_class.return_value = mock_client + + fetch_pypi_package_hashes("test-package", use_test=True) + + # Verify test PyPI URL was used + call_args = mock_client.get.call_args[0][0] + assert "test.pypi.org" in call_args + + @patch("cpex.tools.integrity.httpx.Client") + def test_fetch_hashes_package_not_found(self, mock_client_class): + """Test handling of 404 response.""" + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 404 + mock_client.get.return_value = mock_response + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "404", request=MagicMock(), response=mock_response + ) + mock_client.__enter__.return_value = mock_client + mock_client_class.return_value = mock_client + + with pytest.raises(RuntimeError, match="Package .* not found"): + fetch_pypi_package_hashes("nonexistent-package") + + @patch("cpex.tools.integrity.httpx.Client") + def test_fetch_hashes_network_error(self, mock_client_class): + """Test handling of network errors.""" + mock_client = MagicMock() + mock_client.get.side_effect = httpx.RequestError("Connection failed") + mock_client.__enter__.return_value = mock_client + mock_client_class.return_value = mock_client + + with pytest.raises(RuntimeError, match="Network error"): + fetch_pypi_package_hashes("test-package") + + @patch("cpex.tools.integrity.httpx.Client") + def test_fetch_hashes_no_urls(self, mock_client_class): + """Test handling of package with no distribution files.""" + mock_response = MagicMock() + mock_response.json.return_value = {"urls": []} + mock_client = MagicMock() + mock_client.get.return_value = mock_response + mock_client.__enter__.return_value = mock_client + mock_client_class.return_value = mock_client + + hashes = fetch_pypi_package_hashes("empty-package") + + assert hashes == {} + + @patch("cpex.tools.integrity.httpx.Client") + def test_fetch_hashes_missing_sha256(self, mock_client_class): + """Test handling of files without SHA256 digests.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "urls": [ + { + "filename": "package-1.0.0.tar.gz", + "digests": {}, # No SHA256 + "url": "https://files.pythonhosted.org/package-1.0.0.tar.gz", + } + ] + } + mock_client = MagicMock() + mock_client.get.return_value = mock_response + mock_client.__enter__.return_value = mock_client + mock_client_class.return_value = mock_client + + hashes = fetch_pypi_package_hashes("test-package") + + assert hashes == {} + + +class TestVerifyPackageIntegrity: + """Tests for verify_package_integrity function.""" + + def test_verify_success(self, tmp_path): + """Test successful verification.""" + test_file = tmp_path / "package.tar.gz" + test_file.write_text("package content") + + expected_hash = compute_file_hash(test_file) + result = verify_package_integrity(test_file, expected_hash, "test-package") + + assert result is True + + def test_verify_failure_strict(self, tmp_path): + """Test verification failure in strict mode.""" + test_file = tmp_path / "package.tar.gz" + test_file.write_text("package content") + + wrong_hash = "0" * 64 + + with pytest.raises(IntegrityVerificationError) as exc_info: + verify_package_integrity(test_file, wrong_hash, "test-package", strict=True) + + assert "test-package" in str(exc_info.value) + assert exc_info.value.package_name == "test-package" + assert exc_info.value.expected_hash == wrong_hash + + def test_verify_failure_non_strict(self, tmp_path): + """Test verification failure in non-strict mode.""" + test_file = tmp_path / "package.tar.gz" + test_file.write_text("package content") + + wrong_hash = "0" * 64 + + result = verify_package_integrity(test_file, wrong_hash, "test-package", strict=False) + + assert result is False + + def test_verify_case_insensitive(self, tmp_path): + """Test that hash comparison is case-insensitive.""" + test_file = tmp_path / "package.tar.gz" + test_file.write_text("package content") + + expected_hash = compute_file_hash(test_file) + uppercase_hash = expected_hash.upper() + + result = verify_package_integrity(test_file, uppercase_hash, "test-package") + + assert result is True + + def test_verify_nonexistent_file(self, tmp_path): + """Test verification of nonexistent file.""" + nonexistent = tmp_path / "nonexistent.tar.gz" + + with pytest.raises(FileNotFoundError, match="Package file not found"): + verify_package_integrity(nonexistent, "abc123", "test-package") + + def test_verify_without_package_name(self, tmp_path): + """Test verification without explicit package name.""" + test_file = tmp_path / "package.tar.gz" + test_file.write_text("package content") + + expected_hash = compute_file_hash(test_file) + result = verify_package_integrity(test_file, expected_hash) + + assert result is True + + +class TestFindMatchingHash: + """Tests for find_matching_hash function.""" + + def test_find_exact_match(self, tmp_path): + """Test finding exact filename match.""" + test_file = tmp_path / "package-1.0.0.tar.gz" + hashes = { + "package-1.0.0.tar.gz": {"sha256": "abc123", "url": "https://example.com"}, + "other-file.whl": {"sha256": "def456", "url": "https://example.com"}, + } + + result = find_matching_hash(test_file, hashes) + + assert result == "abc123" + + def test_find_case_insensitive_match(self, tmp_path): + """Test finding case-insensitive match.""" + test_file = tmp_path / "Package-1.0.0.TAR.GZ" + hashes = {"package-1.0.0.tar.gz": {"sha256": "abc123", "url": "https://example.com"}} + + result = find_matching_hash(test_file, hashes) + + assert result == "abc123" + + def test_find_no_match(self, tmp_path): + """Test when no matching hash is found.""" + test_file = tmp_path / "unknown-package.tar.gz" + hashes = {"other-package.tar.gz": {"sha256": "abc123", "url": "https://example.com"}} + + result = find_matching_hash(test_file, hashes) + + assert result is None + + def test_find_empty_hashes(self, tmp_path): + """Test with empty hashes dictionary.""" + test_file = tmp_path / "package.tar.gz" + hashes = {} + + result = find_matching_hash(test_file, hashes) + + assert result is None + + +class TestIntegrityVerificationError: + """Tests for IntegrityVerificationError exception.""" + + def test_error_attributes(self): + """Test that error has correct attributes.""" + error = IntegrityVerificationError("test-pkg", "expected123", "actual456") + + assert error.package_name == "test-pkg" + assert error.expected_hash == "expected123" + assert error.actual_hash == "actual456" + assert "test-pkg" in str(error) + assert "expected123" in str(error) + assert "actual456" in str(error) + + def test_error_message_format(self): + """Test error message formatting.""" + error = IntegrityVerificationError("my-package", "a" * 64, "b" * 64) + + message = str(error) + assert "my-package" in message + assert "Integrity verification failed" in message + # Should show truncated hashes + assert "aaaaaaaaaaaaaaa" in message + assert "bbbbbbbbbbbbbbb" in message + + +class TestIntegrationScenarios: + """Integration tests for complete verification workflows.""" + + @patch("cpex.tools.integrity.httpx.Client") + def test_full_verification_workflow(self, mock_client_class, tmp_path): + """Test complete workflow: fetch hashes, download, verify.""" + # Setup mock PyPI response + test_file = tmp_path / "package-1.0.0.tar.gz" + test_file.write_text("package content") + actual_hash = compute_file_hash(test_file) + + mock_response = MagicMock() + mock_response.json.return_value = { + "urls": [ + { + "filename": "package-1.0.0.tar.gz", + "digests": {"sha256": actual_hash}, + "url": "https://files.pythonhosted.org/package-1.0.0.tar.gz", + } + ] + } + mock_client = MagicMock() + mock_client.get.return_value = mock_response + mock_client.__enter__.return_value = mock_client + mock_client_class.return_value = mock_client + + # Fetch hashes + hashes = fetch_pypi_package_hashes("package", "1.0.0") + + # Find matching hash + expected_hash = find_matching_hash(test_file, hashes) + + # Verify + result = verify_package_integrity(test_file, expected_hash, "package") + + assert result is True + + @patch("cpex.tools.integrity.httpx.Client") + def test_verification_with_tampered_package(self, mock_client_class, tmp_path): + """Test detection of tampered package.""" + # Setup mock with original hash + original_hash = "abc123def456" + "0" * 52 + + mock_response = MagicMock() + mock_response.json.return_value = { + "urls": [ + { + "filename": "package-1.0.0.tar.gz", + "digests": {"sha256": original_hash}, + "url": "https://files.pythonhosted.org/package-1.0.0.tar.gz", + } + ] + } + mock_client = MagicMock() + mock_client.get.return_value = mock_response + mock_client.__enter__.return_value = mock_client + mock_client_class.return_value = mock_client + + # Create "tampered" file with different content + test_file = tmp_path / "package-1.0.0.tar.gz" + test_file.write_text("tampered content") + + # Fetch hashes + hashes = fetch_pypi_package_hashes("package", "1.0.0") + expected_hash = find_matching_hash(test_file, hashes) + + # Verification should fail + with pytest.raises(IntegrityVerificationError): + verify_package_integrity(test_file, expected_hash, "package", strict=True) + +# Made with Bob From 310d0db80d23b93affb0a91a59a4f5c577916226 Mon Sep 17 00:00:00 2001 From: terylt <30874627+terylt@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:31:59 -0600 Subject: [PATCH 6/8] fix: missing cmf-demo main.go file and gitignore fix that missed it (#52) Co-authored-by: Teryl Taylor --- .gitignore | 27 ++- examples/go-demo/.gitignore | 11 +- examples/go-demo/cmd/cmf-demo/main.go | 272 ++++++++++++++++++++++++++ 3 files changed, 297 insertions(+), 13 deletions(-) create mode 100644 examples/go-demo/cmd/cmf-demo/main.go diff --git a/.gitignore b/.gitignore index beb86085..f6a8c150 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,6 @@ token.txt cpex.sbom.xml docs/docs/test/ docs/resources/ -tmp *.tgz *.gz *.bz @@ -48,8 +47,10 @@ node_modules/ mcp.db-journal mcp.db-shm mcp.db-wal -certs/ -jwt/ +# Anchored: matches only ./certs/ at the repo root, not nested +# `certs/` directories under source. Bare `certs/` would silently +# hide any cert-management module / test fixture at any depth. +/certs/ FIXMEs *.old logs/ @@ -62,19 +63,22 @@ corpus/ tests/fuzz/fuzzers/results/ .venv mcp.db -public/ +# Anchored to repo root — bare `public/` would shadow any nested +# `public/` directory in source (common in web/frontend code). +/public/ ica_integrations_host.sbom.json .pyre dictionary.dic pdm.lock .pdm-python temp/ -public/ *history.md htmlcov test_commands.md cover.md -build/ +# Anchored: bare `build/` would shadow any nested build-output dir +# anywhere in the source tree. +/build/ .icaenv commands_output.txt commands_output.md @@ -94,7 +98,6 @@ scribeflow.log coverage_re bin/flagged flagged/ -certs/ # VENV .python37/ .python39/ @@ -111,16 +114,20 @@ __pycache__/ # C extensions *.so -# Distribution / packaging +# Distribution / packaging — Python build artifacts. `build/` and +# `lib/` are anchored (root-only) so they don't silently hide +# nested source dirs of the same name. Other patterns (`dist/`, +# `downloads/`, `eggs/`, …) stay bare — they're less likely to +# collide with source-tree directory names. .wily/ .Python -build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ -lib/ +# Anchored: bare `lib/` would shadow any nested `lib/` source dir. +/lib/ lib64/ parts/ sdist/ diff --git a/examples/go-demo/.gitignore b/examples/go-demo/.gitignore index 8123b755..6d4b7557 100644 --- a/examples/go-demo/.gitignore +++ b/examples/go-demo/.gitignore @@ -1,3 +1,8 @@ -# Built demo binaries -cpex-demo -cmf-demo +# Built demo binaries. Patterns are anchored (leading slash) so they +# match *files* at their build-output locations, not arbitrary path +# components — the previous unanchored `cmf-demo` rule was silently +# ignoring the `cmd/cmf-demo/` source directory and everything under +# it. +/cpex-demo +/cmf-demo +/cmd/cmf-demo/cmf-demo diff --git a/examples/go-demo/cmd/cmf-demo/main.go b/examples/go-demo/cmd/cmf-demo/main.go new file mode 100644 index 00000000..c550d33a --- /dev/null +++ b/examples/go-demo/cmd/cmf-demo/main.go @@ -0,0 +1,272 @@ +// Location: ./examples/go-demo/cmd/cmf-demo/main.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CPEX CMF Demo — typed message processing with rich extensions. +// +// Demonstrates CMF (ContextForge Message Format) message processing +// through the CPEX plugin pipeline: +// +// 1. Build typed CMF messages (tool calls, tool results) +// 2. Attach security extensions (labels, subject), HTTP headers, +// and agent context +// 3. Invoke cmf.tool_pre_invoke — policy checks tool permissions +// against security labels and meta tags +// 4. Invoke cmf.tool_post_invoke — header injector adds response +// headers using capability-gated write access +// 5. Inspect modified extensions (injected headers) in results +// +// Build & run: +// +// cd examples/go-demo/ffi && cargo build --release +// cd examples/go-demo && go run ./cmd/cmf-demo + +package main + +/* +#cgo LDFLAGS: -L${SRCDIR}/../../../../target/release -lcpex_demo_ffi -lm -ldl -lpthread -framework CoreFoundation -framework Security +#include + +int cpex_demo_register_factories(void* mgr); +*/ +import "C" + +import ( + "fmt" + "os" + "unsafe" + + cpex "github.com/contextforge-org/contextforge-plugins-framework/go/cpex" +) + +func main() { + fmt.Println("=== CPEX CMF Demo ===") + fmt.Println() + + // --- Setup --- + mgr, err := cpex.NewPluginManagerDefault() + if err != nil { + fatal("create manager: %v", err) + } + defer mgr.Shutdown() + + err = mgr.RegisterFactories(func(handle unsafe.Pointer) error { + if C.cpex_demo_register_factories(handle) != 0 { + return fmt.Errorf("factory registration failed") + } + return nil + }) + if err != nil { + fatal("register factories: %v", err) + } + + yaml, err := os.ReadFile("../../cmf_plugins.yaml") + if err != nil { + // Try current directory too + yaml, err = os.ReadFile("cmf_plugins.yaml") + if err != nil { + fatal("read config: %v", err) + } + } + + if err := mgr.LoadConfig(string(yaml)); err != nil { + fatal("load config: %v", err) + } + if err := mgr.Initialize(); err != nil { + fatal("initialize: %v", err) + } + + fmt.Printf("Plugins loaded: %d\n", mgr.PluginCount()) + fmt.Printf("Hooks: cmf.tool_pre_invoke=%v cmf.tool_post_invoke=%v\n\n", + mgr.HasHooksFor("cmf.tool_pre_invoke"), + mgr.HasHooksFor("cmf.tool_post_invoke"), + ) + + // ----------------------------------------------------------------------- + // Scenario 1: PII tool call WITHOUT security label — DENIED + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 1: get_compensation tool call (no PII label) ===") + fmt.Println() + + msg := cpex.MessagePayload{ + Message: cpex.NewMessage("assistant", + cpex.NewTextPart("I'll look up the compensation data for you."), + cpex.NewToolCallPart(cpex.ToolCall{ + ToolCallID: "tc_001", + Name: "get_compensation", + Arguments: map[string]any{"employee_id": 42}, + Namespace: "hr", + }), + ), + } + + ext := &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii", "hr"}, + }, + Security: &cpex.SecurityExtension{ + Labels: []string{}, // no PII label — should be denied + Subject: &cpex.SubjectExtension{ + ID: "alice", + Roles: []string{"hr_analyst"}, + }, + }, + Http: &cpex.HttpExtension{ + RequestHeaders: map[string]string{ + "Authorization": "Bearer eyJ...", + "X-Request-ID": "req-001", + }, + }, + Agent: &cpex.AgentExtension{ + SessionID: "sess_abc123", + AgentID: "hr-assistant", + }, + } + + result, ct, bg, err := mgr.InvokeByName("cmf.tool_pre_invoke", + cpex.PayloadCMFMessage, msg, ext, nil) + if err != nil { + fatal("invoke: %v", err) + } + printResult(result) + bg.Close() + ct.Close() + + // ----------------------------------------------------------------------- + // Scenario 2: PII tool call WITH security label — ALLOWED + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 2: get_compensation tool call (with PII label) ===") + fmt.Println() + + ext.Security.Labels = []string{"PII", "HR"} // now has PII label + + result, ct, bg, err = mgr.InvokeByName("cmf.tool_pre_invoke", + cpex.PayloadCMFMessage, msg, ext, nil) + if err != nil { + fatal("invoke: %v", err) + } + printResult(result) + + // Check for modified extensions (header injector adds response headers) + // Check for modified extensions (header injector adds response headers) + if len(result.ModifiedExtensions) > 0 { + modExt, err := result.DeserializeExtensions() + if err != nil { + fmt.Printf(" (failed to deserialize modified extensions: %v)\n\n", err) + } else if modExt != nil && modExt.Http != nil && len(modExt.Http.ResponseHeaders) > 0 { + fmt.Println(" Modified response headers:") + for k, v := range modExt.Http.ResponseHeaders { + fmt.Printf(" %s: %s\n", k, v) + } + fmt.Println() + } + } + bg.Close() + + // ----------------------------------------------------------------------- + // Scenario 3: Post-invoke with tool result — header injection + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 3: tool result post-invoke (header injection) ===") + fmt.Println() + + resultMsg := cpex.MessagePayload{ + Message: cpex.NewMessage("tool", + cpex.NewToolResultPart(cpex.ToolResult{ + ToolCallID: "tc_001", + ToolName: "get_compensation", + Content: map[string]any{ + "employee_id": 42, + "salary": 125000, + "currency": "USD", + }, + IsError: false, + }), + ), + } + + postExt := &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii", "hr"}, + }, + Security: &cpex.SecurityExtension{ + Labels: []string{"PII", "HR"}, + }, + Http: &cpex.HttpExtension{ + RequestHeaders: map[string]string{ + "Authorization": "Bearer eyJ...", + "X-Request-ID": "req-001", + }, + }, + } + + result2, ct2, bg2, err := mgr.InvokeByName("cmf.tool_post_invoke", + cpex.PayloadCMFMessage, resultMsg, postExt, ct) + if err != nil { + fatal("post-invoke: %v", err) + } + printResult(result2) + + if len(result2.ModifiedExtensions) > 0 { + modExt, err := result2.DeserializeExtensions() + if err != nil { + fmt.Printf(" (failed to deserialize modified extensions: %v)\n\n", err) + } else if modExt != nil && modExt.Http != nil { + fmt.Println(" Modified response headers:") + for k, v := range modExt.Http.ResponseHeaders { + fmt.Printf(" %s: %s\n", k, v) + } + fmt.Println() + } + } + bg2.Close() + ct2.Close() + + // ----------------------------------------------------------------------- + // Scenario 4: Non-PII tool — allowed, no policy restriction + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 4: list_departments (non-PII, text message) ===") + fmt.Println() + + textMsg := cpex.MessagePayload{ + Message: cpex.NewMessage("user", + cpex.NewTextPart("Show me the list of departments"), + ), + } + + textExt := &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "list_departments", + }, + } + + result, ct, bg, err = mgr.InvokeByName("cmf.tool_pre_invoke", + cpex.PayloadCMFMessage, textMsg, textExt, nil) + if err != nil { + fatal("invoke: %v", err) + } + printResult(result) + bg.Close() + ct.Close() + + fmt.Println("=== CMF Demo complete ===") +} + +func printResult(result *cpex.PipelineResult) { + if !result.IsDenied() { + fmt.Printf(" Result: ALLOWED\n\n") + } else { + v := result.Violation + fmt.Printf(" Result: DENIED — %s [%s]\n\n", v.Reason, v.Code) + } +} + +func fatal(format string, args ...any) { + fmt.Fprintf(os.Stderr, "ERROR: "+format+"\n", args...) + os.Exit(1) +} From 162dfa0ef2a8410b4b471b3abdf12b40bf1c0491 Mon Sep 17 00:00:00 2001 From: tedhabeck Date: Mon, 1 Jun 2026 16:32:57 -0400 Subject: [PATCH 7/8] chore: unit test fixes (#57) Signed-off-by: habeck --- tests/unit/cpex/tools/test_cli.py | 49 ++++++++++++++++++------------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/tests/unit/cpex/tools/test_cli.py b/tests/unit/cpex/tools/test_cli.py index 9cc418b0..524693c9 100644 --- a/tests/unit/cpex/tools/test_cli.py +++ b/tests/unit/cpex/tools/test_cli.py @@ -16,6 +16,7 @@ import pytest # We use typer's CliRunner for testing typer apps import click +import typer from typer.testing import CliRunner # Third-Party @@ -852,10 +853,12 @@ def test_install_requires_type_parameter(self): mock_catalog = Mock() with ( patch("cpex.tools.cli.console") as mock_console, - pytest.raises(click.exceptions.Exit) as exc_info, + patch("cpex.tools.cli._install_from_monorepo") as mock_install, ): - install("source", "", mock_catalog) - assert exc_info.value.exit_code == 2 # EXIT_INVALID_ARGS + # When install_type is None, it defaults to "monorepo" and calls _install_from_monorepo + # We need to mock it to avoid actual installation + mock_install.return_value = None + install("source", None, mock_catalog) class TestSearchFunction: @@ -1167,9 +1170,15 @@ def test_uninstall_plugin_not_found(self, temp_registry_dir): with ( patch("cpex.tools.cli.console") as mock_console, - pytest.raises(click.exceptions.Exit) as exc_info, + patch("cpex.tools.cli.PluginRegistry") as mock_registry_class, ): - uninstall("nonexistent_plugin", mock_catalog) + # Mock empty registry + mock_registry = Mock() + mock_registry.registry.plugins = [] + mock_registry_class.return_value = mock_registry + + with pytest.raises(typer.Exit) as exc_info: + uninstall("nonexistent_plugin", mock_catalog) assert exc_info.value.exit_code == 3 # EXIT_NOT_FOUND mock_console.print.assert_called_with(":x: Plugin 'nonexistent_plugin' is not installed.") @@ -1281,7 +1290,6 @@ def test_uninstall_handles_exception(self, temp_registry_dir): patch("cpex.tools.cli.console") as mock_console, patch("cpex.tools.cli.logger") as mock_logger, patch("cpex.tools.cli.PluginCatalog") as mock_catalog_class, - pytest.raises(click.exceptions.Exit) as exc_info, ): # Mock the catalog instance created inside uninstall() mock_catalog_instance = Mock() @@ -1294,7 +1302,8 @@ def test_uninstall_handles_exception(self, temp_registry_dir): mock_status.__exit__ = Mock(return_value=False) mock_console.status = Mock(return_value=mock_status) - uninstall("test_plugin", mock_catalog) + with pytest.raises(typer.Exit) as exc_info: + uninstall("test_plugin", mock_catalog) assert exc_info.value.exit_code == 4 # EXIT_OPERATION_FAILED mock_console.print.assert_any_call(":x: Failed to uninstall test_plugin: Uninstall failed") @@ -1864,14 +1873,12 @@ def test_install_with_unsupported_type_raises_error(self): """Test that install raises typer.Exit for unsupported installation type.""" from cpex.tools.cli import install from cpex.tools.catalog import PluginCatalog - + mock_catalog = Mock(spec=PluginCatalog) - - with ( - patch("cpex.tools.cli.console") as mock_console, - pytest.raises(click.exceptions.Exit) as exc_info, - ): - install("test_plugin", "unsupported_type", mock_catalog) + + with patch("cpex.tools.cli.console") as mock_console: + with pytest.raises(typer.Exit) as exc_info: + install("test_plugin", "unsupported_type", mock_catalog) assert exc_info.value.exit_code == 2 # EXIT_INVALID_ARGS @@ -1992,27 +1999,27 @@ def test_uninstall_when_manifest_not_found(self, temp_registry_dir): ] } registry_file.write_text(json.dumps(registry_data)) - + mock_catalog = Mock() - + with ( patch("cpex.tools.cli.inquirer.prompt", return_value={"confirm": True}), patch("cpex.tools.cli.console") as mock_console, patch("cpex.tools.cli.PluginCatalog") as mock_catalog_class, - pytest.raises(click.exceptions.Exit) as exc_info, ): # Mock the catalog.find method to return None (manifest not found) mock_catalog_instance = Mock() mock_catalog_instance.find = Mock(return_value=None) mock_catalog_class.return_value = mock_catalog_instance - + mock_status = Mock() mock_status.__enter__ = Mock(return_value=mock_status) mock_status.__exit__ = Mock(return_value=False) mock_console.status = Mock(return_value=mock_status) - - uninstall("test_plugin", mock_catalog) - + + with pytest.raises(typer.Exit) as exc_info: + uninstall("test_plugin", mock_catalog) + assert exc_info.value.exit_code == 3 # EXIT_NOT_FOUND # When manifest is not found, uninstall should print error and exit # So uninstall_package should NOT be called From 82ce18c6e302cb648cdb63a96209c768428505b5 Mon Sep 17 00:00:00 2001 From: habeck Date: Wed, 3 Jun 2026 16:14:41 -0400 Subject: [PATCH 8/8] chore: fix module path to match renamed repo Signed-off-by: habeck --- go/cpex/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/cpex/go.mod b/go/cpex/go.mod index d71e10b0..c3c06bc9 100644 --- a/go/cpex/go.mod +++ b/go/cpex/go.mod @@ -1,4 +1,4 @@ -module github.com/contextforge-org/contextforge-plugins-framework/go/cpex +module github.com/contextforge-org/cpex/go/cpex go 1.25.4