Ver código fonte

Merge branch 'develop' into feature/#398-databricks-poc

Toan Quach 7 meses atrás
pai
commit
8425f78804
36 arquivos alterados com 678 adições e 221 exclusões
  1. 1 1
      .github/workflows/trigger-benchmark.yml
  2. 37 0
      doc/gui/examples/controls/selector_adapter.py
  3. 25 0
      doc/gui/examples/controls/selector_checkbox.py
  4. 25 0
      doc/gui/examples/controls/selector_dropdown.py
  5. 28 0
      doc/gui/examples/controls/selector_filter.py
  6. 25 0
      doc/gui/examples/controls/selector_icon.py
  7. 25 0
      doc/gui/examples/controls/selector_list.py
  8. 25 0
      doc/gui/examples/controls/selector_multiple.py
  9. 25 0
      doc/gui/examples/controls/selector_radio.py
  10. 53 0
      doc/gui/examples/controls/selector_styling.py
  11. 1 3
      taipy/config/__init__.py
  12. 39 40
      taipy/config/config.py
  13. 42 40
      taipy/config/config.pyi
  14. 1 1
      taipy/core/_core.py
  15. 0 1
      taipy/core/_init.py
  16. 0 3
      taipy/core/_orchestrator/_dispatcher/_development_job_dispatcher.py
  17. 0 3
      taipy/core/_orchestrator/_dispatcher/_standalone_job_dispatcher.py
  18. 4 6
      taipy/core/job/_job_converter.py
  19. 4 7
      taipy/core/job/_job_model.py
  20. 99 22
      taipy/core/job/job.py
  21. 2 2
      taipy/core/orchestrator.py
  22. 25 11
      taipy/core/submission/submission.py
  23. 1 7
      taipy/core/taipy.py
  24. 15 8
      taipy/gui/_renderers/builder.py
  25. 5 1
      taipy/gui/builder/_api_generator.py
  26. 5 3
      taipy/gui/builder/_element.py
  27. 21 21
      taipy/gui/gui.py
  28. 1 0
      taipy/gui/utils/__init__.py
  29. 21 9
      taipy/gui/utils/_evaluator.py
  30. 16 0
      taipy/gui/utils/_lambda.py
  31. 4 4
      taipy/gui/viselements.json
  32. 15 11
      tests/core/_orchestrator/test_orchestrator__submit.py
  33. 62 1
      tests/core/job/test_job.py
  34. 3 3
      tests/core/notification/test_events_published.py
  35. 22 12
      tests/core/submission/test_submission.py
  36. 1 1
      tests/core/test_core.py

+ 1 - 1
.github/workflows/trigger-benchmark.yml

@@ -8,7 +8,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Trigger taipy-benchmark computation
-        uses: peter-evans/repository-dispatch@v1
+        uses: peter-evans/repository-dispatch@v3
         with:
           token: ${{secrets.TAIPY_INTEGRATION_TESTING_ACCESS_TOKEN}}
           repository: avaiga/taipy-benchmark

+ 37 - 0
doc/gui/examples/controls/selector_adapter.py

@@ -0,0 +1,37 @@
+# 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.
+# -----------------------------------------------------------------------------------------
+# To execute this script, make sure that the taipy-gui package is installed in your
+# Python environment and run:
+#     python <script>
+# -----------------------------------------------------------------------------------------
+from taipy.gui import Gui
+
+
+class User:
+    def __init__(self, id, name, birth_year):
+        self.id, self.name, self.birth_year = (id, name, birth_year)
+
+users = [
+    User(231, "Johanna", 1987),
+    User(125, "John", 1979),
+    User(4,   "Peter", 1968),
+    User(31,  "Mary", 1974)
+    ]
+
+user_sel = users[2]
+
+page = """
+<|{user_sel}|selector|lov={users}|type=User|adapter={lambda u: (u.id, u.name)}|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Selector - Adapter")

+ 25 - 0
doc/gui/examples/controls/selector_checkbox.py

@@ -0,0 +1,25 @@
+# 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.
+# -----------------------------------------------------------------------------------------
+# To execute this script, make sure that the taipy-gui package is installed in your
+# Python environment and run:
+#     python <script>
+# -----------------------------------------------------------------------------------------
+from taipy.gui import Gui
+
+value="Item 2"
+
+page = """
+<|{value}|selector|lov=Item 1;Item 2;Item 3|mode=check|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Selector - Checkbox")

+ 25 - 0
doc/gui/examples/controls/selector_dropdown.py

@@ -0,0 +1,25 @@
+# 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.
+# -----------------------------------------------------------------------------------------
+# To execute this script, make sure that the taipy-gui package is installed in your
+# Python environment and run:
+#     python <script>
+# -----------------------------------------------------------------------------------------
+from taipy.gui import Gui
+
+value="Item 2"
+
+page = """
+<|{value}|selector|lov=Item 1;Item 2;Item 3|dropdown|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Selector - Dropdown")

+ 28 - 0
doc/gui/examples/controls/selector_filter.py

@@ -0,0 +1,28 @@
+# 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.
+# -----------------------------------------------------------------------------------------
+# To execute this script, make sure that the taipy-gui package is installed in your
+# Python environment and run:
+#     python <script>
+# -----------------------------------------------------------------------------------------
+import builtins
+
+from taipy.gui import Gui
+
+_python_builtins = dir(builtins)
+value = _python_builtins[0]
+
+page = """
+<|{value}|selector|lov={_python_builtins}|filter|multiple|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Selector - Filter")

+ 25 - 0
doc/gui/examples/controls/selector_icon.py

@@ -0,0 +1,25 @@
+# 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.
+# -----------------------------------------------------------------------------------------
+# To execute this script, make sure that the taipy-gui package is installed in your
+# Python environment and run:
+#     python <script>
+# -----------------------------------------------------------------------------------------
+from taipy.gui import Gui
+
+sel = "id2"
+
+page = """
+<|{sel}|selector|lov={[("id1", "Label 1"), ("id2", Icon("/images/icon.png", "Label 2"),("id3", "Label 3")]}|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Selector - Icon")

+ 25 - 0
doc/gui/examples/controls/selector_list.py

@@ -0,0 +1,25 @@
+# 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.
+# -----------------------------------------------------------------------------------------
+# To execute this script, make sure that the taipy-gui package is installed in your
+# Python environment and run:
+#     python <script>
+# -----------------------------------------------------------------------------------------
+from taipy.gui import Gui
+
+value="Item 2"
+
+page = """
+<|{value}|selector|lov=Item 1;Item 2;Item 3|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Selector - List")

+ 25 - 0
doc/gui/examples/controls/selector_multiple.py

@@ -0,0 +1,25 @@
+# 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.
+# -----------------------------------------------------------------------------------------
+# To execute this script, make sure that the taipy-gui package is installed in your
+# Python environment and run:
+#     python <script>
+# -----------------------------------------------------------------------------------------
+from taipy.gui import Gui
+
+value="Item 2"
+
+page = """
+<|{value}|selector|lov=Item 1;Item 2;Item 3|multiple|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Selector - Multiple")

+ 25 - 0
doc/gui/examples/controls/selector_radio.py

@@ -0,0 +1,25 @@
+# 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.
+# -----------------------------------------------------------------------------------------
+# To execute this script, make sure that the taipy-gui package is installed in your
+# Python environment and run:
+#     python <script>
+# -----------------------------------------------------------------------------------------
+from taipy.gui import Gui
+
+value="Item 2"
+
+page = """
+<|{value}|selector|lov=Item 1;Item 2;Item 3|mode=radio|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Selector - Radio")

+ 53 - 0
doc/gui/examples/controls/selector_styling.py

@@ -0,0 +1,53 @@
+# 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.
+# -----------------------------------------------------------------------------------------
+# To execute this script, make sure that the taipy-gui package is installed in your
+# Python environment and run:
+#     python <script>
+# -----------------------------------------------------------------------------------------
+import builtins
+
+from taipy.gui import Gui, Markdown
+
+_python_builtins = dir(builtins)
+value = _python_builtins[0]
+
+page = Markdown(
+    """
+<|{value}|selector|lov={_python_builtins}|>
+""",
+    style={
+        ".taipy-selector": {
+            "margin": "0px !important",  # global margin
+            ".MuiInputBase-root": {  # input field
+                "background-color": "#572c5f38",
+                "color": "#221025",
+                "border-radius": "0px",
+                "height": "50px",
+            },
+            ".MuiList-root": {  # list
+                "height": "70vh",  # limit height
+                "overflow-y": "auto",  # show vertical scroll if necessary
+                ".MuiListItemButton-root:nth-child(even)": {  # change colors
+                    "background-color": "lightgrey",
+                    "color": "darkgrey",
+                },
+                ".MuiListItemButton-root:nth-child(odd)": {
+                    "background-color": "darkgrey",
+                    "color": "lightgrey",
+                },
+            },
+        }
+    },
+)
+
+if __name__ == "__main__":
+    Gui(page).run(title="Selector - Style every other row")

+ 1 - 3
taipy/config/__init__.py

@@ -9,9 +9,7 @@
 # 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.
 
-"""---
-title: Taipy Config package
----
+"""# Taipy `config` Package
 
 The `taipy.config` package provides features to configure a Taipy application.
 

+ 39 - 40
taipy/config/config.py

@@ -37,15 +37,7 @@ class Config:
         The Config class provides various methods to configure the application. Each method adds
         a specific section to the configuration and returns it.
 
-        Here is a non-exhaustive list of configuration method examples:
-
-        - `configure_data_node()`: Configure a data node adding and returning a `DataNodeConfig^`.
-        - `configure_task()`: Configure a task adding and returning a `TaskConfig^`.
-        - `configure_scenario()`: Configure a scenario adding and returning a `ScenarioConfig^`.
-        - `load()`: Load a TOML configuration file. (This overrides the Python configuration)
-        - more...
-
-        !!! example "Most frequently used configuration methods"
+        ??? example "Most frequently used configuration methods"
 
             ```python
             from taipy import Config
@@ -61,7 +53,7 @@ class Config:
             Config.load("config.toml") # Load a configuration file
             ```
 
-        !!! example "Advanced use case"
+        ??? example "Advanced use case"
 
             The configuration can be done in three ways: Python code, configuration files, or
             environment variables. All configuration manners are ultimately merged (overriding the previous way
@@ -74,7 +66,7 @@ class Config:
         Once the configuration is done, you can retrieve the configuration values using the exposed
         attributes.
 
-        !!! Example "Retrieve configuration values"
+        ??? Example "Retrieve configuration values"
 
             ```python
             from taipy import Config
@@ -82,35 +74,41 @@ class Config:
             global_cfg = Config.global_config  # Retrieve the global application configuration
             data_node_cfgs = Config.data_nodes  # Retrieve all data node configurations
             scenario_cfgs = Config.scenarios  # Retrieve all scenario configurations
-        ```
+            ```
 
     3. A few methods to manage the configuration:
 
-        The Config class also provides a few methods to manage the configuration. For example,
-        you can:
-
-        - *Check the configuration for issues*: Use the `Config.check()^` method to check the
-            configuration. It returns an `IssueCollector^` containing all the `Issue^`s
-            found representing the issues with their severity.
-        - *Block the configuration update*: Use the `Config.block_update()^` method to forbid
-            any update on the configuration. This can be useful when you want to ensure that
-            the configuration is not modified at run time. Note that running the `Orchestrator^`
-            service` automatically blocks the configuration update.
-        - *Unblock the configuration update*: Use the `Config.unblock_update()^` method to allow
-            again the update on the configuration.
-        - *Backup the configuration*: Use the `Config.backup()^` method to back up as a TOML
-            file the applied configuration. The applied configuration backed up is the result
-            of the compilation of the three possible configuration methods that overrides each
-            others.
-        - *Restore the configuration*: Use the `Config.restore()^` method to restore a TOML
-            configuration file and replace the current applied configuration.
-        - *Export the configuration*: Use the `Config.export()^` method to export as a TOML file
-            the Python code configuration.
-        - *Load the configuration*: Use the `Config.load()^` method to load a TOML configuration
-            file and replace the current Python configuration.
-        - *Override the configuration*: Use the `Config.override()^` method to load a TOML
-            configuration file and override the current Python configuration.
-
+        The Config class also provides a few methods to manage the configuration.
+
+        ??? example "Manage the configuration"
+
+            - *Check the configuration for issues*: Use the `Config.check()^` method to check the
+                configuration. It returns an `IssueCollector^` containing all the
+                 `Issue^`s found. The issues are logged to the console for debugging.
+            - *Block the configuration update*: Use the `Config.block_update()^` method to forbid
+                any update on the configuration. This can be useful when you want to ensure that
+                the configuration is not modified at run time. Note that running the `Orchestrator^`
+                service` automatically blocks the configuration update.
+            - *Unblock the configuration update*: Use the `Config.unblock_update()^` method to allow
+                again the update on the configuration.
+            - *Backup the configuration*: Use the `Config.backup()^` method to back up as a TOML
+                file the applied configuration. The applied configuration backed up is the result
+                of the compilation of the three possible configuration methods that overrides each
+                others.
+            - *Restore the configuration*: Use the `Config.restore()^` method to restore a TOML
+                configuration file and replace the current applied configuration.
+            - *Export the configuration*: Use the `Config.export()^` method to export as a TOML file
+                the Python code configuration.
+            - *Load the configuration*: Use the `Config.load()^` method to load a TOML configuration
+                file and replace the current Python configuration.
+            - *Override the configuration*: Use the `Config.override()^` method to load a TOML
+                configuration file and override the current Python configuration.
+
+    Attributes:
+        global_config (GlobalAppConfig): configuration values related to the global
+            application as a `GlobalAppConfig^`.
+        unique_sections (Dict[str, UniqueSection]): A dictionary containing all unique sections.
+        sections (Dict[str, Dict[str, Section]]): A dictionary containing all non-unique sections.
     """
 
     _ENVIRONMENT_VARIABLE_NAME_WITH_CONFIG_PATH = "TAIPY_CONFIG_PATH"
@@ -127,17 +125,14 @@ class Config:
 
     @_Classproperty
     def unique_sections(cls) -> Dict[str, UniqueSection]:
-        """Return all unique sections."""
         return cls._applied_config._unique_sections
 
     @_Classproperty
     def sections(cls) -> Dict[str, Dict[str, Section]]:
-        """Return all non unique sections."""
         return cls._applied_config._sections
 
     @_Classproperty
     def global_config(cls) -> GlobalAppConfig:
-        """Return configuration values related to the global application as a `GlobalAppConfig^`."""
         return cls._applied_config._global_config
 
     @classmethod
@@ -249,6 +244,10 @@ class Config:
 
         Returns:
             Collector containing the info, warning and error issues.
+
+        Raises:
+            SystemExit: If configuration errors are found, the application
+                exits with an error message.
         """
         cls._collector = _Checker._check(cls._applied_config)
         cls.__log_message(cls)

+ 42 - 40
taipy/config/config.pyi

@@ -36,15 +36,7 @@ class Config:
         The Config class provides various methods to configure the application. Each method adds
         a specific section to the configuration and returns it.
 
-        Here is a non-exhaustive list of configuration method examples:
-
-        - `configure_data_node()`: Configure a data node adding and returning a `DataNodeConfig^`.
-        - `configure_task()`: Configure a task adding and returning a `TaskConfig^`.
-        - `configure_scenario()`: Configure a scenario adding and returning a `ScenarioConfig^`.
-        - `load()`: Load a TOML configuration file. (This overrides the Python configuration)
-        - more...
-
-        !!! example "Most frequently used configuration methods"
+        ??? example "Most frequently used configuration methods"
 
             ```python
             from taipy import Config
@@ -60,7 +52,7 @@ class Config:
             Config.load("config.toml") # Load a configuration file
             ```
 
-        !!! example "Advanced use case"
+        ??? example "Advanced use case"
 
             The configuration can be done in three ways: Python code, configuration files, or
             environment variables. All configuration manners are ultimately merged (overriding the previous way
@@ -73,7 +65,7 @@ class Config:
         Once the configuration is done, you can retrieve the configuration values using the exposed
         attributes.
 
-        !!! Example "Retrieve configuration values"
+        ??? Example "Retrieve configuration values"
 
             ```python
             from taipy import Config
@@ -81,47 +73,53 @@ class Config:
             global_cfg = Config.global_config  # Retrieve the global application configuration
             data_node_cfgs = Config.data_nodes  # Retrieve all data node configurations
             scenario_cfgs = Config.scenarios  # Retrieve all scenario configurations
-        ```
+            ```
 
     3. A few methods to manage the configuration:
 
-        The Config class also provides a few methods to manage the configuration. For example,
-        you can:
-
-        - *Check the configuration for issues*: Use the `Config.check()^` method to check the
-            configuration. It returns an `IssueCollector^` containing all the `Issue^`s
-            found representing the issues with their severity.
-        - *Block the configuration update*: Use the `Config.block_update()^` method to forbid
-            any update on the configuration. This can be useful when you want to ensure that
-            the configuration is not modified at run time. Note that running the `Orchestrator^`
-            service` automatically blocks the configuration update.
-        - *Unblock the configuration update*: Use the `Config.unblock_update()^` method to allow
-            again the update on the configuration.
-        - *Backup the configuration*: Use the `Config.backup()^` method to back up as a TOML
-            file the applied configuration. The applied configuration backed up is the result
-            of the compilation of the three possible configuration methods that overrides each
-            others.
-        - *Restore the configuration*: Use the `Config.restore()^` method to restore a TOML
-            configuration file and replace the current applied configuration.
-        - *Export the configuration*: Use the `Config.export()^` method to export as a TOML file
-            the Python code configuration.
-        - *Load the configuration*: Use the `Config.load()^` method to load a TOML configuration
-            file and replace the current Python configuration.
-        - *Override the configuration*: Use the `Config.override()^` method to load a TOML
-            configuration file and override the current Python configuration.
-
+        The Config class also provides a few methods to manage the configuration.
+
+        ??? example "Manage the configuration"
+
+            - *Check the configuration for issues*: Use the `Config.check()^` method to check the
+                configuration. It returns an `IssueCollector^` containing all the
+                 `Issue^`s found. The issues are logged to the console for debugging.
+            - *Block the configuration update*: Use the `Config.block_update()^` method to forbid
+                any update on the configuration. This can be useful when you want to ensure that
+                the configuration is not modified at run time. Note that running the `Orchestrator^`
+                service` automatically blocks the configuration update.
+            - *Unblock the configuration update*: Use the `Config.unblock_update()^` method to allow
+                again the update on the configuration.
+            - *Backup the configuration*: Use the `Config.backup()^` method to back up as a TOML
+                file the applied configuration. The applied configuration backed up is the result
+                of the compilation of the three possible configuration methods that overrides each
+                others.
+            - *Restore the configuration*: Use the `Config.restore()^` method to restore a TOML
+                configuration file and replace the current applied configuration.
+            - *Export the configuration*: Use the `Config.export()^` method to export as a TOML file
+                the Python code configuration.
+            - *Load the configuration*: Use the `Config.load()^` method to load a TOML configuration
+                file and replace the current Python configuration.
+            - *Override the configuration*: Use the `Config.override()^` method to load a TOML
+                configuration file and override the current Python configuration.
+
+    Attributes:
+        global_config (GlobalAppConfig): configuration values related to the global
+            application as a `GlobalAppConfig^`.
+        unique_sections (Dict[str, UniqueSection]): A dictionary containing all unique sections.
+        sections (Dict[str, Dict[str, Section]]): A dictionary containing all non-unique sections.
     """
     @_Classproperty
     def unique_sections(cls) -> Dict[str, UniqueSection]:
-        """Return all unique sections."""
+        """"""
 
     @_Classproperty
     def sections(cls) -> Dict[str, Dict[str, Section]]:
-        """Return all non unique sections."""
+        """"""
 
     @_Classproperty
     def global_config(cls) -> GlobalAppConfig:
-        """Return configuration values related to the global application as a `GlobalAppConfig^`."""
+        """"""
 
     @classmethod
     @_ConfigBlocker._check()
@@ -209,6 +207,10 @@ class Config:
 
         Returns:
             Collector containing the info, warning and error issues.
+
+        Raises:
+            SystemExit: If configuration errors are found, the application
+                exits with an error message.
         """
 
     @classmethod

+ 1 - 1
taipy/core/_core.py

@@ -16,7 +16,7 @@ from .orchestrator import Orchestrator
 
 
 class Core:
-    """Deprecated. Use the `Orchestrator^` service class instead."""
+    """NOT DOCUMENTED"""
 
     __logger = _TaipyLogger._get_logger()
 

+ 0 - 1
taipy/core/_init.py

@@ -30,7 +30,6 @@ from .taipy import (
     can_create,
     cancel_job,
     clean_all_entities,
-    clean_all_entities_by_version,
     compare_scenarios,
     create_global_data_node,
     create_scenario,

+ 0 - 3
taipy/core/_orchestrator/_dispatcher/_development_job_dispatcher.py

@@ -9,7 +9,6 @@
 # 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 datetime
 from typing import Optional
 
 from ...job.job import Job
@@ -45,7 +44,5 @@ class _DevelopmentJobDispatcher(_JobDispatcher):
         Parameters:
             job (Job^): The job to submit on an executor with an available worker.
         """
-        job.execution_started_at = datetime.datetime.now()
         rs = _TaskFunctionWrapper(job.id, job.task).execute()
         self._update_job_status(job, rs)
-        job.execution_ended_at = datetime.datetime.now()

+ 0 - 3
taipy/core/_orchestrator/_dispatcher/_standalone_job_dispatcher.py

@@ -9,7 +9,6 @@
 # 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 datetime
 import multiprocessing as mp
 from concurrent.futures import Executor, ProcessPoolExecutor
 from functools import partial
@@ -61,7 +60,6 @@ class _StandaloneJobDispatcher(_JobDispatcher):
             self._logger.debug(f"Setting nb_available_workers to {self._nb_available_workers} in the dispatch method.")
         config_as_string = _TomlSerializer()._serialize(Config._applied_config)  # type: ignore[attr-defined]
 
-        job.execution_started_at = datetime.datetime.now()
         future = self._executor.submit(_TaskFunctionWrapper(job.id, job.task), config_as_string=config_as_string)
         future.add_done_callback(partial(self._update_job_status_from_future, job))
 
@@ -70,4 +68,3 @@ class _StandaloneJobDispatcher(_JobDispatcher):
             self._nb_available_workers += 1
             self._logger.debug(f"Setting nb_available_workers to {self._nb_available_workers} in the callback method.")
         self._update_job_status(job, ft.result())
-        job.execution_ended_at = datetime.datetime.now()

+ 4 - 6
taipy/core/job/_job_converter.py

@@ -27,12 +27,11 @@ class _JobConverter(_AbstractConverter):
             job.id,
             job._task.id,
             job._status,
+            {status: timestamp.isoformat() for status, timestamp in job._status_change_records.items()},
             job._force,
             job.submit_id,
             job.submit_entity_id,
             job._creation_date.isoformat(),
-            job._execution_started_at.isoformat() if job._execution_started_at else None,
-            job._execution_ended_at.isoformat() if job._execution_ended_at else None,
             cls.__serialize_subscribers(job._subscribers),
             job._stacktrace,
             version=job._version,
@@ -52,12 +51,11 @@ class _JobConverter(_AbstractConverter):
         )
 
         job._status = model.status  # type: ignore
+        job._status_change_records = {
+            status: datetime.fromisoformat(timestamp) for status, timestamp in model.status_change_records.items()
+        }
         job._force = model.force  # type: ignore
         job._creation_date = datetime.fromisoformat(model.creation_date)  # type: ignore
-        job._execution_started_at = (
-            datetime.fromisoformat(model.execution_started_at) if model.execution_started_at else None
-        )
-        job._execution_ended_at = datetime.fromisoformat(model.execution_ended_at) if model.execution_ended_at else None
         for it in model.subscribers:
             try:
                 fct_module, fct_name = it.get("fct_module"), it.get("fct_name")

+ 4 - 7
taipy/core/job/_job_model.py

@@ -10,7 +10,7 @@
 # specific language governing permissions and limitations under the License.
 
 from dataclasses import dataclass
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, List
 
 from .._repository._base_taipy_model import _BaseModel
 from .job_id import JobId
@@ -22,12 +22,11 @@ class _JobModel(_BaseModel):
     id: JobId
     task_id: str
     status: Status
+    status_change_records: Dict[str, str]
     force: bool
     submit_id: str
     submit_entity_id: str
     creation_date: str
-    execution_started_at: Optional[str]
-    execution_ended_at: Optional[str]
     subscribers: List[Dict]
     stacktrace: List[str]
     version: str
@@ -38,12 +37,11 @@ class _JobModel(_BaseModel):
             id=data["id"],
             task_id=data["task_id"],
             status=Status._from_repr(data["status"]),
+            status_change_records=_BaseModel._deserialize_attribute(data["status_change_records"]),
             force=data["force"],
             submit_id=data["submit_id"],
             submit_entity_id=data["submit_entity_id"],
             creation_date=data["creation_date"],
-            execution_started_at=data["execution_started_at"],
-            execution_ended_at=data["execution_ended_at"],
             subscribers=_BaseModel._deserialize_attribute(data["subscribers"]),
             stacktrace=_BaseModel._deserialize_attribute(data["stacktrace"]),
             version=data["version"],
@@ -54,12 +52,11 @@ class _JobModel(_BaseModel):
             self.id,
             self.task_id,
             repr(self.status),
+            _BaseModel._serialize_attribute(self.status_change_records),
             self.force,
             self.submit_id,
             self.submit_entity_id,
             self.creation_date,
-            self.execution_started_at,
-            self.execution_ended_at,
             _BaseModel._serialize_attribute(self.subscribers),
             _BaseModel._serialize_attribute(self.stacktrace),
             self.version,

+ 99 - 22
taipy/core/job/job.py

@@ -12,7 +12,7 @@
 __all__ = ["Job"]
 
 from datetime import datetime
-from typing import TYPE_CHECKING, Any, Callable, List, Optional
+from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
 
 from taipy.logger._taipy_logger import _TaipyLogger
 
@@ -49,8 +49,8 @@ class Job(_Entity, _Labeled):
 
     Every time a task is submitted for execution, a new *Job* is created. A job represents a
     single execution of a task. It holds all the information related to the task execution,
-    including the **creation date**, the execution `Status^`, and the **stacktrace** of any
-    exception that may be raised by the user function.
+    including the **creation date**, the execution `Status^`, the timestamp of status changes,
+    and the **stacktrace** of any exception that may be raised by the user function.
 
     In addition, a job notifies scenario or sequence subscribers on its status change.
 
@@ -78,8 +78,7 @@ class Job(_Entity, _Labeled):
         self._creation_date = datetime.now()
         self._submit_id: str = submit_id
         self._submit_entity_id: str = submit_entity_id
-        self._execution_started_at: Optional[datetime] = None
-        self._execution_ended_at: Optional[datetime] = None
+        self._status_change_records: Dict[str, datetime] = {"SUBMITTED": self._creation_date}
         self._subscribers: List[Callable] = []
         self._stacktrace: List[str] = []
         self.__logger = _TaipyLogger._get_logger()
@@ -134,6 +133,7 @@ class Job(_Entity, _Labeled):
     @status.setter  # type: ignore
     @_self_setter(_MANAGER_NAME)
     def status(self, val):
+        self._status_change_records[val.name] = datetime.now()
         self._status = val
 
     @property  # type: ignore
@@ -148,36 +148,113 @@ class Job(_Entity, _Labeled):
 
     @property
     @_self_reload(_MANAGER_NAME)
-    def execution_started_at(self) -> Optional[datetime]:
-        return self._execution_started_at
+    def submitted_at(self) -> datetime:
+        """Get the date time when the job was submitted.
 
-    @execution_started_at.setter
-    @_self_setter(_MANAGER_NAME)
-    def execution_started_at(self, val):
-        self._execution_started_at = val
+        Returns:
+            datetime: The date time when the job was submitted.
+        """
+        return self._status_change_records["SUBMITTED"]
 
     @property
     @_self_reload(_MANAGER_NAME)
-    def execution_ended_at(self) -> Optional[datetime]:
-        return self._execution_ended_at
+    def run_at(self) -> Optional[datetime]:
+        """Get the date time when the job was run.
 
-    @execution_ended_at.setter
-    @_self_setter(_MANAGER_NAME)
-    def execution_ended_at(self, val):
-        self._execution_ended_at = val
+        Returns:
+            Optional[datetime]: The date time when the job was run.
+                If the job is not run, None is returned.
+        """
+        return self._status_change_records.get(Status.RUNNING.name, None)
+
+    @property
+    @_self_reload(_MANAGER_NAME)
+    def finished_at(self) -> Optional[datetime]:
+        """Get the date time when the job was finished.
+
+        Returns:
+            Optional[datetime]: The date time when the job was finished.
+                If the job is not finished, None is returned.
+        """
+        if self.is_finished():
+            if self.is_completed():
+                return self._status_change_records[Status.COMPLETED.name]
+            elif self.is_failed():
+                return self._status_change_records[Status.FAILED.name]
+            elif self.is_canceled():
+                return self._status_change_records[Status.CANCELED.name]
+            elif self.is_skipped():
+                return self._status_change_records[Status.SKIPPED.name]
+            elif self.is_abandoned():
+                return self._status_change_records[Status.ABANDONED.name]
+
+        return None
 
     @property
     @_self_reload(_MANAGER_NAME)
     def execution_duration(self) -> Optional[float]:
         """Get the duration of the job execution in seconds.
+        The execution time is the duration from the job running to the job completion.
 
         Returns:
-            Optional[float]: The duration of the job execution in seconds. If the job is not
-            completed, None is returned.
+            Optional[float]: The duration of the job execution in seconds.
+                - If the job was not run, None is returned.
+                - If the job is not finished, the execution time is the duration
+                  from the running time to the current time.
         """
-        if self._execution_started_at and self._execution_ended_at:
-            return (self._execution_ended_at - self._execution_started_at).total_seconds()
-        return None
+        if Status.RUNNING.name not in self._status_change_records:
+            return None
+
+        if self.is_finished():
+            return (self.finished_at - self._status_change_records[Status.RUNNING.name]).total_seconds()
+
+        return (datetime.now() - self._status_change_records[Status.RUNNING.name]).total_seconds()
+
+    @property
+    @_self_reload(_MANAGER_NAME)
+    def pending_duration(self) -> Optional[float]:
+        """Get the duration of the job in the pending state in seconds.
+
+        Returns:
+            Optional[float]: The duration of the job in the pending state in seconds.
+                - If the job is not running, None is returned.
+                - If the job is not pending, the pending time is the duration
+                  from the submission to the current time.
+        """
+        if Status.PENDING.name not in self._status_change_records:
+            return None
+
+        if self.is_finished() or self.is_running():
+            return (
+                self._status_change_records[Status.RUNNING.name] - self._status_change_records[Status.PENDING.name]
+            ).total_seconds()
+
+        return (datetime.now() - self._status_change_records[Status.PENDING.name]).total_seconds()
+
+    @property
+    @_self_reload(_MANAGER_NAME)
+    def blocked_duration(self) -> Optional[float]:
+        """Get the duration of the job in the blocked state in seconds.
+
+        Returns:
+            Optional[float]: The duration of the job in the blocked state in seconds.
+                - If the job is not running, None is returned.
+                - If the job is not blocked, the blocked time is the duration
+                  from the submission to the current time.
+        """
+        if Status.BLOCKED.name not in self._status_change_records:
+            return None
+
+        if Status.PENDING.name in self._status_change_records:
+            return (
+                self._status_change_records[Status.PENDING.name] - self._status_change_records[Status.BLOCKED.name]
+            ).total_seconds()
+        if self.is_finished():
+            return (self.finished_at - self._status_change_records[Status.BLOCKED.name]).total_seconds()
+
+        # If pending time is not recorded, and the job is not finished, the only possible status left is blocked
+        # which means the current status is blocked.
+        return (datetime.now() - self._status_change_records[Status.BLOCKED.name]).total_seconds()
 
     @property  # type: ignore
     @_self_reload(_MANAGER_NAME)

+ 2 - 2
taipy/core/orchestrator.py

@@ -42,13 +42,13 @@ class Orchestrator:
 
     def __init__(self) -> None:
         """
-        Initialize a Orchestrator service.
+        Initialize an Orchestrator service.
         """
         pass
 
     def run(self, force_restart=False):
         """
-        Start a Orchestrator service.
+        Start an Orchestrator service.
 
         This function checks and locks the configuration, manages application's version,
         and starts a job dispatcher.

+ 25 - 11
taipy/core/submission/submission.py

@@ -141,29 +141,43 @@ class Submission(_Entity, _Labeled):
 
     @property
     @_self_reload(_MANAGER_NAME)
-    def execution_started_at(self) -> Optional[datetime]:
-        if all(job.execution_started_at is not None for job in self.jobs):
-            return min(job.execution_started_at for job in self.jobs)
+    def submitted_at(self) -> Optional[datetime]:
+        jobs_submitted_at = [job.submitted_at for job in self.jobs if job.submitted_at]
+        if jobs_submitted_at:
+            return min(jobs_submitted_at)
         return None
 
     @property
     @_self_reload(_MANAGER_NAME)
-    def execution_ended_at(self) -> Optional[datetime]:
-        if all(job.execution_ended_at is not None for job in self.jobs):
-            return max(job.execution_ended_at for job in self.jobs)
+    def run_at(self) -> Optional[datetime]:
+        jobs_run_at = [job.run_at for job in self.jobs if job.run_at]
+        if jobs_run_at:
+            return min(jobs_run_at)
+        return None
+
+    @property
+    @_self_reload(_MANAGER_NAME)
+    def finished_at(self) -> Optional[datetime]:
+        if all(job.finished_at for job in self.jobs):
+            return max([job.finished_at for job in self.jobs if job.finished_at])
         return None
 
     @property
     @_self_reload(_MANAGER_NAME)
     def execution_duration(self) -> Optional[float]:
-        """Get the duration of the submission in seconds.
+        """Get the duration of the submission execution in seconds.
+        The execution time is the duration from the first job running to the last job completion.
 
         Returns:
-            Optional[float]: The duration of the submission in seconds. If the job is not
-            completed, None is returned.
+            Optional[float]: The duration of the job execution in seconds.
+                - If no job was run, None is returned.
+                - If one of the jobs is not finished, the execution time is the duration
+                  from the running time of the first job to the current time.
         """
-        if self.execution_started_at and self.execution_ended_at:
-            return (self.execution_ended_at - self.execution_started_at).total_seconds()
+        if self.finished_at and self.run_at:
+            return (self.finished_at - self.run_at).total_seconds()
+        elif self.run_at and self.finished_at is None:
+            return (datetime.now() - self.run_at).total_seconds()
         return None
 
     def get_label(self) -> str:

+ 1 - 7
taipy/core/taipy.py

@@ -26,7 +26,7 @@ from .common._check_instance import (
     _is_submission,
     _is_task,
 )
-from .common._warnings import _warn_deprecated, _warn_no_orchestrator_service
+from .common._warnings import _warn_no_orchestrator_service
 from .config.data_node_config import DataNodeConfig
 from .config.scenario_config import ScenarioConfig
 from .cycle._cycle_manager_factory import _CycleManagerFactory
@@ -952,12 +952,6 @@ def create_global_data_node(config: DataNodeConfig) -> DataNode:
     return _DataManagerFactory._build_manager()._create_and_set(config, None, None)
 
 
-def clean_all_entities_by_version(version_number=None) -> bool:
-    """Deprecated. Use `clean_all_entities` function instead."""
-    _warn_deprecated("'clean_all_entities_by_version'", suggest="the 'clean_all_entities' function")
-    return clean_all_entities(version_number)
-
-
 def clean_all_entities(version_number: str) -> bool:
     """Deletes all entities associated with the specified version.
     This function cleans all entities, including jobs, submissions, scenarios, cycles, sequences, tasks, and data nodes.

+ 15 - 8
taipy/gui/_renderers/builder.py

@@ -28,6 +28,7 @@ from ..utils import (
     _get_client_var_name,
     _get_data_type,
     _get_expr_var_name,
+    _get_lambda_id,
     _getscopeattr,
     _getscopeattr_drill,
     _is_boolean,
@@ -153,18 +154,24 @@ class _Builder:
         hashes = {}
         # Bind potential function and expressions in self.attributes
         for k, v in attributes.items():
-            val = v
             hash_name = hash_names.get(k)
             if hash_name is None:
-                if callable(v):
-                    if v.__name__ == "<lambda>":
-                        hash_name = f"__lambda_{id(v)}"
-                        gui._bind_var_val(hash_name, v)
-                    else:
-                        hash_name = _get_expr_var_name(v.__name__)
-                elif isinstance(v, str):
+                if isinstance(v, str):
+                    looks_like_a_lambda = v.startswith("{lambda ") and v.endswith("}")
                     # need to unescape the double quotes that were escaped during preprocessing
                     (val, hash_name) = _Builder.__parse_attribute_value(gui, v.replace('\\"', '"'))
+                else:
+                    looks_like_a_lambda = False
+                    val = v
+                if callable(val):
+                    # 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 looks_like_a_lambda or not hash_name:
+                            hash_name = _get_lambda_id(val)
+                            gui._bind_var_val(hash_name, val)  # type: ignore[arg-type]
+                    else:
+                        hash_name = _get_expr_var_name(val.__name__)
 
                 if val is not None or hash_name:
                     attributes[k] = val

+ 5 - 1
taipy/gui/builder/_api_generator.py

@@ -139,5 +139,9 @@ class _ElementApiGenerator(object, metaclass=_Singleton):
         return type(
             classname,
             (ElementBaseClass,),
-            {"_ELEMENT_NAME": element_name, "_DEFAULT_PROPERTY": default_property, "_TYPES": properties},
+            {
+                "_ELEMENT_NAME": element_name,
+                "_DEFAULT_PROPERTY": default_property,
+                "_TYPES": {f"{parts[0]}__" if len(parts := k.split("[")) > 1 else k: v for k, v in properties.items()},
+            },
         )

+ 5 - 3
taipy/gui/builder/_element.py

@@ -89,7 +89,9 @@ class _Element(ABC):
         return key
 
     def _is_callable(self, name: str):
-        return "callable" in self._TYPES.get(name, "").lower()
+        return (
+            "callable" in self._TYPES.get(f"{parts[0]}__" if len(parts := name.split("__")) > 1 else name, "").lower()
+        )
 
     def _parse_property(self, key: str, value: t.Any) -> t.Any:
         if isinstance(value, (str, dict, Iterable)):
@@ -121,10 +123,10 @@ class _Element(ABC):
                 return None
             args = [arg.arg for arg in lambda_fn.args.args]
             targets = [
-                compr.target.id  # type: ignore[attr-defined]
+                comprehension.target.id  # type: ignore[attr-defined]
                 for node in ast.walk(lambda_fn.body)
                 if isinstance(node, ast.ListComp)
-                for compr in node.generators
+                for comprehension in node.generators
             ]
             tree = _TransformVarToValue(self.__calling_frame, args + targets + _python_builtins).visit(lambda_fn)
             ast.fix_missing_locations(tree)

+ 21 - 21
taipy/gui/gui.py

@@ -813,17 +813,17 @@ class Gui:
             on_change_fn = self._get_user_function("on_change")
         if callable(on_change_fn):
             try:
-                argcount = on_change_fn.__code__.co_argcount
-                if argcount > 0 and inspect.ismethod(on_change_fn):
-                    argcount -= 1
-                args: t.List[t.Any] = [None for _ in range(argcount)]
-                if argcount > 0:
+                arg_count = on_change_fn.__code__.co_argcount
+                if arg_count > 0 and inspect.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 argcount > 1:
+                if arg_count > 1:
                     args[1] = var_name
-                if argcount > 2:
+                if arg_count > 2:
                     args[2] = value
-                if argcount > 3:
+                if arg_count > 3:
                     args[3] = current_context
                 on_change_fn(*args)
             except Exception as e:  # pragma: no cover
@@ -849,22 +849,22 @@ class Gui:
     def _get_user_content_url(
         self, path: t.Optional[str] = None, query_args: t.Optional[t.Dict[str, str]] = None
     ) -> t.Optional[str]:
-        qargs = query_args or {}
-        qargs.update({Gui.__ARG_CLIENT_ID: self._get_client_id()})
-        return f"/{Gui.__USER_CONTENT_URL}/{path or 'TaIpY'}?{urlencode(qargs)}"
+        q_args = query_args or {}
+        q_args.update({Gui.__ARG_CLIENT_ID: self._get_client_id()})
+        return f"/{Gui.__USER_CONTENT_URL}/{path or 'TaIpY'}?{urlencode(q_args)}"
 
     def __serve_user_content(self, path: str) -> t.Any:
         self.__set_client_id_in_context()
-        qargs: t.Dict[str, str] = {}
-        qargs.update(request.args)
-        qargs.pop(Gui.__ARG_CLIENT_ID, None)
+        q_args: t.Dict[str, str] = {}
+        q_args.update(request.args)
+        q_args.pop(Gui.__ARG_CLIENT_ID, None)
         cb_function: t.Optional[t.Union[t.Callable, str]] = None
         cb_function_name = None
-        if qargs.get(Gui._HTML_CONTENT_KEY):
+        if q_args.get(Gui._HTML_CONTENT_KEY):
             cb_function = self.__process_content_provider
             cb_function_name = cb_function.__name__
         else:
-            cb_function_name = qargs.get(Gui.__USER_CONTENT_CB)
+            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):
@@ -891,8 +891,8 @@ class Gui:
                 args: t.List[t.Any] = []
                 if path:
                     args.append(path)
-                if len(qargs):
-                    args.append(qargs)
+                if len(q_args):
+                    args.append(q_args)
                 ret = self._call_function_with_state(cb_function, args)
                 if ret is None:
                     _warn(f"{cb_function_name}() callback function must return a value.")
@@ -932,8 +932,8 @@ class Gui:
                 if libs is None:
                     libs = []
                     libraries[lib.get_name()] = libs
-                elts: t.List[t.Dict[str, str]] = []
-                libs.append({"js module": lib.get_js_module_name(), "elements": elts})
+                elements: t.List[t.Dict[str, str]] = []
+                libs.append({"js module": lib.get_js_module_name(), "elements": elements})
                 for element_name, elt in lib.get_elements().items():
                     if not isinstance(elt, Element):
                         continue
@@ -942,7 +942,7 @@ class Gui:
                         elt_dict["render function"] = elt._render_xhtml.__code__.co_name
                     else:
                         elt_dict["react name"] = elt._get_js_name(element_name)
-                    elts.append(elt_dict)
+                    elements.append(elt_dict)
         status.update({"libraries": libraries})
 
     def _serve_status(self, template: Path) -> t.Dict[str, t.Dict[str, str]]:

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

@@ -17,6 +17,7 @@ from ._attributes import (
     _setscopeattr,
     _setscopeattr_drill,
 )
+from ._lambda import _get_lambda_id
 from ._locals_context import _LocalsContext
 from ._map_dict import _MapDict
 from ._runtime_manager import _RuntimeManager

+ 21 - 9
taipy/gui/utils/_evaluator.py

@@ -25,6 +25,7 @@ if t.TYPE_CHECKING:
 from . import (
     _get_client_var_name,
     _get_expr_var_name,
+    _get_lambda_id,
     _getscopeattr,
     _getscopeattr_drill,
     _hasscopeattr,
@@ -100,19 +101,23 @@ class _Evaluator:
             st = ast.parse('f"{' + e + '}"' if _Evaluator.__EXPR_EDGE_CASE_F_STRING.match(e) else e)
             args = [arg.arg for node in ast.walk(st) if isinstance(node, ast.arguments) for arg in node.args]
             targets = [
-                compr.target.id  # type: ignore[attr-defined]
+                comprehension.target.id  # type: ignore[attr-defined]
                 for node in ast.walk(st)
                 if isinstance(node, ast.ListComp)
-                for compr in node.generators
+                for comprehension in node.generators
             ]
+            functionsCalls = set()
             for node in ast.walk(st):
-                if isinstance(node, ast.Name):
+                if isinstance(node, ast.Call):
+                    functionsCalls.add(node.func)
+                elif isinstance(node, ast.Name):
                     var_name = node.id.split(sep=".")[0]
                     if var_name in builtin_vars:
-                        _warn(
-                            f"Variable '{var_name}' cannot be used in Taipy expressions "
-                            "as its name collides with a Python built-in identifier."
-                        )
+                        if node not in functionsCalls:
+                            _warn(
+                                f"Variable '{var_name}' cannot be used in Taipy expressions "
+                                "as its name collides with a Python built-in identifier."
+                            )
                     elif var_name not in args and var_name not in targets and var_name not in non_vars:
                         try:
                             if lazy_declare and var_name.startswith("__"):
@@ -136,6 +141,7 @@ class _Evaluator:
         expr_hash: t.Optional[str],
         expr_evaluated: t.Optional[t.Any],
         var_map: t.Dict[str, str],
+        lambda_expr: t.Optional[bool] = False,
     ):
         if expr in self.__expr_to_hash:
             expr_hash = self.__expr_to_hash[expr]
@@ -143,7 +149,8 @@ class _Evaluator:
             return expr_hash
         if expr_hash is None:
             expr_hash = _get_expr_var_name(expr)
-        else:
+        elif not lambda_expr:
+            # if lambda expr, it has a hasname, we work with that
             # edge case, only a single variable
             expr_hash = f"tpec_{_get_client_var_name(expr)}"
         self.__expr_to_hash[expr] = expr_hash
@@ -223,6 +230,9 @@ class _Evaluator:
     ) -> t.Any:
         if not self._is_expression(expr) and not lambda_expr:
             return expr
+        if not lambda_expr and expr.startswith("{lambda ") and expr.endswith("}"):
+            lambda_expr = True
+            expr = expr[1:-1]
         var_val, var_map = ({}, {}) if lambda_expr else self._analyze_expression(gui, expr, lazy_declare)
         expr_hash = None
         is_edge_case = False
@@ -252,8 +262,10 @@ class _Evaluator:
         except Exception as e:
             _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)
         # save the expression if it needs to be re-evaluated
-        return self.__save_expression(gui, expr, expr_hash, expr_evaluated, var_map)
+        return self.__save_expression(gui, expr, expr_hash, expr_evaluated, var_map, lambda_expr)
 
     def refresh_expr(self, gui: Gui, var_name: str, holder: t.Optional[_TaipyBase]):
         """

+ 16 - 0
taipy/gui/utils/_lambda.py

@@ -0,0 +1,16 @@
+# 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.
+
+from types import LambdaType
+
+
+def _get_lambda_id(lambda_fn: LambdaType):
+    return f"__lambda_{id(lambda_fn)}"

+ 4 - 4
taipy/gui/viselements.json

@@ -788,22 +788,22 @@
                     },
                     {
                         "name": "style[<i>column_name</i>]",
-                        "type": "str",
+                        "type": "Union[str, Callable]",
                         "doc": "Allows the styling of table cells.<br/>See <a href=\"#dynamic-styling\">below</a> for details."
                     },
                     {
                         "name": "tooltip",
-                        "type": "str",
+                        "type": "Union[str, Callable]",
                         "doc": "The name of the function that must return a tooltip text for a cell.<br/>See <a href=\"#cell-tooltips\">below</a> for details."
                     },
                     {
                         "name": "tooltip[<i>column_name</i>]",
-                        "type": "str",
+                        "type": "Union[str, Callable]",
                         "doc": "The name of the function that must return a tooltip text for a cell.<br/>See <a href=\"#cell-tooltips\">below</a> for details."
                     },
                     {
                         "name": "format_fn[<i>column_name</i>]",
-                        "type": "str",
+                        "type": "Union[str, Callable]",
                         "doc": "TODO: The name of the function that must return a formatted value for a cell.<br/>See <a href=\"#cell-formats\">below</a> for details."
                     },
                     {

+ 15 - 11
tests/core/_orchestrator/test_orchestrator__submit.py

@@ -535,16 +535,18 @@ def test_submit_duration_development_mode():
     jobs = submission.jobs
     orchestrator.stop()
 
-    assert all(isinstance(job.execution_started_at, datetime) for job in jobs)
-    assert all(isinstance(job.execution_ended_at, datetime) for job in jobs)
+    assert all(isinstance(job.submitted_at, datetime) for job in jobs)
+    assert all(isinstance(job.run_at, datetime) for job in jobs)
+    assert all(isinstance(job.finished_at, datetime) for job in jobs)
     jobs_1s = jobs[0] if jobs[0].task.config_id == "task_config_id_1" else jobs[1]
     jobs_2s = jobs[0] if jobs[0].task.config_id == "task_config_id_2" else jobs[1]
     assert jobs_1s.execution_duration >= 1
     assert jobs_2s.execution_duration >= 2
 
     assert submission.execution_duration >= 3
-    assert submission.execution_started_at == min(jobs_1s.execution_started_at, jobs_2s.execution_started_at)
-    assert submission.execution_ended_at == max(jobs_1s.execution_ended_at, jobs_2s.execution_ended_at)
+    assert submission.submitted_at == min(jobs_1s.submitted_at, jobs_2s.submitted_at)
+    assert submission.run_at == min(jobs_1s.run_at, jobs_2s.run_at)
+    assert submission.finished_at == max(jobs_1s.finished_at, jobs_2s.finished_at)
 
 
 @pytest.mark.standalone
@@ -562,19 +564,21 @@ def test_submit_duration_standalone_mode():
     scenario = Scenario("scenario", {task_1, task_2}, {})
     _ScenarioManager._set(scenario)
     submission = taipy.submit(scenario)
-    jobs = submission.jobs
-
-    assert_true_after_time(jobs[1].is_completed)
 
+    assert_true_after_time(lambda: all(job is not None and job.is_completed() for job in submission.jobs))
     orchestrator.stop()
 
-    assert all(isinstance(job.execution_started_at, datetime) for job in jobs)
-    assert all(isinstance(job.execution_ended_at, datetime) for job in jobs)
+    jobs = submission.jobs
+
+    assert all(isinstance(job.submitted_at, datetime) for job in jobs)
+    assert all(isinstance(job.run_at, datetime) for job in jobs)
+    assert all(isinstance(job.finished_at, datetime) for job in jobs)
     jobs_1s = jobs[0] if jobs[0].task.config_id == "task_config_id_1" else jobs[1]
     jobs_2s = jobs[0] if jobs[0].task.config_id == "task_config_id_2" else jobs[1]
     assert jobs_1s.execution_duration >= 1
     assert jobs_2s.execution_duration >= 2
 
     assert submission.execution_duration >= 2  # Both tasks are executed in parallel so the duration may smaller than 3
-    assert submission.execution_started_at == min(jobs_1s.execution_started_at, jobs_2s.execution_started_at)
-    assert submission.execution_ended_at == max(jobs_1s.execution_ended_at, jobs_2s.execution_ended_at)
+    assert submission.submitted_at == min(jobs_1s.submitted_at, jobs_2s.submitted_at)
+    assert submission.run_at == min(jobs_1s.run_at, jobs_2s.run_at)
+    assert submission.finished_at == max(jobs_1s.finished_at, jobs_2s.finished_at)

+ 62 - 1
tests/core/job/test_job.py

@@ -9,12 +9,13 @@
 # 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 datetime import timedelta
+from datetime import datetime, timedelta
 from time import sleep
 from typing import Union, cast
 from unittest import mock
 from unittest.mock import MagicMock
 
+import freezegun
 import pytest
 
 from taipy.config.common.scope import Scope
@@ -323,6 +324,66 @@ def test_auto_set_and_reload(current_datetime, job_id):
     assert not job_1._is_in_context
 
 
+def test_status_records(job_id):
+    task_1 = Task(config_id="name_1", properties={}, function=_foo, id=TaskId("task_1"))
+    submission = _SubmissionManagerFactory._build_manager()._create(task_1.id, task_1._ID_PREFIX, task_1.config_id)
+    with freezegun.freeze_time("2024-09-25 13:30:30"):
+        job_1 = Job(job_id, task_1, submission.id, "scenario_entity_id")
+    submission.jobs = [job_1]
+
+    _TaskManager._set(task_1)
+    _JobManager._set(job_1)
+
+    assert job_1._status_change_records == {"SUBMITTED": datetime(2024, 9, 25, 13, 30, 30)}
+    assert job_1.submitted_at == datetime(2024, 9, 25, 13, 30, 30)
+    assert job_1.execution_duration is None
+
+    with freezegun.freeze_time("2024-09-25 13:35:30"):
+        job_1.blocked()
+    assert job_1._status_change_records == {
+        "SUBMITTED": datetime(2024, 9, 25, 13, 30, 30),
+        "BLOCKED": datetime(2024, 9, 25, 13, 35, 30),
+    }
+    assert job_1.execution_duration is None
+    with freezegun.freeze_time("2024-09-25 13:36:00"):
+        assert job_1.blocked_duration == 30  # = 13:36:00 - 13:35:30
+
+    with freezegun.freeze_time("2024-09-25 13:40:30"):
+        job_1.pending()
+    assert job_1._status_change_records == {
+        "SUBMITTED": datetime(2024, 9, 25, 13, 30, 30),
+        "BLOCKED": datetime(2024, 9, 25, 13, 35, 30),
+        "PENDING": datetime(2024, 9, 25, 13, 40, 30),
+    }
+    assert job_1.execution_duration is None
+    with freezegun.freeze_time("2024-09-25 13:41:00"):
+        assert job_1.pending_duration == 30  # = 13:41:00 - 13:40:30
+
+    with freezegun.freeze_time("2024-09-25 13:50:30"):
+        job_1.running()
+    assert job_1._status_change_records == {
+        "SUBMITTED": datetime(2024, 9, 25, 13, 30, 30),
+        "BLOCKED": datetime(2024, 9, 25, 13, 35, 30),
+        "PENDING": datetime(2024, 9, 25, 13, 40, 30),
+        "RUNNING": datetime(2024, 9, 25, 13, 50, 30),
+    }
+    assert job_1.run_at == datetime(2024, 9, 25, 13, 50, 30)
+    assert job_1.blocked_duration == 300  # = 13:40:30 - 13:35:30
+    assert job_1.pending_duration == 600  # = 13:50:30 - 13:40:30
+    assert job_1.execution_duration > 0
+
+    with freezegun.freeze_time("2024-09-25 13:56:35"):
+        job_1.completed()
+    assert job_1._status_change_records == {
+        "SUBMITTED": datetime(2024, 9, 25, 13, 30, 30),
+        "BLOCKED": datetime(2024, 9, 25, 13, 35, 30),
+        "PENDING": datetime(2024, 9, 25, 13, 40, 30),
+        "RUNNING": datetime(2024, 9, 25, 13, 50, 30),
+        "COMPLETED": datetime(2024, 9, 25, 13, 56, 35),
+    }
+    assert job_1.execution_duration == 365  # = 13:56:35 - 13:50:30
+
+
 def test_is_deletable():
     with mock.patch("taipy.core.job._job_manager._JobManager._is_deletable") as mock_submit:
         task = Task(config_id="name_1", properties={}, function=_foo, id=TaskId("task_1"))

+ 3 - 3
tests/core/notification/test_events_published.py

@@ -178,16 +178,16 @@ def test_events_published_for_scenario_submission():
     # 1 submission update event for is_completed
     scenario.submit()
     snapshot = all_evts.capture()
-    assert len(snapshot.collected_events) == 19
+    assert len(snapshot.collected_events) == 17
     assert snapshot.entity_type_collected.get(EventEntityType.CYCLE, 0) == 0
     assert snapshot.entity_type_collected.get(EventEntityType.DATA_NODE, 0) == 7
     assert snapshot.entity_type_collected.get(EventEntityType.TASK, 0) == 0
     assert snapshot.entity_type_collected.get(EventEntityType.SEQUENCE, 0) == 0
     assert snapshot.entity_type_collected.get(EventEntityType.SCENARIO, 0) == 1
-    assert snapshot.entity_type_collected.get(EventEntityType.JOB, 0) == 6
+    assert snapshot.entity_type_collected.get(EventEntityType.JOB, 0) == 4
     assert snapshot.entity_type_collected.get(EventEntityType.SUBMISSION, 0) == 5
     assert snapshot.operation_collected.get(EventOperation.CREATION, 0) == 2
-    assert snapshot.operation_collected.get(EventOperation.UPDATE, 0) == 16
+    assert snapshot.operation_collected.get(EventOperation.UPDATE, 0) == 14
     assert snapshot.operation_collected.get(EventOperation.SUBMISSION, 0) == 1
 
     assert snapshot.attr_name_collected["last_edit_date"] == 1

+ 22 - 12
tests/core/submission/test_submission.py

@@ -11,6 +11,7 @@
 
 from datetime import datetime
 
+import freezegun
 import pytest
 
 from taipy.core import TaskId
@@ -919,15 +920,24 @@ def test_execution_duration():
     submission.jobs = [job_1, job_2]
     _SubmissionManagerFactory._build_manager()._set(submission)
 
-    job_1.execution_started_at = datetime(2024, 1, 1, 0, 0, 0)
-    job_1.execution_ended_at = datetime(2024, 1, 1, 0, 0, 10)
-    job_2.execution_started_at = datetime(2024, 1, 1, 0, 1, 0)
-    job_2.execution_ended_at = datetime(2024, 1, 1, 0, 2, 30)
-    assert submission.execution_started_at == job_1.execution_started_at
-    assert submission.execution_ended_at == job_2.execution_ended_at
-    assert submission.execution_duration == 150
-
-    job_2.execution_ended_at = None  # job_2 is still running
-    assert submission.execution_started_at == job_1.execution_started_at
-    assert submission.execution_ended_at is None
-    assert submission.execution_duration is None
+    with freezegun.freeze_time("2024-09-25 13:30:35"):
+        job_1.running()
+        job_2.pending()
+
+    assert submission.run_at == datetime(2024, 9, 25, 13, 30, 35)
+    assert submission.execution_duration > 0
+
+    with freezegun.freeze_time("2024-09-25 13:33:45"):
+        job_1.completed()
+        job_2.running()
+        assert submission.execution_duration == 190  # = 13:33:45 - 13:30:35
+        assert submission.run_at == datetime(2024, 9, 25, 13, 30, 35)
+
+        # Job 2 is not completed, so the submission is not completed
+        assert submission.finished_at is None
+
+    with freezegun.freeze_time("2024-09-25 13:35:50"):
+        job_2.completed()
+
+    assert submission.finished_at == datetime(2024, 9, 25, 13, 35, 50)
+    assert submission.execution_duration == 315  # = 13:35:50 - 13:30:35

+ 1 - 1
tests/core/test_core.py

@@ -16,7 +16,7 @@ from taipy.core import Core, Orchestrator
 
 
 class TestCore:
-    def test_run_core_with_depracated_message(self, caplog):
+    def test_run_core_with_deprecated_message(self, caplog):
         with pytest.warns(DeprecationWarning):
             core = Core()
         core.run()