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

potiuk pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-3-test by this push:
     new 0446165f342 [v3-3-test] Check ui-field-behaviour drift between hooks 
and provider.yaml (#73801) (#73830)
0446165f342 is described below

commit 0446165f3426102766a5d19210f988bed764898e
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Mon Sep 28 17:56:38 2026 +0200

    [v3-3-test] Check ui-field-behaviour drift between hooks and provider.yaml 
(#73801) (#73830)
    
    * Flag ui-field-behaviour drift between hooks and provider.yaml in static 
checks
    
    * Share the hook lookup and clarify ui-field-behaviour drift messages
    
    * fixup! Share the hook lookup and clarify ui-field-behaviour drift messages
    
    Generated-by: Claude Opus 5
    
    ---------
    (cherry picked from commit 2827a13dc4f5b4554cc70d09155a5baf5e3ce040)
    
    Co-authored-by: Yuseok Jo <[email protected]>
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 scripts/ci/prek/check_provider_conn_fields.py      | 123 ++++++++++++++++++-
 .../in_container/run_provider_yaml_files_check.py  |  71 ++++++++---
 .../test_check_ui_field_behaviour_matches_hook.py  | 130 +++++++++++++++++++++
 3 files changed, 306 insertions(+), 18 deletions(-)

diff --git a/scripts/ci/prek/check_provider_conn_fields.py 
b/scripts/ci/prek/check_provider_conn_fields.py
index 9980095b83c..5654d315b73 100644
--- a/scripts/ci/prek/check_provider_conn_fields.py
+++ b/scripts/ci/prek/check_provider_conn_fields.py
@@ -17,7 +17,10 @@
 # specific language governing permissions and limitations
 # under the License.
 """
-Validation helpers for the conn-fields ↔ get_connection_form_widgets() check.
+Validation helpers for the checks that compare connection UI metadata in 
provider.yaml with the hook.
+
+``conn-fields`` is compared with ``get_connection_form_widgets()``, and 
``ui-field-behaviour``
+with ``get_ui_field_behaviour()``.
 
 These functions have no third-party dependencies so they can be unit-tested
 outside of the Breeze container without any stubbing.
@@ -27,6 +30,7 @@ Used by 
``scripts/in_container/run_provider_yaml_files_check.py``.
 
 from __future__ import annotations
 
+import json
 from collections.abc import Callable
 
 
@@ -120,3 +124,120 @@ def build_mismatch_error(
         )
         lines.append("[yellow]How to fix it[/]: Add the missing key(s) to 
conn-fields in provider.yaml.")
     return "\n".join(lines)
+
+
+def normalize_behaviour_value(value: object) -> str:
+    """
+    Equality-normalize a relabeling/placeholder value.
+
+    Placeholder values are display examples, so insignificant formatting must 
not count as
+    drift: surrounding whitespace is stripped, and values that parse as JSON 
on both sides
+    are compared structurally (hooks often build them with ``json.dumps`` at a 
different
+    indent than the YAML block scalar, and ``|`` block scalars add a trailing 
newline).
+    """
+    text = str(value).strip()
+    try:
+        return json.dumps(json.loads(text), sort_keys=True)
+    except (ValueError, TypeError):
+        return text
+
+
+def normalize_placeholder_key(key: str, connection_type: str) -> str:
+    """
+    Prefix a custom-field placeholder key the way 
``_ensure_prefix_for_placeholders`` does at runtime.
+
+    Airflow accepts both ``keyfile_dict`` and 
``extra__<conn_type>__keyfile_dict`` for the same
+    field, so the two spellings must not count as drift.
+    """
+    if key in {"host", "schema", "login", "password", "port", "extra"} or 
key.startswith("extra__"):
+        return key
+    return f"extra__{connection_type}__{key}"
+
+
+def check_ui_field_behaviour_for_entry(
+    conn_type_entry: dict,
+    yaml_file_path: str,
+    get_behaviour: Callable[[str], dict | None],
+) -> list[str]:
+    """
+    Validate a connection-type entry's ``ui-field-behaviour`` against 
``get_ui_field_behaviour()``.
+
+    *get_behaviour(hook_class_name)* is a callable supplied by the caller that 
returns the
+    hook's ``get_ui_field_behaviour()`` dict, or ``None`` to skip the entry 
(hook not
+    importable, or it does not override the method). Any other exception is 
converted to an
+    error string here so callers never need to catch it.
+
+    Airflow 3.2+ builds the connection form from the provider YAML and skips 
the hook method
+    once the YAML declares connection metadata, while the older Airflow 
versions a provider
+    still supports call ``get_ui_field_behaviour()``. The two must agree, or 
the same form
+    looks different depending on the Airflow version. A missing 
``ui-field-behaviour``
+    section is flagged too, since the hook method is deprecated in favour of 
it.
+    """
+    hook_class_name: str = conn_type_entry["hook-class-name"]
+    connection_type: str = conn_type_entry.get("connection-type", "?")
+
+    try:
+        hook_behaviour = get_behaviour(hook_class_name)
+    except Exception as exc:
+        return [
+            f"Failed to call `{hook_class_name}.get_ui_field_behaviour()` "
+            f"while checking {yaml_file_path}: {exc}"
+        ]
+
+    if hook_behaviour is None:
+        return []
+
+    header = (
+        f"Mismatch between `ui-field-behaviour` in {yaml_file_path} and "
+        f"`{hook_class_name}.get_ui_field_behaviour()` "
+        f"for connection-type '{connection_type}':"
+    )
+
+    yaml_behaviour = conn_type_entry.get("ui-field-behaviour")
+    if yaml_behaviour is None:
+        return [
+            f"{header}\n"
+            "  The hook overrides get_ui_field_behaviour(), which is 
deprecated in favour of "
+            "ui-field-behaviour in provider.yaml, but provider.yaml has no 
such section.\n"
+            "[yellow]How to fix it[/]: Declare ui-field-behaviour for this 
connection-type "
+            "in provider.yaml, matching get_ui_field_behaviour()."
+        ]
+
+    problems = []
+
+    yaml_hidden = set(yaml_behaviour.get("hidden-fields") or [])
+    hook_hidden = set(hook_behaviour.get("hidden_fields") or [])
+    if yaml_hidden != hook_hidden:
+        problems.append(
+            "  hidden-fields differ."
+            f" Only in provider.yaml: {sorted(yaml_hidden - hook_hidden) or 
'-'};"
+            f" only in the hook: {sorted(hook_hidden - yaml_hidden) or '-'}"
+        )
+
+    key_normalizers: dict[str, Callable[[str], str]] = {
+        "relabeling": lambda key: key,
+        "placeholders": lambda key: normalize_placeholder_key(key, 
connection_type),
+    }
+    for section, normalize_key in key_normalizers.items():
+        yaml_section = {
+            normalize_key(k): normalize_behaviour_value(v)
+            for k, v in (yaml_behaviour.get(section) or {}).items()
+        }
+        hook_section = {
+            normalize_key(k): normalize_behaviour_value(v)
+            for k, v in (hook_behaviour.get(section) or {}).items()
+        }
+        if yaml_section == hook_section:
+            continue
+        diff_keys = sorted(
+            k for k in yaml_section.keys() | hook_section.keys() if 
yaml_section.get(k) != hook_section.get(k)
+        )
+        problems.append(f"  {section} differ for: {', '.join(diff_keys)}")
+
+    if not problems:
+        return []
+    problems.append(
+        "[yellow]How to fix it[/]: Make ui-field-behaviour in provider.yaml 
say the same "
+        "thing as get_ui_field_behaviour(); Airflow 3.2+ shows the YAML, older 
versions the hook method."
+    )
+    return ["\n".join([header, *problems])]
diff --git a/scripts/in_container/run_provider_yaml_files_check.py 
b/scripts/in_container/run_provider_yaml_files_check.py
index 9518e75ad65..3b1bb917894 100755
--- a/scripts/in_container/run_provider_yaml_files_check.py
+++ b/scripts/in_container/run_provider_yaml_files_check.py
@@ -53,7 +53,7 @@ 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
+from check_provider_conn_fields import check_conn_fields_for_entry, 
check_ui_field_behaviour_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.
@@ -500,15 +500,40 @@ def check_conn_fields_match_form_widgets(yaml_files: 
dict[str, dict]) -> tuple[i
     return num_checks, num_errors
 
 
-def _get_widget_keys(hook_class_name: str) -> set[str] | None:
+@run_check(
+    "Checking that ui-field-behaviour in provider.yaml matches 
get_ui_field_behaviour() of the hook class"
+)
+def check_ui_field_behaviour_matches_hook(yaml_files: dict[str, dict]) -> 
tuple[int, int]:
+    """
+    For every connection-type entry whose hook overrides 
``get_ui_field_behaviour()``,
+    verify the ``ui-field-behaviour`` section in the provider YAML says the 
same thing.
+    Airflow 3.2+ shows the YAML and older versions the hook method, so a 
drifted
+    hidden-fields/relabeling/placeholders value is an error, and so is a 
missing section,
+    since the hook method is deprecated in favour of it.
     """
-    Import *hook_class_name* and return the keys of 
``get_connection_form_widgets()``.
+    num_checks = 0
+    num_errors = 0
 
-    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.
+    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_ui_field_behaviour_for_entry(
+                conn_type_entry, yaml_file_path, _get_ui_field_behaviour
+            ):
+                errors.append(error)
+                num_errors += 1
+
+    return num_checks, num_errors
+
+
+def _call_overridden_hook_method(hook_class_name: str, method_name: str) -> 
Any:
+    """
+    Import *hook_class_name* and return the result of calling *method_name* on 
the hook class.
+
+    Returns ``None`` when the hook or its UI dependencies cannot be imported, 
or when the hook
+    does not override *method_name* in its own ``__dict__``: a hook that 
inherits the method has
+    no provider-specific definition to diff against, so the check is skipped 
for it. Raises for
+    unexpected errors so the ``check_*_for_entry`` helpers can convert them to 
an error string.
     """
     try:
         module_name, class_name = hook_class_name.rsplit(".", maxsplit=1)
@@ -517,23 +542,34 @@ def _get_widget_keys(hook_class_name: str) -> set[str] | 
None:
     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__:
+    if method_name 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())
+            return getattr(hook_class, method_name)()
         except (ImportError, AirflowOptionalProviderFeatureException, 
AttributeError):
             return None
 
 
+def _get_ui_field_behaviour(hook_class_name: str) -> dict[str, Any] | None:
+    """Return the hook's ``get_ui_field_behaviour()`` dict, or ``None`` to 
skip the entry."""
+    return _call_overridden_hook_method(hook_class_name, 
"get_ui_field_behaviour")
+
+
+def _get_widget_keys(hook_class_name: str) -> set[str] | None:
+    """
+    Return the keys of the hook's ``get_connection_form_widgets()``, or 
``None`` to skip the entry.
+
+    ``get_connection_form_widgets()`` is the source of truth for what 
conn-fields should declare.
+    Hooks that inherit it without overriding are skipped; as of writing this 
includes HttpHook,
+    the common/ai hooks, and AzureComputeHook, so any provider whose hook 
falls into this
+    category will NOT be validated here, even if it declares conn-fields.
+    """
+    form_widgets = _call_overridden_hook_method(hook_class_name, 
"get_connection_form_widgets")
+    return None if form_widgets is None else set(form_widgets.keys())
+
+
 @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."""
@@ -1108,6 +1144,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_ui_field_behaviour_matches_hook(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_ui_field_behaviour_matches_hook.py 
b/scripts/tests/ci/prek/test_check_ui_field_behaviour_matches_hook.py
new file mode 100644
index 00000000000..b00d76f793d
--- /dev/null
+++ b/scripts/tests/ci/prek/test_check_ui_field_behaviour_matches_hook.py
@@ -0,0 +1,130 @@
+# 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 (
+    check_ui_field_behaviour_for_entry,
+    normalize_behaviour_value,
+)
+
+YAML_PATH = "providers/my_provider/provider.yaml"
+HOOK_CLASS = "my_provider.hooks.my_hook.MyHook"
+CONN_TYPE = "my_conn_type"
+
+
+def _entry(behaviour: dict | None) -> dict:
+    entry: dict = {"hook-class-name": HOOK_CLASS, "connection-type": CONN_TYPE}
+    if behaviour is not None:
+        entry["ui-field-behaviour"] = behaviour
+    return entry
+
+
+def _hook(behaviour: dict | None):
+    """Return a get_behaviour callable that always returns the given dict."""
+    return lambda _hook_class_name: behaviour
+
+
+def _raise(_hook_class_name: str) -> None:
+    raise RuntimeError("boom")
+
+
+class TestNormalizeBehaviourValue:
+    @pytest.mark.parametrize(
+        "left, right",
+        [
+            pytest.param("plain text", "plain text\n", id="trailing-newline"),
+            pytest.param("  padded  ", "padded", id="surrounding-whitespace"),
+            pytest.param('{"a": 1, "b": [2]}', '{\n  "a": 1,\n  "b": [\n    
2\n  ]\n}\n', id="json-indent"),
+            pytest.param('{"b": [2], "a": 1}', '{"a": 1, "b": [2]}', 
id="json-key-order"),
+        ],
+    )
+    def test_insignificant_formatting_is_equal(self, left, right):
+        assert normalize_behaviour_value(left) == 
normalize_behaviour_value(right)
+
+    @pytest.mark.parametrize(
+        "left, right",
+        [
+            pytest.param('{"a": 1}', '{"a": 2}', id="json-content"),
+            pytest.param("host url", "host  url", id="inner-whitespace"),
+        ],
+    )
+    def test_real_differences_stay_different(self, left, right):
+        assert normalize_behaviour_value(left) != 
normalize_behaviour_value(right)
+
+
+class TestCheckUiFieldBehaviourForEntry:
+    @pytest.mark.parametrize(
+        "yaml_behaviour, get_behaviour",
+        [
+            pytest.param(None, _hook(None), 
id="skip-hook-without-get-ui-field-behaviour"),
+            pytest.param(
+                {
+                    "hidden-fields": ["port", "schema"],
+                    "relabeling": {"host": "Server URL"},
+                    "placeholders": {"extra": '{"a": 1, "b": 2}'},
+                },
+                _hook(
+                    {
+                        "hidden_fields": ["schema", "port"],
+                        "relabeling": {"host": "Server URL"},
+                        "placeholders": {"extra": '{\n  "b": 2,\n  "a": 
1\n}\n'},
+                    }
+                ),
+                id="matching-behaviour-with-formatting-differences",
+            ),
+            pytest.param(
+                {"placeholders": {"keyfile_dict": "{}", "login": "user"}},
+                _hook({"placeholders": {f"extra__{CONN_TYPE}__keyfile_dict": 
"{}", "login": "user"}}),
+                id="bare-and-prefixed-placeholder-keys-are-equal",
+            ),
+        ],
+    )
+    def test_no_errors(self, yaml_behaviour, get_behaviour):
+        assert check_ui_field_behaviour_for_entry(_entry(yaml_behaviour), 
YAML_PATH, get_behaviour) == []
+
+    @pytest.mark.parametrize(
+        "yaml_behaviour, get_behaviour, expected_in_error",
+        [
+            pytest.param(
+                None, _hook({"hidden_fields": ["port"]}), "no such section", 
id="missing-yaml-section"
+            ),
+            pytest.param(
+                {"hidden-fields": ["port"]},
+                _hook({"hidden_fields": ["port", "schema"]}),
+                "only in the hook: ['schema']",
+                id="hidden-fields-drift",
+            ),
+            pytest.param(
+                {"relabeling": {"host": "Server URL"}},
+                _hook({"relabeling": {"host": "Server URL (optional)"}}),
+                "relabeling differ for: host",
+                id="relabeling-drift",
+            ),
+            pytest.param(
+                {"placeholders": {"extra": '{"model": "old"}'}},
+                _hook({"placeholders": {"extra": '{"model": "new"}', "login": 
"user"}}),
+                "placeholders differ for: extra, login",
+                id="placeholders-drift",
+            ),
+            pytest.param(None, _raise, "boom", 
id="unexpected-exception-message"),
+        ],
+    )
+    def test_one_error_containing(self, yaml_behaviour, get_behaviour, 
expected_in_error):
+        errors = check_ui_field_behaviour_for_entry(_entry(yaml_behaviour), 
YAML_PATH, get_behaviour)
+        assert len(errors) == 1
+        assert expected_in_error in errors[0]

Reply via email to