Parcourir la source

Support event listener through Hooks (#1993)

* Support event listener through Hooks
linked to taipy-enterprise 362

* use event listener in core elements

* refresh auth on init with state

* Make firing events non blocking and not in the current process (ie time for state to be updated ...)

* wait for the timer to execute

* Fab's comments

* manage events

* callable is not always callable

* manage callable instances

* Fab's comments

* sorting

---------

Co-authored-by: Fred Lefévère-Laoide <Fred.Lefevere-Laoide@Taipy.io>
Fred Lefévère-Laoide il y a 7 mois
Parent
commit
e82c6db393

+ 29 - 0
taipy/gui/_event_context_manager.py

@@ -0,0 +1,29 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed 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.
+
+import typing as t
+from threading import Thread
+
+
+class _EventManager:
+    def __init__(self) -> None:
+        self.__thread_stack: t.List[Thread] = []
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if self.__thread_stack:
+            self.__thread_stack.pop().start()
+        return self
+
+    def _add_thread(self, thread: Thread):
+        self.__thread_stack.append(thread)

+ 1 - 1
taipy/gui/_hook.py

@@ -42,7 +42,7 @@ class _Hooks(object, metaclass=_Singleton):
                     func = getattr(hook, name)
                     if not callable(func):
                         raise Exception(f"'{name}' hook is not callable")
-                    res = getattr(hook, name)(*args, **kwargs)
+                    res = func(*args, **kwargs)
                 except Exception as e:
                     _TaipyLogger._get_logger().error(f"Error while calling hook '{name}': {e}")
                     return

+ 23 - 19
taipy/gui/_renderers/builder.py

@@ -17,7 +17,7 @@ import typing as t
 import xml.etree.ElementTree as etree
 from datetime import date, datetime, time
 from enum import Enum
-from inspect import isclass, isroutine
+from inspect import isclass
 from types import LambdaType
 from urllib.parse import quote
 
@@ -34,7 +34,9 @@ from ..utils import (
     _getscopeattr,
     _getscopeattr_drill,
     _is_boolean,
+    _is_function,
     _is_true,
+    _is_unnamed_function,
     _MapDict,
     _to_camel_case,
 )
@@ -140,7 +142,7 @@ class _Builder:
             hash_value = gui._evaluate_expr(value)
             try:
                 func = gui._get_user_function(hash_value)
-                if isroutine(func):
+                if _is_function(func):
                     return (func, hash_value)
                 return (_getscopeattr_drill(gui, hash_value), hash_value)
             except AttributeError:
@@ -163,10 +165,10 @@ class _Builder:
                     (val, hash_name) = _Builder.__parse_attribute_value(gui, v.replace('\\"', '"'))
                 else:
                     val = v
-                if isroutine(val) and not hash_name:
+                if _is_function(val) and not hash_name:
                     # if it's not a callable (and not a string), forget it
-                    if val.__name__ == "<lambda>":
-                        # if it is a lambda and it has already a hash_name, we're fine
+                    if _is_unnamed_function(val):
+                        # lambda or callable instance
                         hash_name = _get_lambda_id(t.cast(LambdaType, val))
                         gui._bind_var_val(hash_name, val)  # type: ignore[arg-type]
                     else:
@@ -294,7 +296,7 @@ class _Builder:
             except ValueError:
                 raise ValueError(f"Property {name} expects a number for control {self.__control_type}") from None
         elif isinstance(value, numbers.Number):
-            val = value  # type: ignore
+            val = value # type: ignore[assignment]
         else:
             raise ValueError(
                 f"Property {name} expects a number for control {self.__control_type}, received {type(value)}"
@@ -347,7 +349,7 @@ class _Builder:
             if not optional:
                 _warn(f"Property {name} is required for control {self.__control_type}.")
             return self
-        elif isroutine(str_attr):
+        elif _is_function(str_attr):
             str_attr = self.__hashes.get(name)
             if str_attr is None:
                 return self
@@ -403,12 +405,12 @@ class _Builder:
         adapter = self.__attributes.get("adapter", adapter)
         if adapter and isinstance(adapter, str):
             adapter = self.__gui._get_user_function(adapter)
-        if adapter and not callable(adapter):
+        if adapter and not _is_function(adapter):
             _warn(f"{self.__element_name}: adapter property value is invalid.")
             adapter = None
         var_type = self.__attributes.get("type", var_type)
         if isclass(var_type):
-            var_type = var_type.__name__  # type: ignore
+            var_type = var_type.__name__
 
         if isinstance(lov, list):
             if not isinstance(var_type, str):
@@ -425,10 +427,10 @@ class _Builder:
                 var_type = self.__gui._get_unique_type_adapter(type(elt).__name__)
             if adapter is None:
                 adapter = self.__gui._get_adapter_for_type(var_type)
-            elif var_type == str.__name__ and isroutine(adapter):
+            elif var_type == str.__name__ and _is_function(adapter):
                 var_type += (
                     _get_lambda_id(t.cast(LambdaType, adapter))
-                    if adapter.__name__ == "<lambda>"
+                    if _is_unnamed_function(adapter)
                     else _get_expr_var_name(adapter.__name__)
                 )
             if lov_name:
@@ -442,13 +444,15 @@ class _Builder:
                 else:
                     self.__gui._add_type_for_var(value_name, t.cast(str, var_type))
             if adapter is not None:
-                self.__gui._add_adapter_for_type(var_type, adapter)  # type: ignore
+                self.__gui._add_adapter_for_type(var_type, adapter) # type: ignore[arg-type]
 
             if default_lov is not None and lov:
                 for elt in lov:
                     ret = self.__gui._run_adapter(
-                        t.cast(t.Callable, adapter), elt, adapter.__name__ if isroutine(adapter) else "adapter"
-                    )  # type: ignore
+                        t.cast(t.Callable, adapter),
+                        elt,
+                        adapter.__name__ if hasattr(adapter, "__name__") else "adapter",
+                    )
                     if ret is not None:
                         default_lov.append(ret)
 
@@ -459,9 +463,9 @@ class _Builder:
                 ret = self.__gui._run_adapter(
                     t.cast(t.Callable, adapter),
                     val,
-                    adapter.__name__ if isroutine(adapter) else "adapter",
+                    adapter.__name__ if hasattr(adapter, "__name__") else "adapter",
                     id_only=True,
-                )  # type: ignore
+                )
                 if ret is not None:
                     ret_list.append(ret)
             if multi_selection:
@@ -575,7 +579,7 @@ class _Builder:
         if not isinstance(self.__attributes.get("style"), (type(None), dict, _MapDict)):
             _warn("Table: property 'style' has been renamed to 'row_class_name'.")
         if row_class_name := self.__attributes.get("row_class_name"):
-            if isroutine(row_class_name):
+            if _is_function(row_class_name):
                 value = self.__hashes.get("row_class_name")
             elif isinstance(row_class_name, str):
                 value = row_class_name.strip()
@@ -586,7 +590,7 @@ class _Builder:
             elif value:
                 self.set_attribute("rowClassName", value)
         if tooltip := self.__attributes.get("tooltip"):
-            if isroutine(tooltip):
+            if _is_function(tooltip):
                 value = self.__hashes.get("tooltip")
             elif isinstance(tooltip, str):
                 value = tooltip.strip()
@@ -806,7 +810,7 @@ class _Builder:
         elif var_type == PropertyType.lov_value:
             # Done by _get_adapter
             return self
-        elif isclass(var_type) and issubclass(var_type, _TaipyBase):  # type: ignore
+        elif isclass(var_type) and issubclass(var_type, _TaipyBase):
             return self.__set_default_value(var_name, t.cast(t.Callable, var_type)(value, "").get())
         else:
             return self.__set_json_attribute(default_var_name, value)

+ 115 - 92
taipy/gui/gui.py

@@ -24,9 +24,9 @@ import typing as t
 import warnings
 from importlib import metadata, util
 from importlib.util import find_spec
-from inspect import currentframe, getabsfile, ismethod, ismodule, isroutine
+from inspect import currentframe, getabsfile, ismethod, ismodule
 from pathlib import Path
-from threading import Timer
+from threading import Thread, Timer
 from types import FrameType, FunctionType, LambdaType, ModuleType, SimpleNamespace
 from urllib.parse import unquote, urlencode, urlparse
 
@@ -51,6 +51,7 @@ if util.find_spec("pyngrok"):
     from pyngrok import ngrok  # type: ignore[reportMissingImports]
 
 from ._default_config import _default_stylekit, default_config
+from ._event_context_manager import _EventManager
 from ._hook import _Hooks
 from ._page import _Page
 from ._renderers import _EmptyPage
@@ -77,6 +78,7 @@ from .utils import (
     _delscopeattr,
     _DoNotUpdate,
     _filter_locals,
+    _function_name,
     _get_broadcast_var_name,
     _get_client_var_name,
     _get_css_var_value,
@@ -88,7 +90,9 @@ from .utils import (
     _getscopeattr,
     _getscopeattr_drill,
     _hasscopeattr,
+    _is_function,
     _is_in_notebook,
+    _is_unnamed_function,
     _LocalsContext,
     _MapDict,
     _setscopeattr,
@@ -378,6 +382,8 @@ class Gui:
             ]
         )
 
+        self.__event_manager = _EventManager()
+
         # Init Gui Hooks
         _Hooks()._init(self)
 
@@ -436,7 +442,7 @@ class Gui:
         if Gui.__content_providers.get(content_type):
             _warn(f"The type {content_type} is already associated with a provider.")
             return
-        if not callable(content_provider):
+        if not _is_function(content_provider):
             _warn(f"The provider for {content_type} must be a function.")
             return
         Gui.__content_providers[content_type] = content_provider
@@ -480,9 +486,9 @@ class Gui:
                         Gui.register_content_provider(MatplotlibFigure, get_matplotlib_content)
                         provider_fn = get_matplotlib_content
 
-            if callable(provider_fn):
+            if _is_function(provider_fn):
                 try:
-                    return provider_fn(t.cast(t.Any, content))
+                    return t.cast(t.Callable, provider_fn)(t.cast(t.Any, content))
                 except Exception as e:
                     _warn(f"Error in content provider for type {str(type(content))}", e)
         return (
@@ -821,23 +827,11 @@ class Gui:
             _warn("", e)
             return
         on_change_fn = self._get_user_function(on_change) if on_change else None
-        if not callable(on_change_fn):
+        if not _is_function(on_change_fn):
             on_change_fn = self._get_user_function("on_change")
-        if callable(on_change_fn):
+        if _is_function(on_change_fn):
             try:
-                arg_count = on_change_fn.__code__.co_argcount
-                if arg_count > 0 and ismethod(on_change_fn):
-                    arg_count -= 1
-                args: t.List[t.Any] = [None for _ in range(arg_count)]
-                if arg_count > 0:
-                    args[0] = self.__get_state()
-                if arg_count > 1:
-                    args[1] = var_name
-                if arg_count > 2:
-                    args[2] = value
-                if arg_count > 3:
-                    args[3] = current_context
-                on_change_fn(*args)
+                self._call_function_with_state(t.cast(t.Callable, on_change_fn), [var_name, value, current_context])
             except Exception as e:  # pragma: no cover
                 if not self._call_on_exception(on_change or "on_change", e):
                     _warn(f"{on_change or 'on_change'}(): callback function raised an exception", e)
@@ -879,7 +873,7 @@ class Gui:
             cb_function_name = q_args.get(Gui.__USER_CONTENT_CB)
             if cb_function_name:
                 cb_function = self._get_user_function(cb_function_name)
-                if not callable(cb_function):
+                if not _is_function(cb_function):
                     parts = cb_function_name.split(".", 1)
                     if len(parts) > 1:
                         base = _getscopeattr(self, parts[0], None)
@@ -889,29 +883,29 @@ class Gui:
                             base = self.__evaluator._get_instance_in_context(parts[0])
                             if base and (meth := getattr(base, parts[1], None)):
                                 cb_function = meth
-                if not callable(cb_function):
+                if not _is_function(cb_function):
                     _warn(f"{cb_function_name}() callback function has not been defined.")
                     cb_function = None
         if cb_function is None:
             cb_function_name = "on_user_content"
-            if hasattr(self, cb_function_name) and callable(self.on_user_content):
+            if hasattr(self, cb_function_name) and _is_function(self.on_user_content):
                 cb_function = self.on_user_content
             else:
                 _warn("on_user_content() callback function has not been defined.")
-        if callable(cb_function):
+        if _is_function(cb_function):
             try:
                 args: t.List[t.Any] = []
                 if path:
                     args.append(path)
                 if len(q_args):
                     args.append(q_args)
-                ret = self._call_function_with_state(cb_function, args)
+                ret = self._call_function_with_state(t.cast(t.Callable, cb_function), args)
                 if ret is None:
                     _warn(f"{cb_function_name}() callback function must return a value.")
                 else:
                     return (ret, 200)
             except Exception as e:  # pragma: no cover
-                if not self._call_on_exception(str(cb_function_name), e):
+                if not self._call_on_exception(cb_function_name, e):
                     _warn(f"{cb_function_name}() callback function raised an exception", e)
         return ("", 404)
 
@@ -1038,9 +1032,12 @@ class Gui:
                                 pass
                         data["path"] = file_path
                         file_fn = self._get_user_function(on_upload_action)
-                        if not callable(file_fn):
+                        if not _is_function(file_fn):
                             file_fn = _getscopeattr(self, on_upload_action)
-                        self._call_function_with_state(file_fn, ["file_upload", {"args": [data]}])
+                        if _is_function(file_fn):
+                            self._call_function_with_state(
+                                t.cast(t.Callable, file_fn), ["file_upload", {"args": [data]}]
+                            )
                     else:
                         setattr(self._bindings(), var_name, newvalue)
         return ("", 200)
@@ -1446,13 +1443,13 @@ class Gui:
         func = (
             getattr(self, func_name.split(".", 2)[1], func_name) if func_name.startswith(f"{Gui.__SELF_VAR}.") else None
         )
-        if not callable(func):
+        if not _is_function(func):
             func = _getscopeattr(self, func_name, None)
-        if not callable(func):
+        if not _is_function(func):
             func = self._get_locals_bind().get(func_name)
-        if not callable(func):
+        if not _is_function(func):
             func = self.__locals_context.get_default().get(func_name)
-        return func if callable(func) else func_name
+        return t.cast(t.Callable, func) if _is_function(func) else func_name
 
     def _get_user_instance(self, class_name: str, class_type: type) -> t.Union[object, str]:
         cls = _getscopeattr(self, class_name, None)
@@ -1508,26 +1505,17 @@ class Gui:
         id = t.cast(str, kwargs.get("id"))
         payload = kwargs.get("payload")
 
-        if callable(action_function):
+        if _is_function(action_function):
             try:
-                argcount = action_function.__code__.co_argcount
-                if argcount > 0 and ismethod(action_function):
-                    argcount -= 1
-                args = t.cast(list, [None for _ in range(argcount)])
-                if argcount > 0:
-                    args[0] = self.__get_state()
-                if argcount > 1:
-                    try:
-                        args[1] = self._get_real_var_name(id)[0]
-                    except Exception:
-                        args[1] = id
-                if argcount > 2:
-                    args[2] = payload
-                action_function(*args)
+                try:
+                    args = [self._get_real_var_name(id)[0], payload]
+                except Exception:
+                    args = [id, payload]
+                self._call_function_with_state(t.cast(t.Callable, action_function), [args])
                 return True
             except Exception as e:  # pragma: no cover
-                if not self._call_on_exception(action_function.__name__, e):
-                    _warn(f"on_action(): Exception raised in '{action_function.__name__}()'", e)
+                if not self._call_on_exception(action_function, e):
+                    _warn(f"on_action(): Exception raised in '{_function_name(action_function)}()'", e)
         return False
 
     def _call_function_with_state(self, user_function: t.Callable, args: t.Optional[t.List[t.Any]] = None) -> t.Any:
@@ -1540,7 +1528,8 @@ class Gui:
             cp_args += (argcount - len(cp_args)) * [None]
         else:
             cp_args = cp_args[:argcount]
-        return user_function(*cp_args)
+        with self.__event_manager:
+            return user_function(*cp_args)
 
     def _set_module_context(self, module_context: t.Optional[str]) -> t.ContextManager[None]:
         return self._set_locals_context(module_context) if module_context is not None else contextlib.nullcontext()
@@ -1548,7 +1537,7 @@ class Gui:
     def invoke_callback(
         self,
         state_id: str,
-        callback: t.Callable,
+        callback: t.Union[str, t.Callable],
         args: t.Optional[t.Sequence[t.Any]] = None,
         module_context: t.Optional[str] = None,
     ) -> t.Any:
@@ -1559,7 +1548,7 @@ class Gui:
 
         Arguments:
             state_id: The identifier of the state to use, as returned by `get_state_id()^`.
-            callback (Callable[[State^, ...], None]): The user-defined function that is invoked.<br/>
+            callback (Union[str, Callable[[State^, ...], None]]): The user-defined function that is invoked.<br/>
                 The first parameter of this function **must** be a `State^`.
             args (Optional[Sequence]): The remaining arguments, as a List or a Tuple.
             module_context (Optional[str]): The name of the module that will be used.
@@ -1573,21 +1562,20 @@ class Gui:
             with self.get_flask_app().app_context():
                 setattr(g, Gui.__ARG_CLIENT_ID, state_id)
                 with self._set_module_context(module_context):
-                    if not callable(callback):
-                        callback = self._get_user_function(callback)
-                    if not callable(callback):
+                    if not _is_function(callback):
+                        callback = self._get_user_function(t.cast(str, callback))
+                    if not _is_function(callback):
                         _warn(f"invoke_callback(): {callback} is not callable.")
                         return None
-                    return self._call_function_with_state(callback, list(args) if args else None)
+                    return self._call_function_with_state(t.cast(t.Callable, callback), list(args) if args else None)
         except Exception as e:  # pragma: no cover
-            if not self._call_on_exception(callback.__name__ if callable(callback) else callback, e):
+            if not self._call_on_exception(callback, e):
                 _warn(
-                    "Gui.invoke_callback(): Exception raised in "
-                    + f"'{callback.__name__ if callable(callback) else callback}()'",
+                    f"Gui.invoke_callback(): Exception raised in {_function_name(callback)}",
                     e,
                 )
         finally:
-            if this_sid and request:
+            if this_sid:
                 request.sid = this_sid  # type: ignore[attr-defined]
         return None
 
@@ -2174,13 +2162,13 @@ class Gui:
 
     def __bind_local_func(self, name: str):
         func = getattr(self, name, None)
-        if func is not None and not callable(func):  # pragma: no cover
+        if func is not None and not _is_function(func):  # pragma: no cover
             _warn(f"{self.__class__.__name__}.{name}: {func} should be a function; looking for {name} in the script.")
             func = None
         if func is None:
             func = self._get_locals_bind().get(name)
         if func is not None:
-            if callable(func):
+            if _is_function(func):
                 setattr(self, name, func)
             else:  # pragma: no cover
                 _warn(f"{name}: {func} should be a function.")
@@ -2225,11 +2213,11 @@ class Gui:
     def _download(
         self, content: t.Any, name: t.Optional[str] = "", on_action: t.Optional[t.Union[str, t.Callable]] = ""
     ):
-        if isroutine(on_action) and on_action.__name__:
+        if _is_function(on_action):
             on_action_name = (
                 _get_lambda_id(t.cast(LambdaType, on_action))
-                if on_action.__name__ == "<lambda>"
-                else _get_expr_var_name(on_action.__name__)
+                if _is_unnamed_function(on_action)
+                else _get_expr_var_name(t.cast(t.Callable, on_action).__name__)
             )
             if on_action_name:
                 self._bind_var_val(on_action_name, on_action)
@@ -2258,8 +2246,13 @@ class Gui:
         callback: t.Optional[t.Union[str, t.Callable]] = None,
         message: t.Optional[str] = "Work in Progress...",
     ):  # pragma: no cover
-        action_name = callback.__name__ if callable(callback) else callback
-        # TODO: what if lambda? (it does work)
+        action_name = (
+            callback
+            if isinstance(callback, str)
+            else _get_lambda_id(t.cast(LambdaType, callback))
+            if _is_unnamed_function(callback)
+            else callback.__name__ if callback is not None else None
+        )
         func = self.__get_on_cancel_block_ui(action_name)
         def_action_name = func.__name__
         _setscopeattr(self, def_action_name, func)
@@ -2306,27 +2299,28 @@ class Gui:
             _setscopeattr(self, Gui.__ON_INIT_NAME, True)
             self.__pre_render_pages()
             self.__init_libs()
-            if hasattr(self, "on_init") and callable(self.on_init):
+            if hasattr(self, "on_init") and _is_function(self.on_init):
                 try:
-                    self._call_function_with_state(self.on_init)
+                    self._call_function_with_state(t.cast(t.Callable, self.on_init))
                 except Exception as e:  # pragma: no cover
                     if not self._call_on_exception("on_init", e):
                         _warn("Exception raised in on_init()", e)
         return self._render_route()
 
-    def _call_on_exception(self, function_name: str, exception: Exception) -> bool:
-        if hasattr(self, "on_exception") and callable(self.on_exception):
+    def _call_on_exception(self, function: t.Any, exception: Exception) -> bool:
+        if hasattr(self, "on_exception") and _is_function(self.on_exception):
+            function_name = _function_name(function) if callable(function) else str(function)
             try:
-                self.on_exception(self.__get_state(), function_name, exception)
+                self._call_function_with_state(t.cast(t.Callable, self.on_exception), [function_name, exception])
             except Exception as e:  # pragma: no cover
                 _warn("Exception raised in on_exception()", e)
             return True
         return False
 
     def __call_on_status(self) -> t.Optional[str]:
-        if hasattr(self, "on_status") and callable(self.on_status):
+        if hasattr(self, "on_status") and _is_function(self.on_status):
             try:
-                return self.on_status(self.__get_state())
+                return self._call_function_with_state(t.cast(t.Callable, self.on_status))
             except Exception as e:  # pragma: no cover
                 if not self._call_on_exception("on_status", e):
                     _warn("Exception raised in on_status", e)
@@ -2349,15 +2343,12 @@ class Gui:
 
     def _get_navigated_page(self, page_name: str) -> t.Any:
         nav_page = page_name
-        if hasattr(self, "on_navigate") and callable(self.on_navigate):
+        if hasattr(self, "on_navigate") and _is_function(self.on_navigate):
             try:
-                if self.on_navigate.__code__.co_argcount == 2:
-                    nav_page = self.on_navigate(self.__get_state(), page_name)
-                else:
-                    params = request.args.to_dict() if hasattr(request, "args") else {}
-                    params.pop("client_id", None)
-                    params.pop("v", None)
-                    nav_page = self.on_navigate(self.__get_state(), page_name, params)
+                params = request.args.to_dict() if hasattr(request, "args") else {}
+                params.pop("client_id", None)
+                params.pop("v", None)
+                nav_page = self._call_function_with_state(t.cast(t.Callable, self.on_navigate), [page_name, params])
                 if nav_page != page_name:
                     if isinstance(nav_page, str):
                         if self._navigate(nav_page):
@@ -2374,18 +2365,10 @@ class Gui:
         if page_name == Gui.__root_page_name:
             page_name = "/"
         on_page_load_fn = self._get_user_function("on_page_load")
-        if not callable(on_page_load_fn):
+        if not _is_function(on_page_load_fn):
             return
         try:
-            arg_count = on_page_load_fn.__code__.co_argcount
-            if arg_count > 0 and ismethod(on_page_load_fn):
-                arg_count -= 1
-            args: t.List[t.Any] = [None for _ in range(arg_count)]
-            if arg_count > 0:
-                args[0] = self.__get_state()
-            if arg_count > 1:
-                args[1] = page_name
-            on_page_load_fn(*args)
+            self._call_function_with_state(t.cast(t.Callable, on_page_load_fn), [page_name])
         except Exception as e:
             if not self._call_on_exception("on_page_load", e):
                 _warn("Exception raised in on_page_load()", e)
@@ -2907,3 +2890,43 @@ class Gui:
             self._broadcast(
                 "taipy_favicon", url, self._get_client_id() if state else None, message_type=_WsType.FAVICON
             )
+
+    @staticmethod
+    def _add_event_listener(
+        event_name: str,
+        listener: t.Union[
+            t.Callable[[str, t.Dict[str, t.Any]], None], t.Callable[[State, str, t.Dict[str, t.Any]], None]
+        ],
+        with_state: t.Optional[bool] = False,
+    ):
+        _Hooks()._add_event_listener(event_name, listener, with_state)
+
+    def _fire_event(
+        self, event_name: str, client_id: t.Optional[str] = None, payload: t.Optional[t.Dict[str, t.Any]] = None
+    ):
+        # the event manager will take care of starting the thread
+        # once the current callback (or the next one) is finished
+        self.__event_manager._add_thread(
+            Thread(
+                target=self.__do_fire_event,
+                args=(event_name, client_id, payload),
+            )
+        )
+
+    def __do_fire_event(
+        self, event_name: str, client_id: t.Optional[str] = None, payload: t.Optional[t.Dict[str, t.Any]] = None
+    ):
+        this_sid = None
+        if request:
+            # avoid messing with the client_id => Set(ws id)
+            this_sid = getattr(request, "sid", None)
+            request.sid = None  # type: ignore[attr-defined]
+
+        try:
+            with self.get_flask_app().app_context(), self.__event_manager:
+                if client_id:
+                    setattr(g, Gui.__ARG_CLIENT_ID, client_id)
+                _Hooks()._fire_event(event_name, client_id, payload)
+        finally:
+            if this_sid:
+                request.sid = this_sid  # type: ignore[attr-defined]

+ 1 - 0
taipy/gui/utils/__init__.py

@@ -23,6 +23,7 @@ from ._map_dict import _MapDict
 from ._runtime_manager import _RuntimeManager
 from ._variable_directory import _variable_decode, _variable_encode, _VariableDirectory
 from .boolean import _is_boolean, _is_true
+from .callable import _function_name, _is_function, _is_unnamed_function
 from .clientvarname import _get_broadcast_var_name, _get_client_var_name, _to_camel_case
 from .datatype import _get_data_type
 from .date import _date_to_string, _string_to_date

+ 1 - 1
taipy/gui/utils/_evaluator.py

@@ -263,7 +263,7 @@ class _Evaluator:
             _warn(f"Cannot evaluate expression '{not_encoded_expr if is_edge_case else expr_string}'", e)
             expr_evaluated = None
         if lambda_expr and callable(expr_evaluated):
-            expr_hash = _get_lambda_id(expr_evaluated, module=module_name)
+            expr_hash = _get_lambda_id(expr_evaluated, module=module_name)  # type: ignore[reportArgumentType]
         # save the expression if it needs to be re-evaluated
         return self.__save_expression(gui, expr, expr_hash, expr_evaluated, var_map, lambda_expr)
 

+ 31 - 0
taipy/gui/utils/callable.py

@@ -0,0 +1,31 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed 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.
+
+import typing as t
+from inspect import isclass
+from types import LambdaType
+
+
+def _is_function(s: t.Any) -> bool:
+    return callable(s) and not isclass(s)
+
+
+def _function_name(s: t.Any) -> str:
+    if hasattr(s, "__name__"):
+        return s.__name__
+    elif callable(s):
+        return f"<instance of {type(s).__name__}>"
+    else:
+        return str(s)
+
+
+def _is_unnamed_function(s: t.Any):
+    return isinstance(s, LambdaType) or (callable(s) and not hasattr(s, "__name__"))

+ 10 - 9
taipy/gui_core/_GuiCoreLib.py

@@ -13,7 +13,7 @@ import typing as t
 from datetime import datetime
 
 from taipy.core import Cycle, DataNode, Job, Scenario, Sequence, Task
-from taipy.gui import Gui
+from taipy.gui import Gui, State
 from taipy.gui.extension import Element, ElementLibrary, ElementProperty, PropertyType
 
 from ..version import _get_version
@@ -103,9 +103,7 @@ class _GuiCore(ElementLibrary):
                     f"{{{__CTX_VAR_NAME}.get_scenario_by_id({__SCENARIO_SELECTOR_ID_VAR}<tp:uniq:sc>)}}",
                 ),
                 "on_scenario_select": ElementProperty(PropertyType.function, f"{{{__CTX_VAR_NAME}.select_scenario}}"),
-                "creation_not_allowed": ElementProperty(
-                    PropertyType.string, f"{{{__CTX_VAR_NAME}.get_creation_reason()}}"
-                ),
+                "creation_not_allowed": ElementProperty(PropertyType.broadcast, _GuiCoreContext._AUTH_CHANGED_NAME),
                 "update_sc_vars": ElementProperty(
                     PropertyType.string,
                     f"filter={__SCENARIO_SELECTOR_FILTER_VAR}<tp:uniq:sc>;"
@@ -321,11 +319,14 @@ class _GuiCore(ElementLibrary):
         return ["lib/taipy-gui-core.js"]
 
     def on_init(self, gui: Gui) -> t.Optional[t.Tuple[str, t.Any]]:
-        ctx = _GuiCoreContext(gui)
-        gui._add_adapter_for_type(_GuiCore.__SCENARIO_ADAPTER, ctx.scenario_adapter)
-        gui._add_adapter_for_type(_GuiCore.__DATANODE_ADAPTER, ctx.data_node_adapter)
-        gui._add_adapter_for_type(_GuiCore.__JOB_ADAPTER, ctx.job_adapter)
-        return _GuiCore.__CTX_VAR_NAME, ctx
+        self.ctx = _GuiCoreContext(gui)
+        gui._add_adapter_for_type(_GuiCore.__SCENARIO_ADAPTER, self.ctx.scenario_adapter)
+        gui._add_adapter_for_type(_GuiCore.__DATANODE_ADAPTER, self.ctx.data_node_adapter)
+        gui._add_adapter_for_type(_GuiCore.__JOB_ADAPTER, self.ctx.job_adapter)
+        return _GuiCore.__CTX_VAR_NAME, self.ctx
+
+    def on_user_init(self, state: State):
+        self.ctx.on_user_init(state)
 
     def get_version(self) -> str:
         if not hasattr(self, "version"):

+ 17 - 7
taipy/gui_core/_context.py

@@ -34,6 +34,7 @@ from taipy.core import (
     SequenceId,
     Submission,
     SubmissionId,
+    can_create,
     cancel_job,
     create_scenario,
     delete_job,
@@ -56,7 +57,7 @@ from taipy.core.notification.event import Event, EventOperation
 from taipy.core.notification.notifier import Notifier
 from taipy.core.reason import ReasonCollection
 from taipy.core.submission.submission_status import SubmissionStatus
-from taipy.gui import Gui, State
+from taipy.gui import Gui, State, get_state_id
 from taipy.gui._warnings import _warn
 from taipy.gui.gui import _DoNotUpdate
 from taipy.gui.utils._map_dict import _MapDict
@@ -82,6 +83,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
     __ENTITY_PROPS = (__PROP_CONFIG_ID, __PROP_DATE, __PROP_ENTITY_NAME)
     __ACTION = "action"
     _CORE_CHANGED_NAME = "core_changed"
+    _AUTH_CHANGED_NAME = "auth_changed"
 
     def __init__(self, gui: Gui) -> None:
         self.gui = gui
@@ -97,9 +99,14 @@ class _GuiCoreContext(CoreEventConsumerBase):
         self.submissions_lock = Lock()
         # lazy_start
         self.__started = False
+        # Gui event listener
+        gui._add_event_listener("authorization", self._auth_listener, with_state=True)
         # super
         super().__init__(reg_id, reg_queue)
 
+    def on_user_init(self, state: State):
+        self.gui._fire_event("authorization", get_state_id(state), {})
+
     def __lazy_start(self):
         if self.__started:
             return
@@ -1199,12 +1206,6 @@ class _GuiCoreContext(CoreEventConsumerBase):
         elif args[1]:
             _warn(f"dag.on_action(): Invalid function '{args[1]}()'.")
 
-    def get_creation_reason(self):
-        self.__lazy_start()
-        # make this dynamic
-        # return "" if (reason := can_create()) else f"Cannot create scenario: {_get_reason(reason)}"
-        return ""
-
     def on_file_action(self, state: State, id: str, payload: t.Dict[str, t.Any]):
         args = t.cast(list, payload.get("args"))
         act_payload = t.cast(t.Dict[str, str], args[0])
@@ -1240,6 +1241,15 @@ class _GuiCoreContext(CoreEventConsumerBase):
         else:
             state.assign(error_id, reason.reasons)
 
+    def _auth_listener(self, state: State, client_id: t.Optional[str], payload: t.Dict[str, t.Any]):
+        self.gui._broadcast(
+            _GuiCoreContext._AUTH_CHANGED_NAME,
+            payload.get("override", "")
+            if (reason := can_create())
+            else f"Cannot create scenario: {_get_reason(reason)}",
+            client_id,
+        )
+
 
 def _get_reason(reason: t.Union[bool, ReasonCollection]):
     return reason.reasons if isinstance(reason, ReasonCollection) else " "

+ 10 - 0
tests/gui/hooks/__init__.py

@@ -0,0 +1,10 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed 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.

+ 66 - 0
tests/gui/hooks/test_listener.py

@@ -0,0 +1,66 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed 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.
+import time
+import typing as t
+from unittest.mock import MagicMock
+
+from taipy.gui import Gui
+from taipy.gui._hook import _Hook, _Hooks
+
+
+def test_empty_listener(gui: Gui):
+    listener = MagicMock()
+    event = "an_event"
+    payload = {"a": 1}
+
+    gui._add_event_listener(event, listener)
+
+    gui._fire_event(event, None, payload)
+
+    listener.assert_not_called()
+
+def test_listener(gui: Gui):
+    class ListenerHook(_Hook):
+        method_names = ["_add_event_listener", "_fire_event"]
+
+        def __init__(self):
+            super().__init__()
+            self.listeners = {}
+
+        def _add_event_listener(
+            self,
+            event_name: str,
+            listener: t.Callable[[str, t.Dict[str, t.Any]], None],
+            with_state: t.Optional[bool] = False,
+        ):
+            self.listeners[event_name] = listener
+
+        def _fire_event(
+            self, event_name: str, client_id: t.Optional[str] = None, payload: t.Optional[t.Dict[str, t.Any]] = None
+        ):
+            if func := self.listeners.get(event_name):
+                func(event_name, client_id, payload)
+
+    gui.run(run_server=False, single_client=True, stylekit=False)
+
+    _Hooks()._register_hook(ListenerHook())
+
+    listener = MagicMock()
+    event = "an_event"
+    payload = {"a": 1}
+
+    gui._add_event_listener(event, listener)
+
+    with gui._Gui__event_manager: # type: ignore[attr-defined]
+        gui._fire_event(event, None, payload)
+
+    time.sleep(0.3)
+    listener.assert_called_once_with(event, None, payload)