This is an automated email from the ASF dual-hosted git repository. Lee-W pushed a commit to branch common-ai-supported-services-page in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 707d760e90167ac657c99739598299426016e724 Author: Wei Lee <[email protected]> AuthorDate: Fri Sep 25 20:56:08 2026 +0900 Keep the common.ai toolset services list specific to common.ai Toolsets are a common.ai concept rather than a provider-wide one, so a provider.yaml schema field and a globally registered Sphinx directive for them would invite other providers to adopt a shape that does not apply to them. The toolset table now lives with the common.ai docs and is guarded by the provider's own metadata tests; the connection-type services table keeps using the shared mechanism, since that field already exists for every provider. --- airflow-core/src/airflow/provider.yaml.schema.json | 24 ------- .../23_provider_hook_migration_to_yaml.rst | 27 -------- .../src/sphinx_exts/providers_extensions.py | 62 ------------------ .../templates/provider_toolset_services.rst.jinja2 | 28 -------- .../unit/sphinx_exts/test_providers_extensions.py | 58 ----------------- providers/common/ai/docs/model_providers.rst | 3 + providers/common/ai/docs/supported_services.rst | 30 ++++++++- providers/common/ai/provider.yaml | 31 --------- .../tests/unit/common/ai/test_provider_metadata.py | 75 +++++++++++++++++----- 9 files changed, 89 insertions(+), 249 deletions(-) diff --git a/airflow-core/src/airflow/provider.yaml.schema.json b/airflow-core/src/airflow/provider.yaml.schema.json index a4215cf640c..4c4a199a882 100644 --- a/airflow-core/src/airflow/provider.yaml.schema.json +++ b/airflow-core/src/airflow/provider.yaml.schema.json @@ -388,30 +388,6 @@ "items": { "type": "string" } - }, - "external-services": { - "description": "Per-module list of external services each toolset reaches. Representative, non-exhaustive. Each 'module' must also appear in 'python-modules'.", - "type": "array", - "items": { - "type": "object", - "properties": { - "module": { - "type": "string", - "description": "Dotted path of the toolset module, as listed in 'python-modules'." - }, - "services": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false, - "required": [ - "module", - "services" - ] - } } }, "additionalProperties": false, diff --git a/contributing-docs/23_provider_hook_migration_to_yaml.rst b/contributing-docs/23_provider_hook_migration_to_yaml.rst index b32aa2a5501..3b0069dbeb4 100644 --- a/contributing-docs/23_provider_hook_migration_to_yaml.rst +++ b/contributing-docs/23_provider_hook_migration_to_yaml.rst @@ -109,33 +109,6 @@ supported field options, see type: string default: "my-project" -Toolset metadata is defined under the separate top-level ``toolsets`` key: - -toolsets.external-services -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Per-module list of external services each toolset reaches, added as a sibling of -``python-modules`` inside a ``toolsets`` entry. Unlike the connection-type -``external-services`` key above, this one is not surfaced on the registry's -provider version page, and there is no built-in Sphinx rendering for it either -- -a provider that wants to show it must add its own docs page with the -``provider-toolset-services`` directive, the way ``common.ai`` does in -``providers/common/ai/docs/supported_services.rst`` (which also renders the -connection-type list there via ``provider-connection-services``). As with the -connection-type list, the entries are representative, not exhaustive. Every -``module`` value must also appear in that entry's ``python-modules`` list. - -.. code-block:: yaml - - toolsets: - - integration-name: Common AI - python-modules: - - airflow.providers.common.ai.toolsets.hook - external-services: - - module: airflow.providers.common.ai.toolsets.hook - services: - - Any Airflow connection, through its provider hook - Migration Tool -------------- diff --git a/devel-common/src/sphinx_exts/providers_extensions.py b/devel-common/src/sphinx_exts/providers_extensions.py index ca45c16981d..70be9ebbb59 100644 --- a/devel-common/src/sphinx_exts/providers_extensions.py +++ b/devel-common/src/sphinx_exts/providers_extensions.py @@ -534,57 +534,6 @@ def _render_connection_services_content(package_name: str) -> str: return _render_template("provider_connection_services.rst.jinja2", rows=rows) -# Display name for each toolset module: the class name for modules that map to one -# (`toolsets.rst` documents each under a `` ``ClassName`` `` heading), or a short -# descriptive name for the two that don't (`managed_agent` documents a family of -# provider-specific subclasses under "Managed Agent Toolsets"; `langchain_bridge` is a -# function, documented under "Working with LangChain"). Kept here rather than in -# `provider.yaml` because it is presentation-only, not metadata the registry also needs. -# -# Deliberately no `.get(basename, default)` fallback below: every module in -# `python-modules` must have an entry here, the same way `LABELS` and the anchor check -# in `test_provider_metadata.py` require full coverage. A missing entry for a new -# toolset module fails loudly with a `ValueError` naming the provider and module, not -# a silently rendered basename. -_TOOLSET_DISPLAY_NAMES = { - "hook": "HookToolset", - "sql": "SQLToolset", - "datafusion": "DataFusionToolset", - "logging": "LoggingToolset", - "mcp": "MCPToolset", - "skills": "AgentSkillsToolset", - "sandbox": "SandboxToolset", - "langchain_bridge": "LangChain Bridge", - "managed_agent": "Managed Agent Toolsets", -} - - -def _render_toolset_services_content(package_name: str) -> str: - provider = _find_provider_package_data(package_name) - rows = [] - for toolset in provider.get("toolsets", []): - services_by_module = { - entry["module"]: entry["services"] for entry in toolset.get("external-services") or [] - } - for module in toolset.get("python-modules", []): - basename = module.rsplit(".", 1)[-1] - try: - display_name = _TOOLSET_DISPLAY_NAMES[basename] - except KeyError as e: - raise ValueError( - f"No display name for toolset module '{module}' of provider " - f"'{package_name}'. Add an entry for '{basename}' to `_TOOLSET_DISPLAY_NAMES`." - ) from e - rows.append( - { - "display_name": display_name, - "services": services_by_module.get(module, []), - "ref": f"howto/toolset:{basename}", - } - ) - return _render_template("provider_toolset_services.rst.jinja2", rows=rows) - - class ProviderConnectionServicesDirective(BaseJinjaReferenceDirective): """Render a table of a provider's connection types and the external services each reaches.""" @@ -595,20 +544,9 @@ class ProviderConnectionServicesDirective(BaseJinjaReferenceDirective): return _render_connection_services_content(self.arguments[0]) -class ProviderToolsetServicesDirective(BaseJinjaReferenceDirective): - """Render a table of a provider's toolset modules and the external services each reaches.""" - - required_arguments = 1 - optional_arguments = 0 - - def render_content(self, *, tags: set[str] | None, header_separator: str = DEFAULT_HEADER_SEPARATOR): - return _render_toolset_services_content(self.arguments[0]) - - def setup(app): """Setup plugin""" app.add_directive("airflow-providers-openlineage-supported-classes", OpenLineageSupportedClassesDirective) app.add_directive("provider-connection-services", ProviderConnectionServicesDirective) - app.add_directive("provider-toolset-services", ProviderToolsetServicesDirective) return {"parallel_read_safe": True, "parallel_write_safe": True} diff --git a/devel-common/src/sphinx_exts/templates/provider_toolset_services.rst.jinja2 b/devel-common/src/sphinx_exts/templates/provider_toolset_services.rst.jinja2 deleted file mode 100644 index bc9059cc8cf..00000000000 --- a/devel-common/src/sphinx_exts/templates/provider_toolset_services.rst.jinja2 +++ /dev/null @@ -1,28 +0,0 @@ -{# - Licensed to the Apache Software Foundation (ASF) under one - or more contributor license agreements. See the NOTICE file - distributed with this work for additional information - regarding copyright ownership. The ASF licenses this file - to you under the Apache License, Version 2.0 (the - "License"); you may not use this file except in compliance - with the License. You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, - software distributed under the License is distributed on an - "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - KIND, either express or implied. See the License for the - specific language governing permissions and limitations - under the License. -#} -.. list-table:: - :header-rows: 1 - :widths: 30 70 - - * - Toolset - - External services - {% for row in rows -%} - * - :ref:`{{ row.display_name }} <{{ row.ref }}>` - - {{ row.services | join(', ') }} - {% endfor %} diff --git a/devel-common/tests/unit/sphinx_exts/test_providers_extensions.py b/devel-common/tests/unit/sphinx_exts/test_providers_extensions.py index 04ce64269e0..ac0e130a31d 100644 --- a/devel-common/tests/unit/sphinx_exts/test_providers_extensions.py +++ b/devel-common/tests/unit/sphinx_exts/test_providers_extensions.py @@ -22,7 +22,6 @@ import pytest from sphinx_exts.providers_extensions import ( _render_connection_services_content, - _render_toolset_services_content, ) FAKE_PACKAGE_DATA = [ @@ -36,25 +35,6 @@ FAKE_PACKAGE_DATA = [ "external-services": ["OpenAI", "Anthropic"], } ], - "toolsets": [ - { - "integration-name": "Example", - "python-modules": [ - "airflow.providers.example.toolsets.hook", - "airflow.providers.example.toolsets.mcp", - ], - "external-services": [ - { - "module": "airflow.providers.example.toolsets.hook", - "services": ["Any Airflow connection, through its provider hook"], - }, - { - "module": "airflow.providers.example.toolsets.mcp", - "services": ["Any MCP server (user-supplied endpoint)"], - }, - ], - } - ], } ] @@ -75,41 +55,3 @@ def test_render_connection_services_content_unknown_package_raises(mock_load_pac with pytest.raises(ValueError, match="No provider.yaml found"): _render_connection_services_content("apache-airflow-providers-does-not-exist") - - [email protected]("sphinx_exts.providers_extensions.load_package_data", autospec=True) -def test_render_toolset_services_content_renders_one_row_per_module(mock_load_package_data): - mock_load_package_data.return_value = FAKE_PACKAGE_DATA - - rendered = _render_toolset_services_content("apache-airflow-providers-example") - - assert ":ref:`HookToolset <howto/toolset:hook>`" in rendered - assert "Any Airflow connection, through its provider hook" in rendered - assert ":ref:`MCPToolset <howto/toolset:mcp>`" in rendered - assert "Any MCP server (user-supplied endpoint)" in rendered - - -FAKE_PACKAGE_DATA_UNKNOWN_TOOLSET_MODULE = [ - { - "package-name": "apache-airflow-providers-example", - "toolsets": [ - { - "integration-name": "Example", - "python-modules": [ - "airflow.providers.example.toolsets.unknown_toolset", - ], - "external-services": [], - } - ], - } -] - - [email protected]("sphinx_exts.providers_extensions.load_package_data", autospec=True) -def test_render_toolset_services_content_unknown_module_raises(mock_load_package_data): - mock_load_package_data.return_value = FAKE_PACKAGE_DATA_UNKNOWN_TOOLSET_MODULE - - with pytest.raises(ValueError, match="apache-airflow-providers-example") as exc_info: - _render_toolset_services_content("apache-airflow-providers-example") - - assert "airflow.providers.example.toolsets.unknown_toolset" in str(exc_info.value) diff --git a/providers/common/ai/docs/model_providers.rst b/providers/common/ai/docs/model_providers.rst index 7e31a70b648..a659b8be43b 100644 --- a/providers/common/ai/docs/model_providers.rst +++ b/providers/common/ai/docs/model_providers.rst @@ -26,6 +26,9 @@ name picks the vendor. Switching vendors is a connection change, not a Dag change. Find your vendor below, install the extra, create the connection type shown, and set the model name with that prefix. +For the exhaustive, generated-and-hand-written list of every service this provider +reaches (connections and toolsets alike), see :doc:`supported_services`. + .. list-table:: :header-rows: 1 :widths: 18 16 18 20 28 diff --git a/providers/common/ai/docs/supported_services.rst b/providers/common/ai/docs/supported_services.rst index 8e51f8f64a5..ad0e9e5c4c2 100644 --- a/providers/common/ai/docs/supported_services.rst +++ b/providers/common/ai/docs/supported_services.rst @@ -23,6 +23,9 @@ talk to, and the systems its toolsets talk to. For per-model detail beyond the v (exact model ids, pricing, capabilities), see `pydantic-ai's model list <https://ai.pydantic.dev/models/>`__. +This page is the complete list of what this provider reaches; for a curated, install- and +credential-oriented walkthrough of each model vendor, see :doc:`model_providers`. + Connections ----------- @@ -31,7 +34,30 @@ Connections Toolsets -------- -.. provider-toolset-services:: apache-airflow-providers-common-ai +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Toolset + - External services + * - :doc:`HookToolset <toolsets/hook>` + - Any Airflow connection, through its provider hook + * - :doc:`SQLToolset <toolsets/sql>` + - Any common.sql database connection (with table allowlists and AST validation) + * - :doc:`DataFusionToolset <toolsets/datafusion>` + - Object storage (S3, local filesystem, Apache Iceberg) via Apache DataFusion + * - :doc:`LoggingToolset <toolsets/logging>` + - Airflow task logs + * - :doc:`MCPToolset <toolsets/mcp>` + - Any MCP server (user-supplied endpoint) + * - :doc:`SandboxToolset <sandbox/index>` + - Docker Sandboxes (the shipped sbx backend; other backends can be added via SandboxBackend) + * - :doc:`AgentSkillsToolset <toolsets/skills>` + - Agent skills from local paths and remote Git repositories + * - :doc:`LangChain Bridge <toolsets/langchain>` + - LangChain tools + * - :doc:`Managed Agent Toolsets <toolsets/managed_agent>` + - Snowflake Cortex Agents; Amazon Bedrock AgentCore; Azure AI Foundry; Vertex AI Agent Engine Notes ----- @@ -45,7 +71,7 @@ Notes (:doc:`connections/pydantic_ai_azure`, :doc:`connections/pydantic_ai_vertex`, :doc:`connections/pydantic_ai_bedrock`) for their non-standard auth. * Most model providers need an extra installed alongside ``apache-airflow-providers-common-ai`` - — see the "Choosing extras" section of :doc:`index`. + — see the "Choosing extras" section of :doc:`installation`. * "Pydantic AI Gateway" in the ``pydanticai`` row is a routing layer, not an upstream vendor in its own right: set the connection's Model field to ``gateway/<vendor>:<model>`` (for example ``gateway/anthropic:claude-sonnet-5``) to send the request through it instead of diff --git a/providers/common/ai/provider.yaml b/providers/common/ai/provider.yaml index e7490068335..3fb1b944939 100644 --- a/providers/common/ai/provider.yaml +++ b/providers/common/ai/provider.yaml @@ -576,37 +576,6 @@ toolsets: - airflow.providers.common.ai.toolsets.skills - airflow.providers.common.ai.toolsets.langchain_bridge - airflow.providers.common.ai.toolsets.managed_agent - external-services: - - module: airflow.providers.common.ai.toolsets.hook - services: - - Any Airflow connection, through its provider hook - - module: airflow.providers.common.ai.toolsets.sql - services: - - Any common.sql database connection (with table allowlists and AST validation) - - module: airflow.providers.common.ai.toolsets.datafusion - services: - - Object storage (S3, local filesystem, Apache Iceberg) via Apache DataFusion - - module: airflow.providers.common.ai.toolsets.logging - services: - - Airflow task logs - - module: airflow.providers.common.ai.toolsets.mcp - services: - - Any MCP server (user-supplied endpoint) - - module: airflow.providers.common.ai.toolsets.sandbox - services: - - Docker Sandboxes (the shipped sbx backend; other backends can be added via SandboxBackend) - - module: airflow.providers.common.ai.toolsets.skills - services: - - Agent skills from local paths and remote Git repositories - - module: airflow.providers.common.ai.toolsets.langchain_bridge - services: - - LangChain tools - - module: airflow.providers.common.ai.toolsets.managed_agent - services: - - Snowflake Cortex Agents - - Amazon Bedrock AgentCore - - Azure AI Foundry - - Vertex AI Agent Engine retry-policies: - integration-name: Common AI diff --git a/providers/common/ai/tests/unit/common/ai/test_provider_metadata.py b/providers/common/ai/tests/unit/common/ai/test_provider_metadata.py index d684257cc6b..ddecbc9919c 100644 --- a/providers/common/ai/tests/unit/common/ai/test_provider_metadata.py +++ b/providers/common/ai/tests/unit/common/ai/test_provider_metadata.py @@ -27,6 +27,7 @@ from __future__ import annotations import importlib import pkgutil +import re from importlib.metadata import version from pathlib import Path @@ -332,26 +333,66 @@ class TestPydanticAIExternalServicesDrift: ) +# Module basename -> (display name, `:doc:` target) expected in `supported_services.rst`'s +# hand-written Toolsets table. Kept here (not in `src/`) because it is test data, not +# runtime behaviour, the same way `LABELS` above is. +TOOLSET_TABLE_ENTRIES: dict[str, tuple[str, str]] = { + "hook": ("HookToolset", "toolsets/hook"), + "sql": ("SQLToolset", "toolsets/sql"), + "datafusion": ("DataFusionToolset", "toolsets/datafusion"), + "logging": ("LoggingToolset", "toolsets/logging"), + "mcp": ("MCPToolset", "toolsets/mcp"), + "sandbox": ("SandboxToolset", "sandbox/index"), + "skills": ("AgentSkillsToolset", "toolsets/skills"), + "langchain_bridge": ("LangChain Bridge", "toolsets/langchain"), + "managed_agent": ("Managed Agent Toolsets", "toolsets/managed_agent"), +} + + class TestToolsetExternalServices: - def test_every_toolset_module_has_external_services(self): + def test_every_toolset_module_has_a_table_entry(self): + """ + `TOOLSET_TABLE_ENTRIES` is test data, not runtime behaviour (see `LABELS` above): every + module in `provider.yaml`'s `toolsets[].python-modules` must have an entry here, or the + hand-written table in `supported_services.rst` silently omits a toolset. + """ toolset_block = _extract_toolset_block() - modules = set(toolset_block["python-modules"]) - services_by_module = { - entry["module"]: entry["services"] for entry in toolset_block["external-services"] - } + for module in toolset_block["python-modules"]: + basename = module.rsplit(".", 1)[-1] + assert basename in TOOLSET_TABLE_ENTRIES, ( + f"No table entry for toolset module {module!r}. Add an entry for {basename!r} " + f"to TOOLSET_TABLE_ENTRIES." + ) - assert set(services_by_module) == modules, ( - f"toolsets.external-services modules {sorted(services_by_module)} != " - f"python-modules {sorted(modules)}" + def test_no_table_entry_is_stale(self): + """The opposite direction: catches a removed toolset module whose row nobody deleted.""" + toolset_block = _extract_toolset_block() + basenames = {module.rsplit(".", 1)[-1] for module in toolset_block["python-modules"]} + stale = set(TOOLSET_TABLE_ENTRIES) - basenames + assert not stale, ( + f"TOOLSET_TABLE_ENTRIES has entry(ies) for removed toolset module(s): {sorted(stale)}" ) - for module, services in services_by_module.items(): - assert services, f"toolset module {module!r} has an empty services list" - def test_every_toolset_module_has_a_docs_anchor(self): - toolsets_rst = (PROVIDER_YAML_PATH.parent / "docs" / "toolsets.rst").read_text() - toolset_block = _extract_toolset_block() + def test_toolset_table_entries_match_supported_services_rst(self): + """ + Guard `supported_services.rst`'s hand-written Toolsets table against drifting from + `TOOLSET_TABLE_ENTRIES`, in both directions: every entry's `:doc:` target must appear in + the Toolsets section, and no *other* `:doc:` target may appear there either. + """ + rst_text = (PROVIDER_YAML_PATH.parent / "docs" / "supported_services.rst").read_text() + start = rst_text.index("Toolsets\n--------") + end = rst_text.index("Notes\n-----", start) + toolsets_section = rst_text[start:end] - for module in toolset_block["python-modules"]: - basename = module.rsplit(".", 1)[-1] - anchor = f".. _howto/toolset:{basename}:" - assert anchor in toolsets_rst, f"missing docs anchor {anchor!r} for module {module!r}" + rendered_targets = set(re.findall(r":doc:`[^<]*<([^>]+)>`", toolsets_section)) + expected_targets = {doc_target for _, doc_target in TOOLSET_TABLE_ENTRIES.values()} + + assert rendered_targets == expected_targets, ( + f"supported_services.rst's Toolsets section links to {sorted(rendered_targets)}, " + f"expected {sorted(expected_targets)}" + ) + + def test_toolset_table_entry_targets_exist(self): + for basename, (_, doc_target) in TOOLSET_TABLE_ENTRIES.items(): + target_path = PROVIDER_YAML_PATH.parent / "docs" / f"{doc_target}.rst" + assert target_path.exists(), f"{doc_target}.rst (for module {basename!r}) does not exist"
