Skip to content

Commit e7f7eb0

Browse files
committed
Show the services each provider toolset reaches in the registry
Toolsets became a registry module category without any record of what they connect to, so a visitor could see that a provider ships nine toolsets but not that one of them reaches every Airflow connection and another reads Apache Iceberg tables. Reading the same provider.yaml field the documentation page reads keeps the two from telling different stories about the same release.
1 parent 6569028 commit e7f7eb0

8 files changed

Lines changed: 187 additions & 11 deletions

‎dev/registry/extract_metadata.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@ class Provider:
376376
connection_types: list[dict] = field(
377377
default_factory=list
378378
) # {conn_type, hook_class, docs_url, external_services}
379+
toolset_services: list[dict] = field(default_factory=list) # {module, services}
379380
requires_python: str = "" # e.g., ">=3.10"
380381
dependencies: list[str] = field(default_factory=list) # from pyproject.toml
381382
optional_extras: dict[str, list[str]] = field(default_factory=dict) # {extra_name: [deps]}
@@ -795,6 +796,15 @@ def main():
795796
}
796797
)
797798

799+
toolset_services = [
800+
{
801+
"module": entry.get("module", ""),
802+
"services": entry.get("services", []),
803+
}
804+
for toolset in provider_yaml.get("toolsets", [])
805+
for entry in toolset.get("external-services", [])
806+
]
807+
798808
# Pre-fetched in parallel before this loop; missing entries fall back
799809
# to zero-value defaults so a never-published provider doesn't crash.
800810
pypi_downloads, pypi_dates = pypi_data.get(
@@ -823,6 +833,7 @@ def main():
823833
pypi_downloads=pypi_downloads,
824834
categories=[asdict(c) for c in categories],
825835
connection_types=connection_types,
836+
toolset_services=toolset_services,
826837
requires_python=pyproject_data["requires_python"],
827838
dependencies=pyproject_data["dependencies"],
828839
optional_extras=pyproject_data.get("optional_extras", {}),

‎dev/registry/extract_versions.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,15 @@ def extract_version_data(
453453
}
454454
)
455455

456+
toolset_services = [
457+
{
458+
"module": entry.get("module", ""),
459+
"services": entry.get("services", []),
460+
}
461+
for toolset in provider_yaml.get("toolsets", [])
462+
for entry in toolset.get("external-services", [])
463+
]
464+
456465
# Extract modules from source files
457466
modules = extract_modules_from_yaml(provider_yaml, tag, layout, dir_path, provider_id, version)
458467
module_counts = count_modules(modules)
@@ -466,6 +475,7 @@ def extract_version_data(
466475
"dependencies": pyproject_data["dependencies"],
467476
"optional_extras": pyproject_data["optional_extras"],
468477
"connection_types": connection_types,
478+
"toolset_services": toolset_services,
469479
"module_counts": module_counts,
470480
"modules": modules,
471481
}

‎dev/registry/registry_contract_models.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ class ConnectionTypeContract(BaseModel):
5555
external_services: list[str] = Field(default_factory=list)
5656

5757

58+
class ToolsetServicesContract(BaseModel):
59+
model_config = ConfigDict(extra="forbid")
60+
61+
module: str
62+
services: list[str] = Field(default_factory=list)
63+
64+
5865
class ProviderContract(BaseModel):
5966
"""Top-level provider entry in providers.json."""
6067

@@ -73,6 +80,7 @@ class ProviderContract(BaseModel):
7380
module_counts: dict[str, int] = Field(default_factory=dict)
7481
categories: list[CategoryContract] = Field(default_factory=list)
7582
connection_types: list[ConnectionTypeContract] = Field(default_factory=list)
83+
toolset_services: list[ToolsetServicesContract] = Field(default_factory=list)
7684
requires_python: str = ""
7785
dependencies: list[str] = Field(default_factory=list)
7886
optional_extras: dict[str, list[str]] = Field(default_factory=dict)
@@ -212,6 +220,7 @@ class ProviderVersionMetadataContract(BaseModel):
212220
dependencies: list[str]
213221
optional_extras: dict[str, list[str]]
214222
connection_types: list[ConnectionTypeContract]
223+
toolset_services: list[ToolsetServicesContract] = Field(default_factory=list)
215224
module_counts: dict[str, int]
216225
modules: list[ModuleContract]
217226

‎dev/registry/tests/test_extract_metadata.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,3 +884,53 @@ def test_external_services_propagates_to_providers_json(
884884
written = json.loads((output_dir / "providers.json").read_text())
885885
provider = next(p for p in written["providers"] if p["id"] == "testprov")
886886
assert provider["connection_types"][0]["external_services"] == ["openai", "anthropic"]
887+
888+
@patch("extract_metadata.fetch_provider_inventory", autospec=True, return_value=None)
889+
@patch("extract_metadata.fetch_pypi_data_parallel", autospec=True, return_value={})
890+
@patch("extract_metadata.load_release_tags", autospec=True, return_value=set())
891+
def test_toolset_external_services_propagates_to_providers_json(
892+
self, _load_release_tags, _fetch_pypi_data_parallel, _fetch_provider_inventory, tmp_path
893+
):
894+
"""Same drift-guard as above (`ToolsetServicesContract`, extra="forbid"), for the
895+
`toolsets.external-services` sub-key added alongside `python-modules`.
896+
"""
897+
providers_dir = tmp_path / "providers"
898+
provider_dir = providers_dir / "testprov"
899+
provider_dir.mkdir(parents=True)
900+
(provider_dir / "provider.yaml").write_text(
901+
textwrap.dedent("""\
902+
name: Test Provider
903+
description: A test provider.
904+
versions:
905+
- 1.0.0
906+
toolsets:
907+
- integration-name: Test Provider
908+
python-modules:
909+
- airflow.providers.test.toolsets.hook
910+
external-services:
911+
- module: airflow.providers.test.toolsets.hook
912+
services:
913+
- Any Airflow connection, through its provider hook
914+
""")
915+
)
916+
output_dir = tmp_path / "output"
917+
script_dir = tmp_path / "script"
918+
output_dir.mkdir()
919+
script_dir.mkdir()
920+
921+
with (
922+
patch("extract_metadata.PROVIDERS_DIR", providers_dir),
923+
patch("extract_metadata.OUTPUT_DIR", output_dir),
924+
patch("extract_metadata.SCRIPT_DIR", script_dir),
925+
patch.object(sys, "argv", ["extract_metadata.py"]),
926+
):
927+
main()
928+
929+
written = json.loads((output_dir / "providers.json").read_text())
930+
provider = next(p for p in written["providers"] if p["id"] == "testprov")
931+
assert provider["toolset_services"] == [
932+
{
933+
"module": "airflow.providers.test.toolsets.hook",
934+
"services": ["Any Airflow connection, through its provider hook"],
935+
}
936+
]

‎dev/registry/tests/test_extract_versions.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,45 @@ def test_external_services_propagates_to_version_metadata(
210210

211211
assert result is not None
212212
assert result["connection_types"][0]["external_services"] == ["openai", "anthropic"]
213+
214+
215+
class TestExtractVersionDataToolsetServices:
216+
"""Same drift-guard as `TestExtractVersionDataConnectionTypes`, for
217+
`toolsets.external-services` -- this is the touchpoint memory
218+
`reference_registry_connection_field_touchpoints` names as most-missed: skip it and the
219+
toolsets table silently disappears once a version is no longer `pv.isLatest`.
220+
"""
221+
222+
PROVIDER_YAML = textwrap.dedent("""\
223+
name: Test Provider
224+
toolsets:
225+
- integration-name: Test Provider
226+
python-modules:
227+
- airflow.providers.test.toolsets.hook
228+
external-services:
229+
- module: airflow.providers.test.toolsets.hook
230+
services:
231+
- Any Airflow connection, through its provider hook
232+
""")
233+
234+
@patch("extract_versions.extract_modules_from_yaml", autospec=True, return_value=[])
235+
@patch("extract_versions.fetch_provider_inventory", autospec=True, return_value=None)
236+
@patch("extract_versions.git_show", autospec=True)
237+
@patch("extract_versions.detect_layout", autospec=True, return_value="new")
238+
@patch("extract_versions.git_tag_exists", autospec=True, return_value=True)
239+
def test_toolset_external_services_propagates_to_version_metadata(
240+
self, _tag_exists, _layout, mock_git_show, _inventory, _modules
241+
):
242+
mock_git_show.side_effect = lambda tag, path: (
243+
self.PROVIDER_YAML if path.endswith("provider.yaml") else None
244+
)
245+
246+
result = extract_version_data("test", "1.0.0", "test")
247+
248+
assert result is not None
249+
assert result["toolset_services"] == [
250+
{
251+
"module": "airflow.providers.test.toolsets.hook",
252+
"services": ["Any Airflow connection, through its provider hook"],
253+
}
254+
]

‎dev/registry/tests/test_registry_contract_models.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from pydantic import ValidationError
2323
from registry_contract_models import (
2424
ConnectionTypeContract,
25+
ToolsetServicesContract,
2526
build_openapi_document,
2627
validate_modules_catalog,
2728
validate_provider_parameters,
@@ -119,6 +120,18 @@ def test_connection_type_contract_round_trips_external_services():
119120
assert validated.external_services == ["openai", "anthropic"]
120121

121122

123+
def test_toolset_services_contract_defaults_services_to_empty_list():
124+
validated = ToolsetServicesContract.model_validate({"module": "airflow.providers.test.toolsets.hook"})
125+
assert validated.services == []
126+
127+
128+
def test_toolset_services_contract_rejects_unknown_fields():
129+
with pytest.raises(ValidationError):
130+
ToolsetServicesContract.model_validate(
131+
{"module": "airflow.providers.test.toolsets.hook", "services": [], "unexpected": "x"}
132+
)
133+
134+
122135
def test_validate_version_metadata_accepts_legacy_version_modules_without_ids():
123136
payload = {
124137
"provider_id": "test",

‎registry/src/css/main.css‎

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3702,13 +3702,17 @@ main {
37023702
background: rgb(from var(--color-purple-500) r g b / 0.1);
37033703
}
37043704

3705-
/* Connection Builder Card */
3706-
.provider-detail-page .connections-card {
3705+
/* Connection Builder Card (also shared by the Toolsets card, which has no
3706+
chip grid / builder panel of its own but reuses the card padding, heading
3707+
style, and external-services table below). */
3708+
.provider-detail-page .connections-card,
3709+
.provider-detail-page .toolset-services-card {
37073710
padding: var(--space-5);
37083711
margin-bottom: var(--space-6);
37093712
}
37103713

3711-
.provider-detail-page .connections-card > h3 {
3714+
.provider-detail-page .connections-card > h3,
3715+
.provider-detail-page .toolset-services-card > h3 {
37123716
font-size: var(--text-sm);
37133717
font-weight: var(--font-semibold);
37143718
color: var(--text-primary);
@@ -3718,7 +3722,8 @@ main {
37183722
gap: var(--space-2);
37193723
}
37203724

3721-
.provider-detail-page .connections-card > h3 svg {
3725+
.provider-detail-page .connections-card > h3 svg,
3726+
.provider-detail-page .toolset-services-card > h3 svg {
37223727
width: 1rem;
37233728
height: 1rem;
37243729
color: var(--color-green-400);
@@ -3761,14 +3766,17 @@ main {
37613766
border-color: var(--color-green-400);
37623767
}
37633768

3764-
/* Per-connection external services matrix */
3765-
.provider-detail-page .connections-card .conn-external-services-table {
3769+
/* Per-connection external services matrix (also used, unmodified, by the
3770+
Toolsets card's module -> external-services table). */
3771+
.provider-detail-page .connections-card .conn-external-services-table,
3772+
.provider-detail-page .toolset-services-card .conn-external-services-table {
37663773
width: 100%;
37673774
margin-top: var(--space-4);
37683775
border-collapse: collapse;
37693776
}
37703777

3771-
.provider-detail-page .connections-card .conn-external-services-header th {
3778+
.provider-detail-page .connections-card .conn-external-services-header th,
3779+
.provider-detail-page .toolset-services-card .conn-external-services-header th {
37723780
padding: var(--space-2) var(--space-3);
37733781
font-size: var(--text-sm);
37743782
font-weight: var(--font-semibold);
@@ -3777,20 +3785,24 @@ main {
37773785
background: var(--bg-tertiary);
37783786
}
37793787

3780-
.provider-detail-page .connections-card .conn-external-services-row {
3788+
.provider-detail-page .connections-card .conn-external-services-row,
3789+
.provider-detail-page .toolset-services-card .conn-external-services-row {
37813790
border-top: 1px solid var(--border-primary);
37823791
}
37833792

3784-
.provider-detail-page .connections-card .conn-external-services-row td {
3793+
.provider-detail-page .connections-card .conn-external-services-row td,
3794+
.provider-detail-page .toolset-services-card .conn-external-services-row td {
37853795
padding: var(--space-2) var(--space-3);
37863796
vertical-align: top;
37873797
}
37883798

3789-
.provider-detail-page .connections-card .conn-external-services-row td:first-child {
3799+
.provider-detail-page .connections-card .conn-external-services-row td:first-child,
3800+
.provider-detail-page .toolset-services-card .conn-external-services-row td:first-child {
37903801
white-space: nowrap;
37913802
}
37923803

3793-
.provider-detail-page .connections-card .conn-external-services-list {
3804+
.provider-detail-page .connections-card .conn-external-services-list,
3805+
.provider-detail-page .toolset-services-card .conn-external-services-list {
37943806
color: var(--text-secondary);
37953807
font-size: var(--text-sm);
37963808
}

‎registry/src/provider-version.njk‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ eleventyComputed:
1818
{% set deps = pv.provider.dependencies or [] %}
1919
{% set extras = pv.provider.optional_extras or {} %}
2020
{% set conns = pv.provider.connection_types or [] %}
21+
{% set toolsetServices = pv.provider.toolset_services or [] %}
2122
{% set providerModules = pv.modules %}
2223
{% set currentVersion = pv.provider.version %}
2324
{% set pythonVersion = pv.provider.requires_python %}
@@ -28,6 +29,7 @@ eleventyComputed:
2829
{% set deps = pv.versionData.dependencies or [] %}
2930
{% set extras = pv.versionData.optional_extras or {} %}
3031
{% set conns = pv.versionData.connection_types or [] %}
32+
{% set toolsetServices = pv.versionData.toolset_services or [] %}
3133
{% set providerModules = pv.versionData.modules or [] %}
3234
{% set currentVersion = pv.version %}
3335
{% set pythonVersion = pv.versionData.requires_python %}
@@ -299,6 +301,33 @@ eleventyComputed:
299301
{% endif %}
300302
</section>
301303

304+
{% if toolsetServices.length > 0 %}
305+
<section class="toolset-services-card card">
306+
<h3>
307+
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
308+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
309+
</svg>
310+
Toolsets ({{ toolsetServices.length }})
311+
</h3>
312+
<table class="conn-external-services-table">
313+
<thead>
314+
<tr class="conn-external-services-header">
315+
<th>Toolset module</th>
316+
<th>External services (examples)</th>
317+
</tr>
318+
</thead>
319+
<tbody>
320+
{% for entry in toolsetServices %}
321+
<tr class="conn-external-services-row">
322+
<td><code>{{ entry.module }}</code></td>
323+
<td class="conn-external-services-list">{{ entry.services | join(", ") }}</td>
324+
</tr>
325+
{% endfor %}
326+
</tbody>
327+
</table>
328+
</section>
329+
{% endif %}
330+
302331
{% if totalModules > 0 %}
303332
{# Module Type Tabs: sorted by module count (busiest first), overflow into a "More" menu #}
304333
{% set moduleTabsVisibleCount = 8 %}

0 commit comments

Comments
 (0)