@openhands/extensions 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/pyproject.toml CHANGED
@@ -1,8 +1,13 @@
1
1
  [project]
2
2
  name = "openhands-extensions"
3
- version = "0.9.0"
3
+ version = "0.10.0"
4
4
  description = "OpenHands extensions, plugins, and skills (Python bindings for the integration catalog)"
5
5
  requires-python = ">=3.12"
6
+ dependencies = [
7
+ "deprecation>=2.1,<3",
8
+ "packaging>=24,<26",
9
+ "pydantic>=2.10,<3",
10
+ ]
6
11
 
7
12
  [build-system]
8
13
  requires = ["hatchling>=1.21"]
@@ -13,12 +13,50 @@ from ._version import __version__
13
13
  from .integrations import (
14
14
  INTEGRATION_CATALOG_SNAPSHOT,
15
15
  get_integration_catalog_entry,
16
+ get_integration_catalog_entry_model,
16
17
  list_integration_catalog,
18
+ list_integration_catalog_models,
19
+ )
20
+ from .integration_models import (
21
+ HttpConnectionOption,
22
+ IntegrationAuthConfig,
23
+ IntegrationCatalogEntry,
24
+ IntegrationCatalogSnapshot,
25
+ IntegrationConnectionOption,
26
+ IntegrationConnectionModel,
27
+ IntegrationHttpConfig,
28
+ IntegrationIdentityMapping,
29
+ IntegrationOAuthConfig,
30
+ IntegrationResourceDiscovery,
31
+ IntegrationTransport,
32
+ McpConnectionOption,
33
+ MarketplaceField,
34
+ SseTransport,
35
+ StdioTransport,
36
+ StreamableHttpTransport,
17
37
  )
18
38
 
19
39
  __all__ = [
20
40
  "INTEGRATION_CATALOG_SNAPSHOT",
41
+ "HttpConnectionOption",
42
+ "IntegrationAuthConfig",
43
+ "IntegrationCatalogEntry",
44
+ "IntegrationCatalogSnapshot",
45
+ "IntegrationConnectionOption",
46
+ "IntegrationConnectionModel",
47
+ "IntegrationHttpConfig",
48
+ "IntegrationIdentityMapping",
49
+ "IntegrationOAuthConfig",
50
+ "IntegrationResourceDiscovery",
51
+ "IntegrationTransport",
52
+ "MarketplaceField",
53
+ "McpConnectionOption",
54
+ "SseTransport",
55
+ "StdioTransport",
56
+ "StreamableHttpTransport",
21
57
  "__version__",
22
58
  "get_integration_catalog_entry",
59
+ "get_integration_catalog_entry_model",
23
60
  "list_integration_catalog",
61
+ "list_integration_catalog_models",
24
62
  ]
@@ -16,8 +16,8 @@ from importlib.metadata import PackageNotFoundError, version
16
16
 
17
17
  #: Fallback used only when the package is not installed (no dist metadata).
18
18
  #: Kept in lock-step with pyproject.toml/package.json by the version test.
19
- # x-release-please-update-version
20
- _FALLBACK_VERSION = "0.9.0"
19
+ # x-release-please-version
20
+ _FALLBACK_VERSION = "0.10.0"
21
21
 
22
22
  try:
23
23
  __version__: str = version("openhands-extensions")
@@ -0,0 +1,159 @@
1
+ """Versioned deprecation helpers for OpenHands extensions.
2
+
3
+ This module intentionally mirrors ``openhands.sdk.utils.deprecation`` so public
4
+ extension APIs use the same decorator, runtime warning, and model-input
5
+ compatibility behavior as the SDK.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import warnings
11
+ from collections.abc import Callable
12
+ from datetime import date
13
+ from functools import cache
14
+ from importlib.metadata import PackageNotFoundError, version as get_version
15
+ from typing import Any, TypeVar, cast
16
+
17
+ from deprecation import (
18
+ DeprecatedWarning,
19
+ UnsupportedWarning,
20
+ deprecated as _deprecated,
21
+ )
22
+ from packaging import version as pkg_version
23
+
24
+
25
+ _FuncT = TypeVar("_FuncT", bound=Callable[..., Any])
26
+
27
+
28
+ @cache
29
+ def _current_version() -> str:
30
+ try:
31
+ return get_version("openhands-extensions")
32
+ except PackageNotFoundError:
33
+ return "0.0.0"
34
+
35
+
36
+ def deprecated(
37
+ *,
38
+ deprecated_in: str,
39
+ removed_in: str | date | None,
40
+ current_version: str | None = None,
41
+ details: str = "",
42
+ ) -> Callable[[_FuncT], _FuncT]:
43
+ """Return a decorator that deprecates a callable with explicit metadata."""
44
+ base_decorator = _deprecated(
45
+ deprecated_in=deprecated_in,
46
+ removed_in=removed_in,
47
+ current_version=current_version or _current_version(),
48
+ details=details,
49
+ )
50
+
51
+ def decorator(func: _FuncT) -> _FuncT:
52
+ return cast(_FuncT, base_decorator(func))
53
+
54
+ return decorator
55
+
56
+
57
+ def _should_warn(
58
+ *,
59
+ deprecated_in: str | None,
60
+ removed_in: str | date | None,
61
+ current_version: str | None,
62
+ ) -> tuple[bool, bool]:
63
+ is_deprecated = False
64
+ is_unsupported = False
65
+
66
+ if isinstance(removed_in, date):
67
+ if date.today() >= removed_in:
68
+ is_unsupported = True
69
+ else:
70
+ is_deprecated = True
71
+ elif current_version:
72
+ current = pkg_version.parse(current_version)
73
+ if removed_in and current >= pkg_version.parse(str(removed_in)):
74
+ is_unsupported = True
75
+ elif deprecated_in and current >= pkg_version.parse(deprecated_in):
76
+ is_deprecated = True
77
+ else:
78
+ is_deprecated = True
79
+
80
+ return is_deprecated, is_unsupported
81
+
82
+
83
+ def warn_deprecated(
84
+ feature: str,
85
+ *,
86
+ deprecated_in: str,
87
+ removed_in: str | date | None,
88
+ current_version: str | None = None,
89
+ details: str = "",
90
+ stacklevel: int = 2,
91
+ ) -> None:
92
+ """Emit a deprecation warning for dynamic access to a legacy feature."""
93
+ current_version = current_version or _current_version()
94
+ is_deprecated, is_unsupported = _should_warn(
95
+ deprecated_in=deprecated_in,
96
+ removed_in=removed_in,
97
+ current_version=current_version,
98
+ )
99
+
100
+ if not (is_deprecated or is_unsupported):
101
+ return
102
+
103
+ warning_cls = UnsupportedWarning if is_unsupported else DeprecatedWarning
104
+ warning = warning_cls(feature, deprecated_in, removed_in, details)
105
+ warnings.warn(warning, stacklevel=stacklevel)
106
+
107
+
108
+ def warn_cleanup(
109
+ workaround: str,
110
+ *,
111
+ cleanup_by: str | date,
112
+ current_version: str | None = None,
113
+ details: str = "",
114
+ stacklevel: int = 2,
115
+ ) -> None:
116
+ """Emit a warning when a temporary workaround has reached its deadline."""
117
+ current_version = current_version or _current_version()
118
+
119
+ should_cleanup = False
120
+ if isinstance(cleanup_by, date):
121
+ should_cleanup = date.today() >= cleanup_by
122
+ else:
123
+ try:
124
+ current = pkg_version.parse(current_version)
125
+ target = pkg_version.parse(str(cleanup_by))
126
+ should_cleanup = current >= target
127
+ except pkg_version.InvalidVersion:
128
+ pass
129
+
130
+ if should_cleanup:
131
+ message = (
132
+ f"Cleanup required: {workaround}. "
133
+ f"This workaround was scheduled for removal by {cleanup_by}."
134
+ )
135
+ if details:
136
+ message += f" {details}"
137
+ warnings.warn(message, UserWarning, stacklevel=stacklevel)
138
+
139
+
140
+ def handle_deprecated_model_fields(
141
+ data: Any,
142
+ deprecated_fields: tuple[str, ...],
143
+ ) -> Any:
144
+ """Remove permanently supported legacy fields from Pydantic model input."""
145
+ if not isinstance(data, dict):
146
+ return data
147
+
148
+ for field in deprecated_fields:
149
+ data.pop(field, None)
150
+
151
+ return data
152
+
153
+
154
+ __all__ = [
155
+ "deprecated",
156
+ "warn_deprecated",
157
+ "warn_cleanup",
158
+ "handle_deprecated_model_fields",
159
+ ]
@@ -0,0 +1,233 @@
1
+ """Typed models for the OpenHands integration catalog.
2
+
3
+ The hand-authored JSON files under ``integrations/catalog`` remain the source
4
+ of truth. These models provide a Python representation of the public catalog
5
+ contract defined by ``integrations/catalog.schema.json`` and
6
+ ``integrations/index.d.ts``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Annotated, Any, Literal
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
14
+
15
+
16
+ NonEmptyString = Annotated[str, Field(min_length=1)]
17
+ HttpsUrl = Annotated[str, Field(pattern=r"^https://", min_length=8)]
18
+ IdentityPath = Annotated[
19
+ str,
20
+ Field(pattern=r"^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$", min_length=1),
21
+ ]
22
+ IntegrationAuthStrategy = Literal["none", "api_key", "bearer", "basic", "oauth2"]
23
+ IntegrationProvider = Literal["mcp", "http"]
24
+
25
+
26
+ class CatalogModel(BaseModel):
27
+ """Base model for strict, portable catalog data."""
28
+
29
+ model_config = ConfigDict(extra="forbid")
30
+
31
+ @model_validator(mode="before")
32
+ @classmethod
33
+ def reject_explicit_nulls(cls, values: Any) -> Any:
34
+ """Match the JSON Schema: optional fields may be omitted, never null."""
35
+ if not isinstance(values, dict):
36
+ return values
37
+
38
+ null_fields = [field for field, value in values.items() if value is None]
39
+ if null_fields:
40
+ raise ValueError(
41
+ "Catalog fields must be omitted instead of set to null: "
42
+ f"{', '.join(null_fields)}"
43
+ )
44
+ return values
45
+
46
+
47
+ class MarketplaceField(CatalogModel):
48
+ key: NonEmptyString
49
+ label: NonEmptyString
50
+ type: Literal["text", "password"]
51
+ placeholder: str | None = None
52
+ helperText: str | None = None
53
+ helperLink: HttpsUrl | None = None
54
+ required: bool
55
+
56
+ @model_validator(mode="after")
57
+ def password_fields_have_help(self) -> MarketplaceField:
58
+ if self.type == "password" and not self.helperText:
59
+ raise ValueError("Password marketplace fields require helperText.")
60
+ return self
61
+
62
+
63
+ class StreamableHttpTransport(CatalogModel):
64
+ kind: Literal["shttp"]
65
+ url: HttpsUrl
66
+ apiKeyOptional: bool | None = None
67
+ headerFields: list[MarketplaceField] | None = None
68
+ urlEditable: bool | None = None
69
+
70
+
71
+ class SseTransport(CatalogModel):
72
+ kind: Literal["sse"]
73
+ url: HttpsUrl
74
+ apiKeyOptional: bool | None = None
75
+ headerFields: list[MarketplaceField] | None = None
76
+ urlEditable: bool | None = None
77
+
78
+
79
+ class StdioTransport(CatalogModel):
80
+ kind: Literal["stdio"]
81
+ serverName: NonEmptyString
82
+ command: NonEmptyString
83
+ args: list[str]
84
+ envFields: list[MarketplaceField] | None = None
85
+ argFields: list[MarketplaceField] | None = None
86
+
87
+
88
+ IntegrationTransport = Annotated[
89
+ StreamableHttpTransport | SseTransport | StdioTransport,
90
+ Field(discriminator="kind"),
91
+ ]
92
+
93
+
94
+ class IntegrationOAuthConfig(CatalogModel):
95
+ authorizationUrl: HttpsUrl | None = None
96
+ tokenUrl: HttpsUrl | None = None
97
+ scopes: list[NonEmptyString] | None = None
98
+ optionalScopes: list[NonEmptyString] | None = None
99
+ toolScopes: list[NonEmptyString] | None = None
100
+ scopeSeparator: Literal["space", "comma"] | None = None
101
+ pkce: bool | None = None
102
+ clientAuthentication: Literal["basic", "body", "none"] | None = None
103
+ registrationUrl: HttpsUrl | None = None
104
+ additionalAuthorizationParams: dict[str, str] | None = None
105
+ additionalTokenParams: dict[str, str] | None = None
106
+
107
+
108
+ class IntegrationAuthConfig(CatalogModel):
109
+ strategy: IntegrationAuthStrategy
110
+ authModes: list[IntegrationAuthStrategy] | None = Field(default=None, min_length=1)
111
+ credentialLabel: NonEmptyString | None = None
112
+ credentialPlaceholder: NonEmptyString | None = None
113
+ credentialHelp: NonEmptyString | None = None
114
+ credentialSecretName: NonEmptyString | None = None
115
+ saveCredentialAsSecretByDefault: bool | None = None
116
+ apiKeyHeaderName: NonEmptyString | None = None
117
+ apiKeyOptional: bool | None = None
118
+ oauth: IntegrationOAuthConfig | None = None
119
+
120
+
121
+ class IntegrationHttpConfig(CatalogModel):
122
+ apiBaseUrl: HttpsUrl
123
+ openApiUrl: HttpsUrl
124
+
125
+
126
+ class IntegrationIdentityMapping(CatalogModel):
127
+ source: Literal["oauth_token_response", "access_token_claims", "identity_api"]
128
+ endpoint: HttpsUrl | None = None
129
+ externalPrincipalIdPath: IdentityPath
130
+ externalTenantIdPath: IdentityPath | None = None
131
+ externalResourceIdPath: IdentityPath | None = None
132
+ resourceNamePath: IdentityPath | None = None
133
+ resourceUrlPath: IdentityPath | None = None
134
+
135
+ @model_validator(mode="after")
136
+ def identity_api_requires_endpoint(self) -> IntegrationIdentityMapping:
137
+ if self.source == "identity_api" and not self.endpoint:
138
+ raise ValueError("identity_api mappings require an endpoint.")
139
+ if self.source != "identity_api" and self.endpoint:
140
+ raise ValueError("Only identity_api mappings may define an endpoint.")
141
+ return self
142
+
143
+
144
+ class IntegrationResourceDiscovery(CatalogModel):
145
+ endpoint: HttpsUrl
146
+ itemsPath: IdentityPath | None = None
147
+ externalResourceIdPath: IdentityPath
148
+ resourceNamePath: IdentityPath | None = None
149
+ resourceUrlPath: IdentityPath | None = None
150
+
151
+
152
+ class IntegrationConnectionModel(CatalogModel):
153
+ principalType: Literal["user", "bot", "service_account", "application"]
154
+ credentialScope: Literal["account", "tenant", "organization", "resource"]
155
+ resourceType: NonEmptyString
156
+ resourceCardinality: Literal["one", "many"]
157
+ selectionMode: Literal["automatic", "post_auth", "runtime"]
158
+ identityMapping: IntegrationIdentityMapping | None = None
159
+ resourceDiscovery: IntegrationResourceDiscovery | None = None
160
+
161
+ @model_validator(mode="after")
162
+ def post_auth_requires_resource_discovery(self) -> IntegrationConnectionModel:
163
+ if self.selectionMode == "post_auth" and not self.resourceDiscovery:
164
+ raise ValueError("post_auth connections require resourceDiscovery.")
165
+ return self
166
+
167
+
168
+ class McpConnectionOption(CatalogModel):
169
+ id: NonEmptyString
170
+ provider: Literal["mcp"]
171
+ transport: IntegrationTransport
172
+ auth: IntegrationAuthConfig
173
+ connectionModel: IntegrationConnectionModel | None = None
174
+
175
+
176
+ class HttpConnectionOption(CatalogModel):
177
+ id: NonEmptyString
178
+ provider: Literal["http"]
179
+ http: IntegrationHttpConfig
180
+ auth: IntegrationAuthConfig
181
+ connectionModel: IntegrationConnectionModel | None = None
182
+
183
+
184
+ IntegrationConnectionOption = Annotated[
185
+ McpConnectionOption | HttpConnectionOption,
186
+ Field(discriminator="provider"),
187
+ ]
188
+
189
+
190
+ class IntegrationCatalogEntry(CatalogModel):
191
+ id: NonEmptyString
192
+ name: NonEmptyString
193
+ description: NonEmptyString
194
+ categories: list[NonEmptyString] | None = Field(default=None, min_length=1)
195
+ appUrl: HttpsUrl | None = None
196
+ docsUrl: HttpsUrl
197
+ notes: NonEmptyString | None = None
198
+ iconBg: NonEmptyString | None = None
199
+ iconColor: NonEmptyString | None = None
200
+ logoUrl: HttpsUrl | None = None
201
+ keywords: list[NonEmptyString] | None = None
202
+ popularityRank: int | None = Field(default=None, ge=0)
203
+ installHint: NonEmptyString | None = None
204
+ connectionOptions: list[IntegrationConnectionOption] = Field(min_length=1)
205
+
206
+ @field_validator("categories", "keywords")
207
+ @classmethod
208
+ def list_values_are_unique(
209
+ cls, values: list[NonEmptyString] | None
210
+ ) -> list[NonEmptyString] | None:
211
+ if values is not None and len(values) != len(set(values)):
212
+ raise ValueError("Catalog list values must be unique.")
213
+ return values
214
+
215
+ @model_validator(mode="after")
216
+ def validate_auth_requirements(self) -> IntegrationCatalogEntry:
217
+ for option in self.connectionOptions:
218
+ if option.provider == "http" and option.auth.strategy == "api_key":
219
+ if not option.auth.apiKeyHeaderName:
220
+ raise ValueError(
221
+ "HTTP API-key connections require apiKeyHeaderName."
222
+ )
223
+ if option.provider == "http" and option.auth.strategy == "oauth2":
224
+ oauth = option.auth.oauth
225
+ if not oauth or not oauth.authorizationUrl or not oauth.tokenUrl:
226
+ raise ValueError(
227
+ "HTTP OAuth connections require authorizationUrl and tokenUrl."
228
+ )
229
+ return self
230
+
231
+
232
+ class IntegrationCatalogSnapshot(CatalogModel):
233
+ integrations: list[IntegrationCatalogEntry]
@@ -14,10 +14,16 @@ from importlib import resources
14
14
  from pathlib import Path
15
15
  from typing import Any, Iterable
16
16
 
17
+ from .deprecation import deprecated
18
+ from .integration_models import IntegrationCatalogEntry
19
+
20
+
17
21
  __all__ = [
18
22
  "INTEGRATION_CATALOG_SNAPSHOT",
19
23
  "get_integration_catalog_entry",
24
+ "get_integration_catalog_entry_model",
20
25
  "list_integration_catalog",
26
+ "list_integration_catalog_models",
21
27
  ]
22
28
 
23
29
 
@@ -45,7 +51,14 @@ def _read_json(path: Any) -> dict[str, Any]:
45
51
  def _integrations() -> tuple[dict[str, Any], ...]:
46
52
  entries = [_read_json(path) for path in _catalog_files()]
47
53
  entries.sort(
48
- key=lambda entry: (-(entry.get("popularityRank") if entry.get("popularityRank") is not None else -1), entry["id"]),
54
+ key=lambda entry: (
55
+ -(
56
+ entry.get("popularityRank")
57
+ if entry.get("popularityRank") is not None
58
+ else -1
59
+ ),
60
+ entry["id"],
61
+ ),
49
62
  )
50
63
  return tuple(entries)
51
64
 
@@ -56,7 +69,9 @@ def _integration_by_id() -> dict[str, dict[str, Any]]:
56
69
 
57
70
 
58
71
  def _entry_supports_mcp(entry: dict[str, Any]) -> bool:
59
- return any(option.get("provider") == "mcp" for option in entry.get("connectionOptions", []))
72
+ return any(
73
+ option.get("provider") == "mcp" for option in entry.get("connectionOptions", [])
74
+ )
60
75
 
61
76
 
62
77
  def _entry_supports_oauth(entry: dict[str, Any]) -> bool:
@@ -66,11 +81,20 @@ def _entry_supports_oauth(entry: dict[str, Any]) -> bool:
66
81
  )
67
82
 
68
83
 
84
+ @deprecated(
85
+ deprecated_in="0.10.0",
86
+ removed_in="0.12.0",
87
+ details="Use list_integration_catalog_models() for validated Pydantic models.",
88
+ )
69
89
  def list_integration_catalog(
70
90
  mcp: bool | None = None,
71
91
  oauth: bool | None = None,
72
92
  ) -> list[dict[str, Any]]:
73
- """Return the integration catalog, optionally filtered by connector type."""
93
+ """Return raw catalog dictionaries, optionally filtered by connector type.
94
+
95
+ Deprecated since v0.10.0 and scheduled for removal in v0.12.0.
96
+ Use :func:`list_integration_catalog_models` instead.
97
+ """
74
98
  result = []
75
99
  for entry in _integrations():
76
100
  if mcp is not None and _entry_supports_mcp(entry) != mcp:
@@ -81,12 +105,43 @@ def list_integration_catalog(
81
105
  return result
82
106
 
83
107
 
108
+ def list_integration_catalog_models(
109
+ mcp: bool | None = None,
110
+ oauth: bool | None = None,
111
+ ) -> list[IntegrationCatalogEntry]:
112
+ """Return typed, validated integration catalog entries.
113
+
114
+ ``list_integration_catalog`` remains available for callers that need the
115
+ original JSON-compatible dictionaries. New Python consumers should prefer
116
+ this function for a stable, validated contract.
117
+ """
118
+ return [
119
+ IntegrationCatalogEntry.model_validate(entry)
120
+ for entry in list_integration_catalog(mcp=mcp, oauth=oauth)
121
+ ]
122
+
123
+
124
+ @deprecated(
125
+ deprecated_in="0.10.0",
126
+ removed_in="0.12.0",
127
+ details="Use get_integration_catalog_entry_model() for a validated Pydantic model.",
128
+ )
84
129
  def get_integration_catalog_entry(id: str) -> dict[str, Any] | None:
85
- """Return one integration catalog entry by id, or ``None``."""
130
+ """Return one raw catalog dictionary by id, or ``None``.
131
+
132
+ Deprecated since v0.10.0 and scheduled for removal in v0.12.0.
133
+ Use :func:`get_integration_catalog_entry_model` instead.
134
+ """
86
135
  entry = _integration_by_id().get(id)
87
136
  return copy.deepcopy(entry) if entry is not None else None
88
137
 
89
138
 
139
+ def get_integration_catalog_entry_model(id: str) -> IntegrationCatalogEntry | None:
140
+ """Return one typed catalog entry, or ``None`` when it does not exist."""
141
+ entry = get_integration_catalog_entry(id)
142
+ return IntegrationCatalogEntry.model_validate(entry) if entry is not None else None
143
+
144
+
90
145
  INTEGRATION_CATALOG_SNAPSHOT: dict[str, Any] = {
91
146
  "integrations": copy.deepcopy(list(_integrations()))
92
147
  }