diff --git a/examples/01_standalone_sdk/43_mixed_marketplace_skills/main.py b/examples/01_standalone_sdk/43_mixed_marketplace_skills/main.py index 44132b0179..b833752198 100644 --- a/examples/01_standalone_sdk/43_mixed_marketplace_skills/main.py +++ b/examples/01_standalone_sdk/43_mixed_marketplace_skills/main.py @@ -37,7 +37,7 @@ import sys from pathlib import Path -from openhands.sdk.plugin import Marketplace +from openhands.sdk.marketplace import Marketplace from openhands.sdk.skills import ( install_skills_from_marketplace, list_installed_skills, diff --git a/openhands-sdk/openhands/sdk/marketplace/__init__.py b/openhands-sdk/openhands/sdk/marketplace/__init__.py new file mode 100644 index 0000000000..2bc8251b1b --- /dev/null +++ b/openhands-sdk/openhands/sdk/marketplace/__init__.py @@ -0,0 +1,49 @@ +"""Marketplace module for OpenHands SDK. + +This module provides support for plugin and skill marketplaces - directories +that list available plugins and skills with their metadata and source locations. + +A marketplace is defined by a `marketplace.json` file in a `.plugin/` or +`.claude-plugin/` directory at the root of a repository. It lists plugins and +skills available for installation, along with metadata like descriptions, +versions, and authors. + +Example marketplace.json: +```json +{ + "name": "company-tools", + "owner": {"name": "DevTools Team"}, + "plugins": [ + {"name": "formatter", "source": "./plugins/formatter"} + ], + "skills": [ + {"name": "github", "source": "./skills/github"} + ] +} +``` +""" + +from openhands.sdk.marketplace.types import ( + MARKETPLACE_MANIFEST_DIRS, + MARKETPLACE_MANIFEST_FILE, + Marketplace, + MarketplaceEntry, + MarketplaceMetadata, + MarketplaceOwner, + MarketplacePluginEntry, + MarketplacePluginSource, +) + + +__all__ = [ + # Constants + "MARKETPLACE_MANIFEST_DIRS", + "MARKETPLACE_MANIFEST_FILE", + # Marketplace classes + "Marketplace", + "MarketplaceEntry", + "MarketplaceOwner", + "MarketplacePluginEntry", + "MarketplacePluginSource", + "MarketplaceMetadata", +] diff --git a/openhands-sdk/openhands/sdk/marketplace/types.py b/openhands-sdk/openhands/sdk/marketplace/types.py new file mode 100644 index 0000000000..0fbdfc7269 --- /dev/null +++ b/openhands-sdk/openhands/sdk/marketplace/types.py @@ -0,0 +1,309 @@ +"""Type definitions for Marketplace module.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, Field, field_validator, model_validator + +from openhands.sdk.plugin.types import ( + HooksConfigDict, + LspServersDict, + McpServersDict, + PluginAuthor, + PluginManifest, +) + + +# Directories to check for marketplace manifest +MARKETPLACE_MANIFEST_DIRS = [".plugin", ".claude-plugin"] +MARKETPLACE_MANIFEST_FILE = "marketplace.json" + + +class MarketplaceOwner(BaseModel): + """Owner information for a marketplace. + + The owner represents the maintainer or team responsible for the marketplace. + """ + + name: str = Field(description="Name of the maintainer or team") + email: str | None = Field( + default=None, description="Contact email for the maintainer" + ) + + +class MarketplacePluginSource(BaseModel): + """Plugin source specification for non-local sources. + + Supports GitHub repositories and generic git URLs. + """ + + source: str = Field(description="Source type: 'github' or 'url'") + repo: str | None = Field( + default=None, description="GitHub repository in 'owner/repo' format" + ) + url: str | None = Field(default=None, description="Git URL for 'url' source type") + ref: str | None = Field( + default=None, description="Branch, tag, or commit reference" + ) + path: str | None = Field( + default=None, description="Subdirectory path within the repository" + ) + + model_config = {"extra": "allow"} + + @model_validator(mode="after") + def validate_source_fields(self) -> MarketplacePluginSource: + """Validate that required fields are present based on source type.""" + if self.source == "github" and not self.repo: + raise ValueError("GitHub source requires 'repo' field") + if self.source == "url" and not self.url: + raise ValueError("URL source requires 'url' field") + return self + + +class MarketplaceEntry(BaseModel): + """Base class for marketplace entries (plugins and skills). + + Both plugins and skills are pointers to directories: + - Plugin directories contain: plugin.json, skills/, commands/, agents/, etc. + - Skill directories contain: SKILL.md and optionally scripts/, references/, assets/ + + Source is a string path (local path or GitHub URL). + """ + + name: str = Field(description="Identifier (kebab-case, no spaces)") + source: str = Field(description="Path to directory (local path or GitHub URL)") + description: str | None = Field(default=None, description="Brief description") + version: str | None = Field(default=None, description="Version") + author: PluginAuthor | None = Field(default=None, description="Author information") + category: str | None = Field(default=None, description="Category for organization") + homepage: str | None = Field( + default=None, description="Homepage or documentation URL" + ) + + model_config = {"extra": "allow", "populate_by_name": True} + + @field_validator("author", mode="before") + @classmethod + def _parse_author(cls, v: Any) -> Any: + if isinstance(v, str): + return PluginAuthor.from_string(v) + return v + + +class MarketplacePluginEntry(MarketplaceEntry): + """Plugin entry in a marketplace. + + Extends MarketplaceEntry with Claude Code compatibility fields for + inline plugin definitions (when strict=False). + + Plugins support both string sources and complex source objects + (MarketplacePluginSource) for GitHub/git URLs with ref and path. + """ + + # Override source to allow complex source objects for plugins + source: str | MarketplacePluginSource = Field( # type: ignore[assignment] + description="Path to plugin directory or source object for GitHub/git" + ) + + # Plugin-specific fields + entry_command: str | None = Field( + default=None, + description=( + "Default command to invoke when launching this plugin. " + "Should match a command name from the commands/ directory." + ), + ) + + # Claude Code compatibility fields + strict: bool = Field( + default=True, + description="If True, plugin source must contain plugin.json. " + "If False, marketplace entry defines the plugin inline.", + ) + commands: str | list[str] | None = Field(default=None) + agents: str | list[str] | None = Field(default=None) + hooks: str | HooksConfigDict | None = Field(default=None) + mcp_servers: McpServersDict | None = Field(default=None, alias="mcpServers") + lsp_servers: LspServersDict | None = Field(default=None, alias="lspServers") + + # Additional metadata fields + license: str | None = Field(default=None, description="SPDX license identifier") + keywords: list[str] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) + repository: str | None = Field( + default=None, description="Source code repository URL" + ) + + @field_validator("source", mode="before") + @classmethod + def _parse_source(cls, v: Any) -> Any: + if isinstance(v, dict): + return MarketplacePluginSource.model_validate(v) + return v + + def to_plugin_manifest(self) -> PluginManifest: + """Convert to PluginManifest (for strict=False entries).""" + return PluginManifest( + name=self.name, + version=self.version or "1.0.0", + description=self.description or "", + author=self.author, + entry_command=self.entry_command, + ) + + +class MarketplaceMetadata(BaseModel): + """Optional metadata for a marketplace.""" + + description: str | None = Field(default=None) + version: str | None = Field(default=None) + + model_config = {"extra": "allow", "populate_by_name": True} + + +class Marketplace(BaseModel): + """A plugin marketplace that lists available plugins and skills. + + Follows the Claude Code marketplace structure for compatibility, + with an additional `skills` field for standalone skill references. + + The marketplace.json file is located in `.plugin/` or `.claude-plugin/` + directory at the root of the marketplace repository. + + Example: + ```json + { + "name": "company-tools", + "owner": {"name": "DevTools Team"}, + "plugins": [ + {"name": "formatter", "source": "./plugins/formatter"} + ], + "skills": [ + {"name": "github", "source": "./skills/github"} + ] + } + ``` + """ + + name: str = Field( + description="Marketplace identifier (kebab-case, no spaces). " + "Users see this when installing plugins: /plugin install tool@" + ) + owner: MarketplaceOwner = Field(description="Marketplace maintainer information") + description: str | None = Field( + default=None, + description="Brief marketplace description. Can also be in metadata.", + ) + plugins: list[MarketplacePluginEntry] = Field( + default_factory=list, description="List of available plugins" + ) + skills: list[MarketplaceEntry] = Field( + default_factory=list, description="List of standalone skills" + ) + metadata: MarketplaceMetadata | None = Field( + default=None, description="Optional marketplace metadata" + ) + path: str | None = Field( + default=None, + description="Path to the marketplace directory (set after loading)", + ) + + model_config = {"extra": "allow"} + + @classmethod + def load(cls, marketplace_path: str | Path) -> Marketplace: + """Load a marketplace from a directory. + + Looks for marketplace.json in .plugin/ or .claude-plugin/ directories. + + Args: + marketplace_path: Path to the marketplace directory. + + Returns: + Loaded Marketplace instance. + + Raises: + FileNotFoundError: If the marketplace directory or manifest doesn't exist. + ValueError: If the marketplace manifest is invalid. + """ + marketplace_dir = Path(marketplace_path).resolve() + if not marketplace_dir.is_dir(): + raise FileNotFoundError( + f"Marketplace directory not found: {marketplace_dir}" + ) + + # Find manifest file + manifest_path = None + for manifest_dir in MARKETPLACE_MANIFEST_DIRS: + candidate = marketplace_dir / manifest_dir / MARKETPLACE_MANIFEST_FILE + if candidate.exists(): + manifest_path = candidate + break + + if manifest_path is None: + dirs = " or ".join(MARKETPLACE_MANIFEST_DIRS) + raise FileNotFoundError( + f"Marketplace manifest not found. " + f"Expected {MARKETPLACE_MANIFEST_FILE} in {dirs} " + f"directory under {marketplace_dir}" + ) + + try: + with open(manifest_path) as f: + data = json.load(f) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in {manifest_path}: {e}") from e + + return cls.model_validate({**data, "path": str(marketplace_dir)}) + + def get_plugin(self, name: str) -> MarketplacePluginEntry | None: + """Get a plugin entry by name. + + Args: + name: Plugin name to look up. + + Returns: + MarketplacePluginEntry if found, None otherwise. + """ + for plugin in self.plugins: + if plugin.name == name: + return plugin + return None + + def resolve_plugin_source( + self, plugin: MarketplacePluginEntry + ) -> tuple[str, str | None, str | None]: + """Resolve a plugin's source to a full path or URL. + + Returns: + Tuple of (source, ref, subpath) where: + - source: Resolved source string (path or URL) + - ref: Branch, tag, or commit reference (None for local paths) + - subpath: Subdirectory path within the repo (None if not specified) + """ + source = plugin.source + + # Handle complex source objects (GitHub, git URLs) + if isinstance(source, MarketplacePluginSource): + if source.source == "github" and source.repo: + return (f"github:{source.repo}", source.ref, source.path) + if source.source == "url" and source.url: + return (source.url, source.ref, source.path) + raise ValueError( + f"Invalid plugin source for '{plugin.name}': " + f"source type '{source.source}' is missing required field" + ) + + # Absolute paths or URLs - return as-is + if source.startswith(("/", "~")) or "://" in source: + return (source, None, None) + + # Relative path - resolve against marketplace path if known + if self.path: + source = str(Path(self.path) / source.lstrip("./")) + + return (source, None, None) diff --git a/openhands-sdk/openhands/sdk/plugin/__init__.py b/openhands-sdk/openhands/sdk/plugin/__init__.py index 7b67494994..f333039cb0 100644 --- a/openhands-sdk/openhands/sdk/plugin/__init__.py +++ b/openhands-sdk/openhands/sdk/plugin/__init__.py @@ -8,8 +8,24 @@ Additionally, it provides utilities for managing installed plugins in the user's home directory (~/.openhands/plugins/installed/). + +Note: Marketplace classes have been moved to ``openhands.sdk.marketplace``. +They are still importable from this module for backward compatibility, but +importing them from here will emit a deprecation warning. """ +from typing import Any + +# Import marketplace classes from new location for internal use +# (no deprecation warning since we're importing from the canonical location) +from openhands.sdk.marketplace import ( + Marketplace as _Marketplace, + MarketplaceEntry as _MarketplaceEntry, + MarketplaceMetadata as _MarketplaceMetadata, + MarketplaceOwner as _MarketplaceOwner, + MarketplacePluginEntry as _MarketplacePluginEntry, + MarketplacePluginSource as _MarketplacePluginSource, +) from openhands.sdk.plugin.fetch import ( PluginFetchError, fetch_plugin_with_resolution, @@ -38,12 +54,6 @@ ) from openhands.sdk.plugin.types import ( CommandDefinition, - Marketplace, - MarketplaceEntry, - MarketplaceMetadata, - MarketplaceOwner, - MarketplacePluginEntry, - MarketplacePluginSource, PluginAuthor, PluginManifest, PluginSource, @@ -51,6 +61,33 @@ ) +# Deprecated marketplace names that trigger warnings when accessed +_DEPRECATED_MARKETPLACE_NAMES = { + "Marketplace": _Marketplace, + "MarketplaceEntry": _MarketplaceEntry, + "MarketplaceMetadata": _MarketplaceMetadata, + "MarketplaceOwner": _MarketplaceOwner, + "MarketplacePluginEntry": _MarketplacePluginEntry, + "MarketplacePluginSource": _MarketplacePluginSource, +} + + +def __getattr__(name: str) -> Any: + """Provide deprecated marketplace names with warnings.""" + if name in _DEPRECATED_MARKETPLACE_NAMES: + from openhands.sdk.utils.deprecation import warn_deprecated + + warn_deprecated( + f"Importing {name} from openhands.sdk.plugin", + deprecated_in="1.16.0", + removed_in="1.19.0", + details="Import from openhands.sdk.marketplace instead.", + stacklevel=3, + ) + return _DEPRECATED_MARKETPLACE_NAMES[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + __all__ = [ # Plugin classes "Plugin", @@ -63,7 +100,7 @@ # Plugin loading "load_plugins", "fetch_plugin_with_resolution", - # Marketplace classes + # Marketplace classes (deprecated - import from openhands.sdk.marketplace) "Marketplace", "MarketplaceEntry", "MarketplaceOwner", diff --git a/openhands-sdk/openhands/sdk/plugin/types.py b/openhands-sdk/openhands/sdk/plugin/types.py index 29030e0254..7cc5800ef2 100644 --- a/openhands-sdk/openhands/sdk/plugin/types.py +++ b/openhands-sdk/openhands/sdk/plugin/types.py @@ -2,17 +2,11 @@ from __future__ import annotations -import json from pathlib import Path from typing import TYPE_CHECKING, Any import frontmatter -from pydantic import BaseModel, Field, field_validator, model_validator - - -# Directories to check for marketplace manifest -MARKETPLACE_MANIFEST_DIRS = [".plugin", ".claude-plugin"] -MARKETPLACE_MANIFEST_FILE = "marketplace.json" +from pydantic import BaseModel, Field, field_validator class PluginSource(BaseModel): @@ -366,288 +360,41 @@ def to_skill(self, plugin_name: str) -> Skill: ) -class MarketplaceOwner(BaseModel): - """Owner information for a marketplace. - - The owner represents the maintainer or team responsible for the marketplace. - """ - - name: str = Field(description="Name of the maintainer or team") - email: str | None = Field( - default=None, description="Contact email for the maintainer" - ) - - -class MarketplacePluginSource(BaseModel): - """Plugin source specification for non-local sources. - - Supports GitHub repositories and generic git URLs. - """ - - source: str = Field(description="Source type: 'github' or 'url'") - repo: str | None = Field( - default=None, description="GitHub repository in 'owner/repo' format" - ) - url: str | None = Field(default=None, description="Git URL for 'url' source type") - ref: str | None = Field( - default=None, description="Branch, tag, or commit reference" - ) - path: str | None = Field( - default=None, description="Subdirectory path within the repository" - ) - - model_config = {"extra": "allow"} - - @model_validator(mode="after") - def validate_source_fields(self) -> MarketplacePluginSource: - """Validate that required fields are present based on source type.""" - if self.source == "github" and not self.repo: - raise ValueError("GitHub source requires 'repo' field") - if self.source == "url" and not self.url: - raise ValueError("URL source requires 'url' field") - return self - - -class MarketplaceEntry(BaseModel): - """Base class for marketplace entries (plugins and skills). - - Both plugins and skills are pointers to directories: - - Plugin directories contain: plugin.json, skills/, commands/, agents/, etc. - - Skill directories contain: SKILL.md and optionally scripts/, references/, assets/ - - Source is a string path (local path or GitHub URL). - """ - - name: str = Field(description="Identifier (kebab-case, no spaces)") - source: str = Field(description="Path to directory (local path or GitHub URL)") - description: str | None = Field(default=None, description="Brief description") - version: str | None = Field(default=None, description="Version") - author: PluginAuthor | None = Field(default=None, description="Author information") - category: str | None = Field(default=None, description="Category for organization") - homepage: str | None = Field( - default=None, description="Homepage or documentation URL" - ) - - model_config = {"extra": "allow", "populate_by_name": True} - - @field_validator("author", mode="before") - @classmethod - def _parse_author(cls, v: Any) -> Any: - if isinstance(v, str): - return PluginAuthor.from_string(v) - return v - - -class MarketplacePluginEntry(MarketplaceEntry): - """Plugin entry in a marketplace. - - Extends MarketplaceEntry with Claude Code compatibility fields for - inline plugin definitions (when strict=False). - - Plugins support both string sources and complex source objects - (MarketplacePluginSource) for GitHub/git URLs with ref and path. - """ - - # Override source to allow complex source objects for plugins - source: str | MarketplacePluginSource = Field( # type: ignore[assignment] - description="Path to plugin directory or source object for GitHub/git" - ) - - # Plugin-specific fields - entry_command: str | None = Field( - default=None, - description=( - "Default command to invoke when launching this plugin. " - "Should match a command name from the commands/ directory." - ), - ) - - # Claude Code compatibility fields - strict: bool = Field( - default=True, - description="If True, plugin source must contain plugin.json. " - "If False, marketplace entry defines the plugin inline.", - ) - commands: str | list[str] | None = Field(default=None) - agents: str | list[str] | None = Field(default=None) - hooks: str | HooksConfigDict | None = Field(default=None) - mcp_servers: McpServersDict | None = Field(default=None, alias="mcpServers") - lsp_servers: LspServersDict | None = Field(default=None, alias="lspServers") - - # Additional metadata fields - license: str | None = Field(default=None, description="SPDX license identifier") - keywords: list[str] = Field(default_factory=list) - tags: list[str] = Field(default_factory=list) - repository: str | None = Field( - default=None, description="Source code repository URL" - ) - - @field_validator("source", mode="before") - @classmethod - def _parse_source(cls, v: Any) -> Any: - if isinstance(v, dict): - return MarketplacePluginSource.model_validate(v) - return v - - def to_plugin_manifest(self) -> PluginManifest: - """Convert to PluginManifest (for strict=False entries).""" - return PluginManifest( - name=self.name, - version=self.version or "1.0.0", - description=self.description or "", - author=self.author, - entry_command=self.entry_command, - ) - - -class MarketplaceMetadata(BaseModel): - """Optional metadata for a marketplace.""" - - description: str | None = Field(default=None) - version: str | None = Field(default=None) - - model_config = {"extra": "allow", "populate_by_name": True} - +# ============================================================================= +# Deprecated marketplace classes - moved to openhands.sdk.marketplace +# ============================================================================= +# These are re-exported here for backward compatibility. Import from +# openhands.sdk.marketplace instead. -class Marketplace(BaseModel): - """A plugin marketplace that lists available plugins and skills. - - Follows the Claude Code marketplace structure for compatibility, - with an additional `skills` field for standalone skill references. - - The marketplace.json file is located in `.plugin/` or `.claude-plugin/` - directory at the root of the marketplace repository. - - Example: - ```json +# Names of deprecated marketplace exports +_DEPRECATED_MARKETPLACE_NAMES = frozenset( { - "name": "company-tools", - "owner": {"name": "DevTools Team"}, - "plugins": [ - {"name": "formatter", "source": "./plugins/formatter"} - ], - "skills": [ - {"name": "github", "source": "./skills/github"} - ] + "MARKETPLACE_MANIFEST_DIRS", + "MARKETPLACE_MANIFEST_FILE", + "Marketplace", + "MarketplaceEntry", + "MarketplaceMetadata", + "MarketplaceOwner", + "MarketplacePluginEntry", + "MarketplacePluginSource", } - ``` - """ +) - name: str = Field( - description="Marketplace identifier (kebab-case, no spaces). " - "Users see this when installing plugins: /plugin install tool@" - ) - owner: MarketplaceOwner = Field(description="Marketplace maintainer information") - description: str | None = Field( - default=None, - description="Brief marketplace description. Can also be in metadata.", - ) - plugins: list[MarketplacePluginEntry] = Field( - default_factory=list, description="List of available plugins" - ) - skills: list[MarketplaceEntry] = Field( - default_factory=list, description="List of standalone skills" - ) - metadata: MarketplaceMetadata | None = Field( - default=None, description="Optional marketplace metadata" - ) - path: str | None = Field( - default=None, - description="Path to the marketplace directory (set after loading)", - ) - model_config = {"extra": "allow"} - - @classmethod - def load(cls, marketplace_path: str | Path) -> Marketplace: - """Load a marketplace from a directory. - - Looks for marketplace.json in .plugin/ or .claude-plugin/ directories. - - Args: - marketplace_path: Path to the marketplace directory. +def __getattr__(name: str) -> Any: + """Provide deprecated marketplace names with warnings via lazy import.""" + if name in _DEPRECATED_MARKETPLACE_NAMES: + from openhands.sdk.utils.deprecation import warn_deprecated - Returns: - Loaded Marketplace instance. - - Raises: - FileNotFoundError: If the marketplace directory or manifest doesn't exist. - ValueError: If the marketplace manifest is invalid. - """ - marketplace_dir = Path(marketplace_path).resolve() - if not marketplace_dir.is_dir(): - raise FileNotFoundError( - f"Marketplace directory not found: {marketplace_dir}" - ) - - # Find manifest file - manifest_path = None - for manifest_dir in MARKETPLACE_MANIFEST_DIRS: - candidate = marketplace_dir / manifest_dir / MARKETPLACE_MANIFEST_FILE - if candidate.exists(): - manifest_path = candidate - break - - if manifest_path is None: - dirs = " or ".join(MARKETPLACE_MANIFEST_DIRS) - raise FileNotFoundError( - f"Marketplace manifest not found. " - f"Expected {MARKETPLACE_MANIFEST_FILE} in {dirs} " - f"directory under {marketplace_dir}" - ) - - try: - with open(manifest_path) as f: - data = json.load(f) - except json.JSONDecodeError as e: - raise ValueError(f"Invalid JSON in {manifest_path}: {e}") from e - - return cls.model_validate({**data, "path": str(marketplace_dir)}) - - def get_plugin(self, name: str) -> MarketplacePluginEntry | None: - """Get a plugin entry by name. - - Args: - name: Plugin name to look up. - - Returns: - MarketplacePluginEntry if found, None otherwise. - """ - for plugin in self.plugins: - if plugin.name == name: - return plugin - return None - - def resolve_plugin_source( - self, plugin: MarketplacePluginEntry - ) -> tuple[str, str | None, str | None]: - """Resolve a plugin's source to a full path or URL. - - Returns: - Tuple of (source, ref, subpath) where: - - source: Resolved source string (path or URL) - - ref: Branch, tag, or commit reference (None for local paths) - - subpath: Subdirectory path within the repo (None if not specified) - """ - source = plugin.source - - # Handle complex source objects (GitHub, git URLs) - if isinstance(source, MarketplacePluginSource): - if source.source == "github" and source.repo: - return (f"github:{source.repo}", source.ref, source.path) - if source.source == "url" and source.url: - return (source.url, source.ref, source.path) - raise ValueError( - f"Invalid plugin source for '{plugin.name}': " - f"source type '{source.source}' is missing required field" - ) - - # Absolute paths or URLs - return as-is - if source.startswith(("/", "~")) or "://" in source: - return (source, None, None) - - # Relative path - resolve against marketplace path if known - if self.path: - source = str(Path(self.path) / source.lstrip("./")) + warn_deprecated( + f"Importing {name} from openhands.sdk.plugin.types", + deprecated_in="1.16.0", + removed_in="1.19.0", + details="Import from openhands.sdk.marketplace instead.", + stacklevel=3, + ) + # Lazy import to avoid circular dependency + import openhands.sdk.marketplace.types as marketplace_types - return (source, None, None) + return getattr(marketplace_types, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/openhands-sdk/openhands/sdk/skills/installed.py b/openhands-sdk/openhands/sdk/skills/installed.py index ed25ad8463..660499a1bf 100644 --- a/openhands-sdk/openhands/sdk/skills/installed.py +++ b/openhands-sdk/openhands/sdk/skills/installed.py @@ -518,7 +518,8 @@ def install_skills_from_marketplace( >>> for info in installed: ... print(f"Installed: {info.name}") """ - from openhands.sdk.plugin import Marketplace, resolve_source_path + from openhands.sdk.marketplace import Marketplace + from openhands.sdk.plugin import resolve_source_path marketplace_path = Path(marketplace_path) installed_dir = _resolve_installed_dir(installed_dir) diff --git a/openhands-sdk/openhands/sdk/skills/skill.py b/openhands-sdk/openhands/sdk/skills/skill.py index cb0cdb837c..7bb140263b 100644 --- a/openhands-sdk/openhands/sdk/skills/skill.py +++ b/openhands-sdk/openhands/sdk/skills/skill.py @@ -913,7 +913,7 @@ def load_marketplace_skill_names( Returns: Set of skill names to load, or None if marketplace file not found or invalid. """ - from openhands.sdk.plugin import Marketplace + from openhands.sdk.marketplace import Marketplace marketplace_file = repo_path / marketplace_path if not marketplace_file.exists(): diff --git a/tests/sdk/marketplace/__init__.py b/tests/sdk/marketplace/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/sdk/marketplace/test_deprecation.py b/tests/sdk/marketplace/test_deprecation.py new file mode 100644 index 0000000000..8859b44694 --- /dev/null +++ b/tests/sdk/marketplace/test_deprecation.py @@ -0,0 +1,145 @@ +"""Tests for marketplace module deprecation warnings.""" + +import warnings + +import pytest +from deprecation import DeprecatedWarning + +from openhands.sdk.marketplace import ( + MARKETPLACE_MANIFEST_DIRS, + MARKETPLACE_MANIFEST_FILE, + Marketplace, + MarketplaceEntry, + MarketplaceMetadata, + MarketplaceOwner, + MarketplacePluginEntry, + MarketplacePluginSource, +) + + +def test_new_import_location_has_all_exports(): + """Test that all marketplace classes are available from the new location.""" + # Constants + assert MARKETPLACE_MANIFEST_DIRS == [".plugin", ".claude-plugin"] + assert MARKETPLACE_MANIFEST_FILE == "marketplace.json" + + # Classes + assert Marketplace is not None + assert MarketplaceEntry is not None + assert MarketplaceOwner is not None + assert MarketplacePluginEntry is not None + assert MarketplacePluginSource is not None + assert MarketplaceMetadata is not None + + +def test_deprecated_import_from_plugin_warns(): + """Test that importing from openhands.sdk.plugin emits deprecation warning.""" + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter("always") + from openhands.sdk.plugin import Marketplace as OldMarketplace + + # Find deprecation warnings for this import + deprecation_warnings = [ + w + for w in caught_warnings + if issubclass(w.category, DeprecatedWarning) + and "openhands.sdk.plugin" in str(w.message) + and "Marketplace" in str(w.message) + ] + assert len(deprecation_warnings) > 0, "Expected deprecation warning not found" + + # Verify the warning message + warning_msg = str(deprecation_warnings[0].message) + assert "openhands.sdk.marketplace" in warning_msg + + # Verify the class is the same + assert OldMarketplace is Marketplace + + +def test_deprecated_import_from_plugin_types_warns(): + """Test that importing from openhands.sdk.plugin.types emits deprecation warning.""" + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter("always") + from openhands.sdk.plugin.types import MarketplaceOwner as OldMarketplaceOwner + + # Find deprecation warnings for this import + deprecation_warnings = [ + w + for w in caught_warnings + if issubclass(w.category, DeprecatedWarning) + and "openhands.sdk.plugin.types" in str(w.message) + and "MarketplaceOwner" in str(w.message) + ] + assert len(deprecation_warnings) > 0, "Expected deprecation warning not found" + + # Verify the class is the same + assert OldMarketplaceOwner is MarketplaceOwner + + +def test_deprecated_constant_import_warns(): + """Test that importing constants from old location emits deprecation warning.""" + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter("always") + from openhands.sdk.plugin.types import ( + MARKETPLACE_MANIFEST_FILE as OLD_MANIFEST_FILE, + ) + + # Find deprecation warnings for this import + deprecation_warnings = [ + w + for w in caught_warnings + if issubclass(w.category, DeprecatedWarning) + and "MARKETPLACE_MANIFEST_FILE" in str(w.message) + ] + assert len(deprecation_warnings) > 0, "Expected deprecation warning not found" + + # Verify the constant is the same + assert OLD_MANIFEST_FILE == MARKETPLACE_MANIFEST_FILE + + +@pytest.mark.parametrize( + "class_name", + [ + "Marketplace", + "MarketplaceEntry", + "MarketplaceOwner", + "MarketplacePluginEntry", + "MarketplacePluginSource", + "MarketplaceMetadata", + ], +) +def test_all_deprecated_classes_from_plugin(class_name: str): + """Test all marketplace classes emit deprecation warnings from plugin.""" + import openhands.sdk.marketplace as marketplace_module + + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + from openhands.sdk import plugin + + old_class = getattr(plugin, class_name) + new_class = getattr(marketplace_module, class_name) + + # Verify the class is the same + assert old_class is new_class + + +def test_marketplace_functionality_preserved(): + """Test that Marketplace class functionality works from new location.""" + owner = MarketplaceOwner(name="Test Team") + assert owner.name == "Test Team" + + source = MarketplacePluginSource(source="github", repo="owner/repo") + assert source.repo == "owner/repo" + + entry = MarketplaceEntry(name="test-skill", source="./skills/test") + assert entry.name == "test-skill" + + plugin_entry = MarketplacePluginEntry( + name="test-plugin", + source="./plugins/test", + description="A test plugin", + ) + assert plugin_entry.description == "A test plugin" + + metadata = MarketplaceMetadata(version="1.0.0") + assert metadata.version == "1.0.0" diff --git a/tests/sdk/plugin/test_marketplace.py b/tests/sdk/marketplace/test_marketplace.py similarity index 99% rename from tests/sdk/plugin/test_marketplace.py rename to tests/sdk/marketplace/test_marketplace.py index 8bc8e1b04f..4f70b09a71 100644 --- a/tests/sdk/plugin/test_marketplace.py +++ b/tests/sdk/marketplace/test_marketplace.py @@ -4,14 +4,14 @@ import pytest -from openhands.sdk.plugin import ( +from openhands.sdk.marketplace import ( Marketplace, MarketplaceMetadata, MarketplaceOwner, MarketplacePluginEntry, MarketplacePluginSource, - PluginAuthor, ) +from openhands.sdk.plugin import PluginAuthor class TestMarketplaceOwner: