You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by ra...@apache.org on 2026/03/25 11:48:01 UTC

(airflow) 20/46: Fix `TypeError` crashes on `/users/list` and `/roles/list` in FAB UI caused by concurrent API schema requests (#63986)

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

rahulvats pushed a commit to branch py-client-sync
in repository https://gitbox.apache.org/repos/asf/airflow.git

commit 0e24b08e11406b70f9c5af106bfb5de32057d426
Author: Subham <su...@gmail.com>
AuthorDate: Tue Mar 24 15:31:23 2026 +0530

    Fix `TypeError` crashes on `/users/list` and `/roles/list` in FAB UI caused by concurrent API schema requests (#63986)
    
    * Fix TypeError crashes on /users/list and /roles/list in FAB UI caused by concurrent API schema requests
    
    * Fix TypeError in FAB UI by isolating ProvidersManager discovery and making MockOptional callable
    
    * Fix unrelated Elasticsearch test failure in FAB UI PR branch
    
    * Revert unrelated Elasticsearch test changes
---
 .../core_api/services/ui/connections.py            | 95 +++++++++++++++++-----
 1 file changed, 76 insertions(+), 19 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/connections.py b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/connections.py
index 96480555c40..12e823ce1d8 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/connections.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/connections.py
@@ -49,10 +49,11 @@ class HookMetaService:
             pass
 
         def __call__(self, form, field):
-            pass
+            """No-op call to satisfy WTForms validator protocol."""
+            return None
 
     class MockEnum:
-        """Mock for wtforms.validators.Optional."""
+        """Mock for wtforms.validators.AnyOf."""
 
         def __init__(self, allowed_values):
             self.allowed_values = allowed_values
@@ -156,23 +157,40 @@ class HookMetaService:
                     raise ModuleNotFoundError
             except ModuleNotFoundError:
                 sys.modules[mod_name] = MagicMock()
-        with (
-            mock.patch("wtforms.StringField", HookMetaService.MockStringField),
-            mock.patch("wtforms.fields.StringField", HookMetaService.MockStringField),
-            mock.patch("wtforms.fields.simple.StringField", HookMetaService.MockStringField),
-            mock.patch("wtforms.IntegerField", HookMetaService.MockIntegerField),
-            mock.patch("wtforms.fields.IntegerField", HookMetaService.MockIntegerField),
-            mock.patch("wtforms.PasswordField", HookMetaService.MockPasswordField),
-            mock.patch("wtforms.BooleanField", HookMetaService.MockBooleanField),
-            mock.patch("wtforms.fields.BooleanField", HookMetaService.MockBooleanField),
-            mock.patch("wtforms.fields.simple.BooleanField", HookMetaService.MockBooleanField),
-            mock.patch("flask_babel.lazy_gettext", mock_lazy_gettext),
-            mock.patch("flask_appbuilder.fieldwidgets.BS3TextFieldWidget", HookMetaService.MockAnyWidget),
-            mock.patch("flask_appbuilder.fieldwidgets.BS3TextAreaFieldWidget", HookMetaService.MockAnyWidget),
-            mock.patch("flask_appbuilder.fieldwidgets.BS3PasswordFieldWidget", HookMetaService.MockAnyWidget),
-            mock.patch("wtforms.validators.Optional", HookMetaService.MockOptional),
-            mock.patch("wtforms.validators.any_of", mock_any_of),
-        ):
+
+        # We conditionally inject mock classes for missing dependencies
+        # to ensure `ProvidersManager` can initialize hook connection widgets
+        # without crashing when FAB/WTForms are not installed.
+        if "wtforms.StringField" not in sys.modules:
+            # Only apply mocks if the actual module wasn't loaded beforehand.
+            # This avoids thread-safety issues caused by `unittest.mock.patch` mutating global states.
+            with (
+                mock.patch("wtforms.StringField", HookMetaService.MockStringField),
+                mock.patch("wtforms.fields.StringField", HookMetaService.MockStringField),
+                mock.patch("wtforms.fields.simple.StringField", HookMetaService.MockStringField),
+                mock.patch("wtforms.IntegerField", HookMetaService.MockIntegerField),
+                mock.patch("wtforms.fields.IntegerField", HookMetaService.MockIntegerField),
+                mock.patch("wtforms.PasswordField", HookMetaService.MockPasswordField),
+                mock.patch("wtforms.BooleanField", HookMetaService.MockBooleanField),
+                mock.patch("wtforms.fields.BooleanField", HookMetaService.MockBooleanField),
+                mock.patch("wtforms.fields.simple.BooleanField", HookMetaService.MockBooleanField),
+                mock.patch("flask_babel.lazy_gettext", mock_lazy_gettext),
+                mock.patch("flask_appbuilder.fieldwidgets.BS3TextFieldWidget", HookMetaService.MockAnyWidget),
+                mock.patch(
+                    "flask_appbuilder.fieldwidgets.BS3TextAreaFieldWidget", HookMetaService.MockAnyWidget
+                ),
+                mock.patch(
+                    "flask_appbuilder.fieldwidgets.BS3PasswordFieldWidget", HookMetaService.MockAnyWidget
+                ),
+                mock.patch("wtforms.validators.Optional", HookMetaService.MockOptional),
+                mock.patch("wtforms.validators.any_of", mock_any_of),
+                # Prevent poisoning the global ProvidersManager singleton with mocks
+                mock.patch("airflow.providers_manager.ProvidersManager._instance", None),
+                mock.patch("airflow.providers_manager.ProvidersManager.initialized", return_value=False),
+            ):
+                pm = ProvidersManager()
+                return pm.hooks, pm.connection_form_widgets, pm.field_behaviours  # Will init providers hooks
+        else:
             pm = ProvidersManager()
             return pm.hooks, pm.connection_form_widgets, pm.field_behaviours  # Will init providers hooks
 
@@ -215,6 +233,45 @@ class HookMetaService:
             elif isinstance(form_widget.field, HookMetaService.MockBaseField):
                 # legacy path, form widgets created using mocked WTForms fields, need to convert to SerializedParam.dump()
                 hook_widgets[form_widget.field_name] = form_widget.field.param.dump()
+            elif type(form_widget.field).__name__ == "UnboundField":
+                # handle real WTForms fields gracefully without needing mock patches
+                field_class_name = getattr(form_widget.field.field_class, "__name__", "")
+                param_type = "string"
+                param_format = None
+                if field_class_name == "BooleanField":
+                    param_type = "boolean"
+                elif field_class_name == "IntegerField":
+                    param_type = "integer"
+                elif field_class_name == "PasswordField":
+                    param_format = "password"
+
+                label = (
+                    form_widget.field.args[0]
+                    if len(form_widget.field.args) > 0
+                    else form_widget.field.kwargs.get("label")
+                )
+                validators = form_widget.field.kwargs.get("validators", [])
+                description = form_widget.field.kwargs.get("description", "")
+                default = form_widget.field.kwargs.get("default", None)
+
+                enum = {}
+                for v in validators:
+                    if type(v).__name__ == "AnyOf":
+                        enum["enum"] = getattr(v, "values", [])
+
+                types = [param_type, "null"]
+                format_dict = {"format": param_format} if param_format else {}
+
+                param = SerializedParam(
+                    default=default,
+                    title=str(label) if label is not None else None,
+                    description=str(description) if description else None,
+                    source=None,
+                    type=types,
+                    **format_dict,
+                    **enum,
+                ).dump()
+                hook_widgets[form_widget.field_name] = param
             else:
                 log.error("Unknown form widget in %s: %s", hook_key, form_widget)
                 continue