From ece3e32d00491e4380a6df799ee34bba5a59ba9e Mon Sep 17 00:00:00 2001
From: David Blain <info@dabla.be>
Date: Mon, 6 Jul 2026 15:15:05 +0200
Subject: [PATCH 1/3] 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

---
 scripts/ci/prek/check_provider_conn_fields.py | 112 ++++++++++++++++
 .../run_provider_yaml_files_check.py          |  58 ++++++++
 ...st_check_conn_fields_match_form_widgets.py | 124 ++++++++++++++++++
 3 files changed, 294 insertions(+)
 create mode 100644 scripts/ci/prek/check_provider_conn_fields.py
 create mode 100644 scripts/tests/ci/prek/test_check_conn_fields_match_form_widgets.py

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 0000000000000..ed4faf157c553
--- /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 db664a66e011c..e5921f20abe27 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.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,58 @@ 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, verify that:
+
+    1. ``conn-fields`` is present (all providers must declare it).
+    2. The field names are exactly the keys returned by
+       ``get_connection_form_widgets()`` on the corresponding hook class,
+       and vice-versa.
+    """
+    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 check if the hook itself (not a base class) defines get_connection_form_widgets.
+    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 +1103,7 @@ def check_providers_have_all_documentation_files(yaml_files: dict[str, dict]):
 
     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 0000000000000..caa2b2fc35264
--- /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]

From 417bbd2ed2678e9d78b2dcadf4a1983b0944570a Mon Sep 17 00:00:00 2001
From: David Blain <info@dabla.be>
Date: Thu, 9 Jul 2026 11:50:19 +0200
Subject: [PATCH 2/3] Update
 scripts/in_container/run_provider_yaml_files_check.py

Co-authored-by: Jarek Potiuk <jarek@potiuk.com>
---
 .../in_container/run_provider_yaml_files_check.py  | 14 ++++++++------
 1 file changed, 8 insertions(+), 6 deletions(-)

diff --git a/scripts/in_container/run_provider_yaml_files_check.py b/scripts/in_container/run_provider_yaml_files_check.py
index e5921f20abe27..4a519ec40cb52 100755
--- a/scripts/in_container/run_provider_yaml_files_check.py
+++ b/scripts/in_container/run_provider_yaml_files_check.py
@@ -481,12 +481,14 @@ def check_hook_class_name_entries_in_connection_types(yaml_files: dict[str, dict
 @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, verify that:
-
-    1. ``conn-fields`` is present (all providers must declare it).
-    2. The field names are exactly the keys returned by
-       ``get_connection_form_widgets()`` on the corresponding hook class,
-       and vice-versa.
+    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

From ff7ea1cd80b0a245a554cef167fc45e99e7c03cb Mon Sep 17 00:00:00 2001
From: David Blain <info@dabla.be>
Date: Thu, 9 Jul 2026 11:52:57 +0200
Subject: [PATCH 3/3] refactor: Updated comment in _get_widget_keys method

---
 scripts/in_container/run_provider_yaml_files_check.py | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/scripts/in_container/run_provider_yaml_files_check.py b/scripts/in_container/run_provider_yaml_files_check.py
index 4a519ec40cb52..922e84b710bc4 100755
--- a/scripts/in_container/run_provider_yaml_files_check.py
+++ b/scripts/in_container/run_provider_yaml_files_check.py
@@ -520,7 +520,12 @@ def _get_widget_keys(hook_class_name: str) -> set[str] | None:
     except (ImportError, AirflowOptionalProviderFeatureException, AttributeError):
         return None
 
-    # Only check if the hook itself (not a base class) defines get_connection_form_widgets.
+    # 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
 
