Repository: Avaiga/taipy
Pull request: broadcast callback on non shared variables
resolves #1207

~renamed current `broadcast_callback` to `broadcast_callback_on_shared`~


Problem statement:
Possibility to forward a change to a subset of clients (conditional broadcast)
**Description**
Being able to broadcast variables not to all the clients but only a subset. A user variable or filter function defines this subset.

**Acceptance Criteria**
- [ ] Ensure new code is unit tested, and check code coverage is at least 90%
- [ ] Propagate any change on the demos and run all of them to ensure there is no breaking change
- [ ] Ensure any change is well documented

Hints from issue discussion:
I think we have internally a *_broadcast* function that broadcasts to a list of clients. Is it true?
This issue has been labelled as "🥶Waiting for contributor" because it has been inactive for more than 14 days. If you would like to continue working on this issue, please add another comment or create a PR that links to this issue. If a PR has already been created which refers to this issue, then you should explicitly mention this issue in the relevant PR. Otherwise, you will be unassigned in 14 days. For more information please refer to the contributing guidelines.
A great use case is a Chat application: not all clients should receive a message sent to a subset of connected users.
A use case is in this issue: https://github.com/Avaiga/taipy-doc/issues/941
Conditional broadcast is achieved via callback: 
broadcast_callback is called on every State with parameters and the callback can modify (or not) each state.

Commit message: remove broadcast_callback_on_shared

Patch to review:
diff --git a/doc/gui/examples/broadcast.py b/doc/gui/examples/broadcast.py
index 52e72c5738..b3ba8730be 100644
--- a/doc/gui/examples/broadcast.py
+++ b/doc/gui/examples/broadcast.py
@@ -15,9 +15,9 @@
 # -----------------------------------------------------------------------------------------
 # Demonstrate how to share variable values across multiple clients.
 # This application creates a thread that increments a value every few seconds.
-# The value is updated for every client using the state.broadcast() method.
+# The value is updated for every client using the broadcast_callback() method.
 # The text of the button that starts or stops the thread is updated on every client's browser
-# using a direct assignment of the state property because the variable is declared 'shared'.
+# using the broadcast_callback() method.
 # -----------------------------------------------------------------------------------------
 from threading import Event, Thread
 from time import sleep
@@ -35,17 +35,15 @@
 button_text = button_texts[0]
 
 
-# Invoked by the timer
-def update_counter(state: State, c):
-    # Update all clients
-    state.broadcast("counter", c)
-
+def update_state_var(state: State, var_name: str, val):
+    # Update each clients
+    state.assign(var_name, val)
 
 def count(event, gui):
     while not event.is_set():
         global counter
         counter = counter + 1
-        broadcast_callback(gui, update_counter, [counter])
+        broadcast_callback(gui, update_state_var, ["counter", counter])
         sleep(2)
 
 
@@ -59,9 +57,8 @@ def start_or_stop(state):
         thread_event.clear()
         thread = Thread(target=count, args=[thread_event, state.get_gui()])
         thread.start()
-    # Update button status.
-    # Because "button_text" is shared, all clients are updated
-    state.button_text = button_texts[1 if thread else 0]
+    # Update button status for all states.
+    broadcast_callback(state.get_gui(), update_state_var, ["button_text", button_texts[1 if thread else 0]])
 
 
 page = """# Broadcasting values
@@ -75,4 +72,4 @@ def start_or_stop(state):
 # Assigning a value to a state's 'button_text' property is propagated to all clients
 Gui.add_shared_variable("button_text")
 
-Gui(page).run()
+Gui(page).run(debug=True)
diff --git a/frontend/taipy-gui/src/components/Taipy/PaginatedTable.tsx b/frontend/taipy-gui/src/components/Taipy/PaginatedTable.tsx
index a5ebb8848e..dafa3433a8 100644
--- a/frontend/taipy-gui/src/components/Taipy/PaginatedTable.tsx
+++ b/frontend/taipy-gui/src/components/Taipy/PaginatedTable.tsx
@@ -173,7 +173,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
                 }
                 return [colsOrder, baseColumns, styTt.styles, styTt.tooltips, hNan, filter];
             } catch (e) {
-                console.info("PTable.columns: " + ((e as Error).message || e));
+                console.info("PTable.columns: ", (e as Error).message || e);
             }
         }
         return [
diff --git a/frontend/taipy-gui/src/utils/index.ts b/frontend/taipy-gui/src/utils/index.ts
index e77b1e14ab..f5fa07ec0c 100644
--- a/frontend/taipy-gui/src/utils/index.ts
+++ b/frontend/taipy-gui/src/utils/index.ts
@@ -170,23 +170,13 @@ export const formatWSValue = (
             if (value == "") {
                 return "";
             }
-            try {
-                return getDateTimeString(value.toString(), dataFormat, formatConf);
-            } catch (e) {
-                console.error(`wrong dateformat "${dataFormat || formatConf.dateTime}"`, e);
-            }
-            return getDateTimeString(value.toString(), undefined, formatConf);
+            return getDateTimeString(value.toString(), dataFormat, formatConf);
         case "datetime.date":
         case "date":
             if (value == "") {
                 return "";
             }
-            try {
-                return getDateTimeString(value.toString(), dataFormat, formatConf, undefined, false);
-            } catch (e) {
-                console.error(`wrong dateformat "${dataFormat || formatConf.date}"`, e);
-            }
-            return getDateTimeString(value.toString(), undefined, formatConf, undefined, false);
+            return getDateTimeString(value.toString(), dataFormat, formatConf, undefined, false);
         case "int":
         case "float":
         case "number":
diff --git a/taipy/gui/gui.py b/taipy/gui/gui.py
index 0aa8c52f55..1cd3169564 100644
--- a/taipy/gui/gui.py
+++ b/taipy/gui/gui.py
@@ -1449,15 +1449,16 @@ def __call_function_with_args(self, **kwargs):
         return False
 
     def _call_function_with_state(self, user_function: t.Callable, args: t.List[t.Any]) -> t.Any:
-        args.insert(0, self.__get_state())
+        cp_args = args.copy()
+        cp_args.insert(0, self.__get_state())
         argcount = user_function.__code__.co_argcount
         if argcount > 0 and inspect.ismethod(user_function):
             argcount -= 1
-        if argcount > len(args):
-            args += (argcount - len(args)) * [None]
+        if argcount > len(cp_args):
+            cp_args += (argcount - len(args)) * [None]
         else:
-            args = args[:argcount]
-        return user_function(*args)
+            cp_args = cp_args[:argcount]
+        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()
@@ -1490,18 +1491,13 @@ def _call_user_callback(
 
     def _call_broadcast_callback(
         self, user_callback: t.Callable, args: t.List[t.Any], module_context: t.Optional[str]
-    ) -> t.Any:
-        @contextlib.contextmanager
-        def _broadcast_callback() -> t.Iterator[None]:
-            try:
-                setattr(g, Gui.__BRDCST_CALLBACK_G_ID, True)
-                yield
-            finally:
-                setattr(g, Gui.__BRDCST_CALLBACK_G_ID, False)
-
-        with _broadcast_callback():
-            # Use global scopes for broadcast callbacks
-            return self._call_user_callback(_DataScopes._GLOBAL_ID, user_callback, args, module_context)
+    ) -> t.Dict[str, t.Any]:
+        # get scopes
+        res = {}
+        for id in [id for id in self.__bindings._get_all_scopes() if id != _DataScopes._GLOBAL_ID]:
+            ret = self._call_user_callback(id, user_callback, args, module_context)
+            res[id] = ret
+        return res
 
     def _is_in_brdcst_callback(self):
         try:
@@ -2528,8 +2524,9 @@ def run(
 
         # Base global ctx is TaipyHolder classes + script modules and callables
         glob_ctx: t.Dict[str, t.Any] = {t.__name__: t for t in _TaipyBase.__subclasses__()}
-        glob_ctx.update({k: v for k, v in locals_bind.items() if inspect.ismodule(v) or callable(v)})
         glob_ctx[Gui.__SELF_VAR] = self
+        glob_ctx["state"] = self.__state
+        glob_ctx.update({k: v for k, v in locals_bind.items() if inspect.ismodule(v) or callable(v)})
 
         # Call on_init on each library
         for name, libs in self.__extensions.items():
diff --git a/taipy/gui/gui_actions.py b/taipy/gui/gui_actions.py
index 23fe14fb69..66d3af0250 100644
--- a/taipy/gui/gui_actions.py
+++ b/taipy/gui/gui_actions.py
@@ -294,7 +294,7 @@ def broadcast_callback(
     callback: t.Callable,
     args: t.Optional[t.Union[t.Tuple, t.List]] = None,
     module_context: t.Optional[str] = None,
-) -> t.Any:
+) -> t.Dict[str, t.Any]:
     """Invoke a callback for every client.
 
     This callback gets invoked for every client connected to the application with the appropriate
diff --git a/tests/gui/gui_specific/test_gui.py b/tests/gui/gui_specific/test_gui.py
index 1dc9a57711..0be7a64cc2 100644
--- a/tests/gui/gui_specific/test_gui.py
+++ b/tests/gui/gui_specific/test_gui.py
@@ -44,14 +44,28 @@ def test__get_user_instance(gui: Gui):
 
 def test__call_broadcast_callback(gui: Gui):
     gui.run(run_server=False)
-    with gui.get_flask_app().app_context():
-        res = gui._call_broadcast_callback(lambda s, t: t, ["Hello World"], "mine")
-        assert res == "Hello World"
 
-    with gui.get_flask_app().app_context():
-        with pytest.warns(UserWarning):
-            res = gui._call_broadcast_callback(print, ["Hello World"], "mine")
-            assert res is None
+    res = gui._call_broadcast_callback(lambda s, t: t, ["Hello World"], "mine")
+    assert isinstance(res, dict)
+    assert not res
+
+    gui._bindings()._get_or_create_scope("mon scope")
+
+    res = gui._call_broadcast_callback(lambda s, t: t, ["Hello World"], "mine")
+    assert len(res) == 1
+    assert res.get("mon scope", None) == "Hello World"
+
+    with pytest.warns(UserWarning):
+        res = gui._call_broadcast_callback(print, ["Hello World"], "mine")
+        assert isinstance(res, dict)
+        assert res.get("mon scope", "Hello World") is None
+
+    gui._bindings()._get_or_create_scope("another scope")
+    res = gui._call_broadcast_callback(lambda s, t: t, ["Hello World"], "mine")
+    assert len(res) == 2
+    assert res.get("mon scope", None) == "Hello World"
+    assert res.get("another scope", None) == "Hello World"
+
 
 
 def test__refresh_expr(gui: Gui):


Code review comments:
diff_hunk: @@ -173,7 +173,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
                 }
                 return [colsOrder, baseColumns, styTt.styles, styTt.tooltips, hNan, filter];
             } catch (e) {
-                console.info("PTable.columns: " + ((e as Error).message || e));
+                console.info("PTable.columns: ", (e as Error).message || e);
path: frontend/taipy-gui/src/components/Taipy/PaginatedTable.tsx
text: @user1:
Because this may be read by programmers, is there any "better" information we could provide instead of the cryptic "PTable"?

diff_hunk: @@ -75,4 +72,4 @@ def start_or_stop(state):
 # Assigning a value to a state's 'button_text' property is propagated to all clients
 Gui.add_shared_variable("button_text")
 
-Gui(page).run()
+Gui(page).run(debug=True)
path: doc/gui/examples/broadcast.py
text: @user1:
```suggestion
Gui(page).run()
```

diff_hunk: @@ -35,17 +35,15 @@
 button_text = button_texts[0]
 
 
-# Invoked by the timer
-def update_counter(state: State, c):
-    # Update all clients
-    state.broadcast("counter", c)
-
+def update_state_var(state: State, var_name: str, val):
path: doc/gui/examples/broadcast.py
text: @user1:
```suggestion
# Invoked by the timer, for every client
def update_counter(state: State, var_name: str, val):
    # Update the variable value for this client
```

The function name indicates what it does, in the context of the invocation.

diff_hunk: @@ -35,17 +35,15 @@
 button_text = button_texts[0]
 
 
-# Invoked by the timer
-def update_counter(state: State, c):
-    # Update all clients
-    state.broadcast("counter", c)
-
+def update_state_var(state: State, var_name: str, val):
+    # Update each clients
+    state.assign(var_name, val)
 
 def count(event, gui):
     while not event.is_set():
         global counter
         counter = counter + 1
-        broadcast_callback(gui, update_counter, [counter])
+        broadcast_callback(gui, update_state_var, ["counter", counter])
path: doc/gui/examples/broadcast.py
text: @user1:
```suggestion
        broadcast_callback(gui, update_counter, ["counter", counter])
```

diff_hunk: @@ -15,9 +15,9 @@
 # -----------------------------------------------------------------------------------------
 # Demonstrate how to share variable values across multiple clients.
 # This application creates a thread that increments a value every few seconds.
-# The value is updated for every client using the state.broadcast() method.
+# The value is updated for every client using the broadcast_callback() method.
 # The text of the button that starts or stops the thread is updated on every client's browser
-# using a direct assignment of the state property because the variable is declared 'shared'.
+# using the broadcast_callback() method.
path: doc/gui/examples/broadcast.py
text: @user1:
```suggestion
# using a direct assignment of the state property because the variable is declared 'shared'.
```
Should be the truth.