This is an automated email from the ASF dual-hosted git repository.

dabla pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 53de1c173ac Added static check for conn-fields defined in 
provider.yaml (#69473)
53de1c173ac is described below

commit 53de1c173aca56d9555165e18bbc83a666df4d4a
Author: David Blain <[email protected]>
AuthorDate: Thu Jul 9 13:15:52 2026 +0200

    Added static check for conn-fields defined in provider.yaml (#69473)
    
    * refactor: Added static check for conn-fields defined in provider.yaml to 
make sure those correspond with the ones defined in the 
get_connection_form_widgets method of the corresponding hook
    
    * Update scripts/in_container/run_provider_yaml_files_check.py
    
    Co-authored-by: Jarek Potiuk <[email protected]>
    
    * refactor: Updated comment in _get_widget_keys method
    
    ---------
    
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 scripts/ci/prek/check_provider_conn_fields.py      | 112 +++++++++++++++++++
 .../in_container/run_provider_yaml_files_check.py  |  65 +++++++++++
 .../test_check_conn_fields_match_form_widgets.py   | 124 +++++++++++++++++++++
 3 files changed, 301 insertions(+)

diff --git a/scripts/ci/prek/check_provider_conn_fields.py 
b/scripts/ci/prek/check_provider_conn_fields.py
new file mode 100644
index 00000000000..ed4faf157c5
--- /dev/null
+++ b/scripts/ci/prek/check_provider_conn_fields.py
@@ -0,0 +1,112 @@
+#!/usr/bin/env python
+#
+# 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.
+"""
+Validation helpers for the conn-fields ↔ get_connection_form_widgets() check.
+
+These functions have no third-party dependencies so they can be unit-tested
+outside of the Breeze container without any stubbing.
+
+Used by ``scripts/in_container/run_provider_yaml_files_check.py``.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+
+
+def check_conn_fields_for_entry(
+    conn_type_entry: dict,
+    yaml_file_path: str,
+    get_widget_keys: Callable[[str], set[str] | None],
+) -> list[str]:
+    """
+    Validate a single connection-type entry.  Returns a (possibly empty) list 
of error strings.
+
+    *get_widget_keys(hook_class_name)* is a callable supplied by the caller 
that:
+
+    - returns the set of field keys from ``get_connection_form_widgets()`` on 
success,
+    - returns ``None`` to signal that the hook could not be imported, its UI
+      dependencies are absent, or it does not implement 
``get_connection_form_widgets()``
+      at all — the entry is then skipped entirely (no ``conn-fields`` check), 
or
+    - raises any other ``Exception`` to signal an unexpected failure (converted
+      here into an error string so callers never need to catch it).
+    """
+    hook_class_name: str = conn_type_entry["hook-class-name"]
+    connection_type: str = conn_type_entry.get("connection-type", "?")
+
+    try:
+        widget_keys = get_widget_keys(hook_class_name)
+    except Exception as exc:
+        return [
+            f"Failed to call `{hook_class_name}.get_connection_form_widgets()` 
"
+            f"while checking {yaml_file_path}: {exc}"
+        ]
+
+    if widget_keys is None:
+        return []
+
+    conn_fields = conn_type_entry.get("conn-fields")
+    if conn_fields is None:
+        # No conn-fields declared: the new UI simply exposes no custom fields 
for this
+        # connection type, which is intentional.  Nothing to validate.
+        return []
+
+    error = build_mismatch_error(
+        set(conn_fields.keys()), widget_keys, connection_type, yaml_file_path, 
hook_class_name
+    )
+    return [error] if error else []
+
+
+def build_mismatch_error(
+    yaml_keys: set[str],
+    hook_keys: set[str],
+    connection_type: str,
+    yaml_file_path: str,
+    hook_class_name: str,
+) -> str | None:
+    """
+    Check that every key declared in ``conn-fields`` exists in
+    ``get_connection_form_widgets()``.
+
+    ``conn-fields`` is the new React UI's view of a connection type and is
+    intentionally a *subset* of the Flask form widgets — fields can be omitted
+    from ``conn-fields`` on purpose.  We therefore only flag keys that appear 
in
+    ``conn-fields`` but are absent from the hook's form (invalid / stale
+    declarations).  The reverse direction (hook fields not listed in
+    ``conn-fields``) is not an error.
+
+    Return an error string when stale keys are found, or ``None`` when the
+    declared keys are all valid.
+    """
+    only_in_yaml = yaml_keys - hook_keys
+
+    if not only_in_yaml:
+        return None
+
+    lines = [
+        f"Mismatch between `conn-fields` in {yaml_file_path} and "
+        f"`{hook_class_name}.get_connection_form_widgets()` "
+        f"for connection-type '{connection_type}':"
+    ]
+    lines.append(
+        "  Fields in provider.yaml conn-fields but NOT in 
get_connection_form_widgets(): "
+        + ", ".join(sorted(only_in_yaml))
+    )
+    lines.append("[yellow]How to fix it[/]: Remove the stale key(s) from 
conn-fields in provider.yaml.")
+    return "\n".join(lines)
diff --git a/scripts/in_container/run_provider_yaml_files_check.py 
b/scripts/in_container/run_provider_yaml_files_check.py
index db664a66e01..922e84b710b 100755
--- a/scripts/in_container/run_provider_yaml_files_check.py
+++ b/scripts/in_container/run_provider_yaml_files_check.py
@@ -50,6 +50,11 @@ from airflow.cli.commands.info_command import Architecture
 from airflow.exceptions import AirflowOptionalProviderFeatureException, 
AirflowProviderDeprecationWarning
 from airflow.providers_manager import ProvidersManager
 
+# check_provider_conn_fields lives in scripts/ci/prek/ which is not on 
sys.path when
+# this script runs inside Breeze; resolve it relative to this file.
+sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "ci" / "prek"))
+from check_provider_conn_fields import check_conn_fields_for_entry
+
 # Those are deprecated modules that contain removed Hooks/Sensors/Operators 
that we left in the code
 # so that users can get a very specific error message when they try to use 
them.
 
@@ -473,6 +478,65 @@ def 
check_hook_class_name_entries_in_connection_types(yaml_files: dict[str, dict
     return num_connection_types, num_errors
 
 
+@run_check("Checking that conn-fields in provider.yaml match 
get_connection_form_widgets() of the hook class")
+def check_conn_fields_match_form_widgets(yaml_files: dict[str, dict]) -> 
tuple[int, int]:
+    """
+    For every connection-type entry whose hook declares ``conn-fields``,
+    verify that every key in ``conn-fields`` also exists in the hook's
+    ``get_connection_form_widgets()``.
+
+    ``conn-fields`` is optional and is intentionally allowed to be a *subset*
+    of the hook's form widgets (the new React UI may expose fewer fields than
+    the legacy Flask form), so extra hook widgets are not flagged — only
+    ``conn-fields`` keys absent from the hook are reported as stale.
+    """
+    num_checks = 0
+    num_errors = 0
+
+    for yaml_file_path, provider_data in yaml_files.items():
+        for conn_type_entry in provider_data.get("connection-types", []):
+            num_checks += 1
+            for error in check_conn_fields_for_entry(conn_type_entry, 
yaml_file_path, _get_widget_keys):
+                errors.append(error)
+                num_errors += 1
+
+    return num_checks, num_errors
+
+
+def _get_widget_keys(hook_class_name: str) -> set[str] | None:
+    """
+    Import *hook_class_name* and return the keys of 
``get_connection_form_widgets()``.
+
+    Returns ``None`` when the hook or its UI dependencies cannot be imported,
+    or when the hook does not override ``get_connection_form_widgets()`` 
(meaning it
+    has no custom connection fields and the conn-fields check should be 
skipped).
+    Raises for unexpected errors so ``check_conn_fields_for_entry`` can 
convert them
+    to an error string.
+    """
+    try:
+        module_name, class_name = hook_class_name.rsplit(".", maxsplit=1)
+        with warnings.catch_warnings(record=True):
+            hook_class = getattr(importlib.import_module(module_name), 
class_name)
+    except (ImportError, AirflowOptionalProviderFeatureException, 
AttributeError):
+        return None
+
+    # Only validate hooks that override get_connection_form_widgets() in their 
own __dict__,
+    # because that method is the source-of-truth for what conn-fields should 
be declared.
+    # Hooks that inherit it without overriding have no provider-specific 
widget definition
+    # to diff against, so the check is intentionally skipped for them.  As of 
writing this
+    # includes HttpHook, the common/ai hooks, and AzureComputeHook — any 
provider whose hook
+    # falls into this category will NOT be validated here, even if it declares 
conn-fields.
+    if "get_connection_form_widgets" not in hook_class.__dict__:
+        return None
+
+    with warnings.catch_warnings(record=True):
+        try:
+            form_widgets: dict[str, Any] = 
hook_class.get_connection_form_widgets()
+            return set(form_widgets.keys())
+        except (ImportError, AirflowOptionalProviderFeatureException, 
AttributeError):
+            return None
+
+
 @run_check("Checking that hook classes defining conn_type are registered in 
connection-types")
 def check_hook_classes_with_conn_type_are_registered(yaml_files: dict[str, 
dict]) -> tuple[int, int]:
     """Find Hook subclasses that define conn_type but are not listed in 
connection-types."""
@@ -1046,6 +1110,7 @@ if __name__ == "__main__":
 
     check_completeness_of_list_of_transfers(all_parsed_yaml_files)
     check_hook_class_name_entries_in_connection_types(all_parsed_yaml_files)
+    check_conn_fields_match_form_widgets(all_parsed_yaml_files)
     check_hook_classes_with_conn_type_are_registered(all_parsed_yaml_files)
     check_executor_classes(all_parsed_yaml_files)
     check_queue_classes(all_parsed_yaml_files)
diff --git a/scripts/tests/ci/prek/test_check_conn_fields_match_form_widgets.py 
b/scripts/tests/ci/prek/test_check_conn_fields_match_form_widgets.py
new file mode 100644
index 00000000000..caa2b2fc352
--- /dev/null
+++ b/scripts/tests/ci/prek/test_check_conn_fields_match_form_widgets.py
@@ -0,0 +1,124 @@
+# 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.
+from __future__ import annotations
+
+import pytest
+from check_provider_conn_fields import (
+    build_mismatch_error,
+    check_conn_fields_for_entry,
+)
+
+YAML_PATH = "providers/my_provider/provider.yaml"
+HOOK_CLASS = "my_provider.hooks.my_hook.MyHook"
+CONN_TYPE = "my_conn_type"
+
+
+def _entry(conn_fields: list[str] | None) -> dict:
+    entry: dict = {"hook-class-name": HOOK_CLASS, "connection-type": CONN_TYPE}
+    if conn_fields is not None:
+        entry["conn-fields"] = {k: {} for k in conn_fields}
+    return entry
+
+
+def _get_keys(*keys: str):
+    """Return a get_widget_keys callable that always returns the given keys."""
+    return lambda _hook_class_name: set(keys)
+
+
+def _skip(_hook_class_name: str) -> None:
+    """get_widget_keys callable that signals 'skip silently'."""
+    return None
+
+
+def _raise(_hook_class_name: str) -> None:
+    raise RuntimeError("boom")
+
+
+class TestBuildMismatchError:
+    @pytest.mark.parametrize(
+        "yaml_keys, hook_keys",
+        [
+            pytest.param({"a", "b"}, {"a", "b"}, id="matching-keys"),
+            pytest.param(set(), set(), id="empty-sets"),
+            # Hook may have more keys than conn-fields — that is intentional 
(subset allowed).
+            pytest.param({"a"}, {"a", "extra_hook"}, 
id="hook-has-extra-keys-no-error"),
+        ],
+    )
+    def test_no_mismatch_returns_none(self, yaml_keys, hook_keys):
+        assert build_mismatch_error(yaml_keys, hook_keys, CONN_TYPE, 
YAML_PATH, HOOK_CLASS) is None
+
+    @pytest.mark.parametrize(
+        "yaml_keys, hook_keys, expected_in_error, not_expected_in_error",
+        [
+            pytest.param(
+                {"a", "extra_yaml"},
+                {"a"},
+                ["extra_yaml", "NOT in get_connection_form_widgets"],
+                ["NOT in provider.yaml conn-fields"],
+                id="extra-in-yaml",
+            ),
+            # only_in_hook no longer triggers an error — only only_in_yaml does
+            pytest.param(
+                {"a", "only_yaml"},
+                {"a", "only_hook"},
+                [
+                    "only_yaml",
+                    "NOT in get_connection_form_widgets",
+                ],
+                ["only_hook", "NOT in provider.yaml conn-fields"],
+                id="both-sides-only-yaml-reported",
+            ),
+        ],
+    )
+    def test_mismatch_error_content(self, yaml_keys, hook_keys, 
expected_in_error, not_expected_in_error):
+        error = build_mismatch_error(yaml_keys, hook_keys, CONN_TYPE, 
YAML_PATH, HOOK_CLASS)
+        assert error is not None
+        for expected in expected_in_error:
+            assert expected in error
+        for not_expected in not_expected_in_error:
+            assert not_expected not in error
+
+
+class TestCheckConnFieldsForEntry:
+    @pytest.mark.parametrize(
+        "conn_fields, get_keys",
+        [
+            pytest.param(["a", "b"], _get_keys("a", "b"), id="matching-keys"),
+            pytest.param([], _get_keys(), id="empty-on-both-sides"),
+            pytest.param(["a"], _skip, 
id="skip-hook-without-get-connection-form-widgets"),
+            pytest.param(None, _skip, 
id="skip-missing-conn-fields-when-hook-has-no-widgets"),
+            # Hook with widgets but no conn-fields is allowed: new UI 
intentionally omits custom fields.
+            pytest.param(None, _get_keys("field_a"), 
id="no-conn-fields-with-hook-widgets-is-ok"),
+            # Hook has extra keys not in conn-fields — allowed (conn-fields is 
a valid subset).
+            pytest.param(["a"], _get_keys("a", "extra_hook"), 
id="hook-extra-keys-no-error"),
+        ],
+    )
+    def test_no_errors(self, conn_fields, get_keys):
+        assert check_conn_fields_for_entry(_entry(conn_fields), YAML_PATH, 
get_keys) == []
+
+    @pytest.mark.parametrize(
+        "conn_fields, get_keys, expected_in_error",
+        [
+            pytest.param(["a", "extra"], _get_keys("a"), "extra", 
id="extra-key-in-yaml"),
+            pytest.param(["a"], _raise, "boom", 
id="unexpected-exception-message"),
+            pytest.param(["a"], _raise, HOOK_CLASS, 
id="unexpected-exception-mentions-hook-class"),
+        ],
+    )
+    def test_one_error_containing(self, conn_fields, get_keys, 
expected_in_error):
+        errors = check_conn_fields_for_entry(_entry(conn_fields), YAML_PATH, 
get_keys)
+        assert len(errors) == 1
+        assert expected_in_error in errors[0]

Reply via email to