@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.
@@ -4,9 +4,12 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import subprocess
7
+ from copy import deepcopy
7
8
  from pathlib import Path
8
9
 
9
10
  import openhands_extensions
11
+ import pytest
12
+ from pydantic import ValidationError
10
13
 
11
14
  ROOT = Path(__file__).resolve().parents[1]
12
15
  CATALOG_DIR = ROOT / "integrations" / "catalog"
@@ -23,7 +26,17 @@ def _catalog_files() -> dict[str, dict]:
23
26
 
24
27
  def _catalog_entries() -> list[dict]:
25
28
  entries = list(_catalog_files().values())
26
- return sorted(entries, key=lambda entry: (-(entry.get("popularityRank") if entry.get("popularityRank") is not None else -1), entry["id"]))
29
+ return sorted(
30
+ entries,
31
+ key=lambda entry: (
32
+ -(
33
+ entry.get("popularityRank")
34
+ if entry.get("popularityRank") is not None
35
+ else -1
36
+ ),
37
+ entry["id"],
38
+ ),
39
+ )
27
40
 
28
41
 
29
42
  def _supports_mcp(entry: dict) -> bool:
@@ -31,8 +44,10 @@ def _supports_mcp(entry: dict) -> bool:
31
44
 
32
45
 
33
46
  def _supports_oauth(entry: dict) -> bool:
34
- return any(option.get("auth", {}).get("strategy") == "oauth2" for option in entry["connectionOptions"])
35
-
47
+ return any(
48
+ option.get("auth", {}).get("strategy") == "oauth2"
49
+ for option in entry["connectionOptions"]
50
+ )
36
51
 
37
52
 
38
53
  def test_catalog_directory_is_hand_authored_source_of_truth() -> None:
@@ -84,6 +99,95 @@ def test_python_list_integration_catalog_returns_raw_entries() -> None:
84
99
  assert "runtimeAvailability" not in entry
85
100
 
86
101
 
102
+ def test_python_list_integration_catalog_models_returns_typed_entries() -> None:
103
+ entries = openhands_extensions.list_integration_catalog()
104
+ models = openhands_extensions.list_integration_catalog_models()
105
+
106
+ assert [model.model_dump(exclude_none=True) for model in models] == entries
107
+ assert all(
108
+ isinstance(model, openhands_extensions.IntegrationCatalogEntry)
109
+ for model in models
110
+ )
111
+ assert (
112
+ openhands_extensions.get_integration_catalog_entry_model(entries[0]["id"])
113
+ == models[0]
114
+ )
115
+ assert openhands_extensions.get_integration_catalog_entry_model("missing") is None
116
+
117
+ with pytest.raises(ValidationError):
118
+ openhands_extensions.IntegrationCatalogEntry.model_validate(
119
+ {**entries[0], "unexpected": True}
120
+ )
121
+
122
+
123
+ def test_http_catalog_options_require_both_urls() -> None:
124
+ entry = next(
125
+ entry
126
+ for entry in _catalog_entries()
127
+ if any(option["provider"] == "http" for option in entry["connectionOptions"])
128
+ )
129
+ option = next(
130
+ option for option in entry["connectionOptions"] if option["provider"] == "http"
131
+ )
132
+
133
+ for field in ("apiBaseUrl", "openApiUrl"):
134
+ invalid = deepcopy(entry)
135
+ invalid_option = next(
136
+ item for item in invalid["connectionOptions"] if item["id"] == option["id"]
137
+ )
138
+ invalid_option["http"].pop(field)
139
+ with pytest.raises(ValidationError, match=field):
140
+ openhands_extensions.IntegrationCatalogEntry.model_validate(invalid)
141
+
142
+
143
+ def test_catalog_models_require_connectability_and_install_metadata() -> None:
144
+ entry = next(entry for entry in _catalog_entries() if entry["id"] == "slack")
145
+
146
+ missing_docs = deepcopy(entry)
147
+ missing_docs.pop("docsUrl")
148
+ with pytest.raises(ValidationError, match="docsUrl"):
149
+ openhands_extensions.IntegrationCatalogEntry.model_validate(missing_docs)
150
+
151
+ missing_field_type = deepcopy(entry)
152
+ missing_field_type["connectionOptions"][1]["transport"]["envFields"][0].pop("type")
153
+ with pytest.raises(ValidationError, match="type"):
154
+ openhands_extensions.IntegrationCatalogEntry.model_validate(missing_field_type)
155
+
156
+ missing_required_marker = deepcopy(entry)
157
+ missing_required_marker["connectionOptions"][1]["transport"]["envFields"][0].pop(
158
+ "required"
159
+ )
160
+ with pytest.raises(ValidationError, match="required"):
161
+ openhands_extensions.IntegrationCatalogEntry.model_validate(
162
+ missing_required_marker
163
+ )
164
+
165
+ missing_principal_mapping = deepcopy(entry)
166
+ missing_principal_mapping["connectionOptions"][0]["connectionModel"][
167
+ "identityMapping"
168
+ ].pop("externalPrincipalIdPath")
169
+ with pytest.raises(ValidationError, match="externalPrincipalIdPath"):
170
+ openhands_extensions.IntegrationCatalogEntry.model_validate(
171
+ missing_principal_mapping
172
+ )
173
+
174
+ null_instead_of_omission = deepcopy(entry)
175
+ null_instead_of_omission["notes"] = None
176
+ with pytest.raises(ValidationError, match="must be omitted"):
177
+ openhands_extensions.IntegrationCatalogEntry.model_validate(
178
+ null_instead_of_omission
179
+ )
180
+
181
+ invalid_identity_path = deepcopy(entry)
182
+ invalid_identity_path["connectionOptions"][0]["connectionModel"]["identityMapping"][
183
+ "externalPrincipalIdPath"
184
+ ] = "not/a/path"
185
+ with pytest.raises(ValidationError, match="pattern"):
186
+ openhands_extensions.IntegrationCatalogEntry.model_validate(
187
+ invalid_identity_path
188
+ )
189
+
190
+
87
191
  def test_logo_metadata_is_serializable_and_language_agnostic() -> None:
88
192
  entries = openhands_extensions.list_integration_catalog()
89
193
  with_logo = [entry for entry in entries if entry.get("logoUrl")]
@@ -130,7 +234,9 @@ def test_js_reads_the_same_catalog_as_python() -> None:
130
234
  "import { getIntegrationCatalogEntry } from './integrations/index.js';\n"
131
235
  f"process.stdout.write(JSON.stringify(getIntegrationCatalogEntry({json.dumps(sample_id)})));"
132
236
  )
133
- assert json.loads(sample) == openhands_extensions.get_integration_catalog_entry(sample_id)
237
+ assert json.loads(sample) == openhands_extensions.get_integration_catalog_entry(
238
+ sample_id
239
+ )
134
240
 
135
241
 
136
242
  def _js_filter(mcp, oauth) -> list[str]:
@@ -155,7 +261,9 @@ def test_filter_mcp_only() -> None:
155
261
 
156
262
 
157
263
  def test_filter_oauth_only() -> None:
158
- py_ids = {e["id"] for e in openhands_extensions.list_integration_catalog(oauth=True)}
264
+ py_ids = {
265
+ e["id"] for e in openhands_extensions.list_integration_catalog(oauth=True)
266
+ }
159
267
  js_ids = set(_js_filter("undefined", "true"))
160
268
  all_oauth = {
161
269
  e["id"]
@@ -168,7 +276,8 @@ def test_filter_oauth_only() -> None:
168
276
 
169
277
  def test_filter_oauth_not_mcp() -> None:
170
278
  py_ids = {
171
- e["id"] for e in openhands_extensions.list_integration_catalog(oauth=True, mcp=False)
279
+ e["id"]
280
+ for e in openhands_extensions.list_integration_catalog(oauth=True, mcp=False)
172
281
  }
173
282
  js_ids = set(_js_filter("false", "true"))
174
283
  expected = {
@@ -197,7 +306,9 @@ def test_accessors_return_independent_copies() -> None:
197
306
  sample = openhands_extensions.get_integration_catalog_entry(sample_id)
198
307
  assert sample is not None
199
308
  sample["__mutated"] = True
200
- assert "__mutated" not in openhands_extensions.get_integration_catalog_entry(sample_id)
309
+ assert "__mutated" not in openhands_extensions.get_integration_catalog_entry(
310
+ sample_id
311
+ )
201
312
 
202
313
  snapshot = openhands_extensions.INTEGRATION_CATALOG_SNAPSHOT
203
314
  snapshot["integrations"][0]["__mutated"] = True