瀏覽代碼

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

Toan Quach 7 月之前
父節點
當前提交
8425f78804
共有 36 個文件被更改,包括 678 次插入221 次删除
  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
     runs-on: ubuntu-latest
     steps:
     steps:
       - name: Trigger taipy-benchmark computation
       - name: Trigger taipy-benchmark computation
-        uses: peter-evans/repository-dispatch@v1
+        uses: peter-evans/repository-dispatch@v3
         with:
         with:
           token: ${{secrets.TAIPY_INTEGRATION_TESTING_ACCESS_TOKEN}}
           token: ${{secrets.TAIPY_INTEGRATION_TESTING_ACCESS_TOKEN}}
           repository: avaiga/taipy-benchmark
           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
 # 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.
 # 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.
 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
         The Config class provides various methods to configure the application. Each method adds
         a specific section to the configuration and returns it.
         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
             ```python
             from taipy import Config
             from taipy import Config
@@ -61,7 +53,7 @@ class Config:
             Config.load("config.toml") # Load a configuration file
             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
             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
             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
         Once the configuration is done, you can retrieve the configuration values using the exposed
         attributes.
         attributes.
 
 
-        !!! Example "Retrieve configuration values"
+        ??? Example "Retrieve configuration values"
 
 
             ```python
             ```python
             from taipy import Config
             from taipy import Config
@@ -82,35 +74,41 @@ class Config:
             global_cfg = Config.global_config  # Retrieve the global application configuration
             global_cfg = Config.global_config  # Retrieve the global application configuration
             data_node_cfgs = Config.data_nodes  # Retrieve all data node configurations
             data_node_cfgs = Config.data_nodes  # Retrieve all data node configurations
             scenario_cfgs = Config.scenarios  # Retrieve all scenario configurations
             scenario_cfgs = Config.scenarios  # Retrieve all scenario configurations
-        ```
+            ```
 
 
     3. A few methods to manage the configuration:
     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"
     _ENVIRONMENT_VARIABLE_NAME_WITH_CONFIG_PATH = "TAIPY_CONFIG_PATH"
@@ -127,17 +125,14 @@ class Config:
 
 
     @_Classproperty
     @_Classproperty
     def unique_sections(cls) -> Dict[str, UniqueSection]:
     def unique_sections(cls) -> Dict[str, UniqueSection]:
-        """Return all unique sections."""
         return cls._applied_config._unique_sections
         return cls._applied_config._unique_sections
 
 
     @_Classproperty
     @_Classproperty
     def sections(cls) -> Dict[str, Dict[str, Section]]:
     def sections(cls) -> Dict[str, Dict[str, Section]]:
-        """Return all non unique sections."""
         return cls._applied_config._sections
         return cls._applied_config._sections
 
 
     @_Classproperty
     @_Classproperty
     def global_config(cls) -> GlobalAppConfig:
     def global_config(cls) -> GlobalAppConfig:
-        """Return configuration values related to the global application as a `GlobalAppConfig^`."""
         return cls._applied_config._global_config
         return cls._applied_config._global_config
 
 
     @classmethod
     @classmethod
@@ -249,6 +244,10 @@ class Config:
 
 
         Returns:
         Returns:
             Collector containing the info, warning and error issues.
             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._collector = _Checker._check(cls._applied_config)
         cls.__log_message(cls)
         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
         The Config class provides various methods to configure the application. Each method adds
         a specific section to the configuration and returns it.
         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
             ```python
             from taipy import Config
             from taipy import Config
@@ -60,7 +52,7 @@ class Config:
             Config.load("config.toml") # Load a configuration file
             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
             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
             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
         Once the configuration is done, you can retrieve the configuration values using the exposed
         attributes.
         attributes.
 
 
-        !!! Example "Retrieve configuration values"
+        ??? Example "Retrieve configuration values"
 
 
             ```python
             ```python
             from taipy import Config
             from taipy import Config
@@ -81,47 +73,53 @@ class Config:
             global_cfg = Config.global_config  # Retrieve the global application configuration
             global_cfg = Config.global_config  # Retrieve the global application configuration
             data_node_cfgs = Config.data_nodes  # Retrieve all data node configurations
             data_node_cfgs = Config.data_nodes  # Retrieve all data node configurations
             scenario_cfgs = Config.scenarios  # Retrieve all scenario configurations
             scenario_cfgs = Config.scenarios  # Retrieve all scenario configurations
-        ```
+            ```
 
 
     3. A few methods to manage the configuration:
     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
     @_Classproperty
     def unique_sections(cls) -> Dict[str, UniqueSection]:
     def unique_sections(cls) -> Dict[str, UniqueSection]:
-        """Return all unique sections."""
+        """"""
 
 
     @_Classproperty
     @_Classproperty
     def sections(cls) -> Dict[str, Dict[str, Section]]:
     def sections(cls) -> Dict[str, Dict[str, Section]]:
-        """Return all non unique sections."""
+        """"""
 
 
     @_Classproperty
     @_Classproperty
     def global_config(cls) -> GlobalAppConfig:
     def global_config(cls) -> GlobalAppConfig:
-        """Return configuration values related to the global application as a `GlobalAppConfig^`."""
+        """"""
 
 
     @classmethod
     @classmethod
     @_ConfigBlocker._check()
     @_ConfigBlocker._check()
@@ -209,6 +207,10 @@ class Config:
 
 
         Returns:
         Returns:
             Collector containing the info, warning and error issues.
             Collector containing the info, warning and error issues.
+
+        Raises:
+            SystemExit: If configuration errors are found, the application
+                exits with an error message.
         """
         """
 
 
     @classmethod
     @classmethod

+ 1 - 1
taipy/core/_core.py

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

+ 0 - 1
taipy/core/_init.py

@@ -30,7 +30,6 @@ from .taipy import (
     can_create,
     can_create,
     cancel_job,
     cancel_job,
     clean_all_entities,
     clean_all_entities,
-    clean_all_entities_by_version,
     compare_scenarios,
     compare_scenarios,
     create_global_data_node,
     create_global_data_node,
     create_scenario,
     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
 # 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.
 # specific language governing permissions and limitations under the License.
 
 
-import datetime
 from typing import Optional
 from typing import Optional
 
 
 from ...job.job import Job
 from ...job.job import Job
@@ -45,7 +44,5 @@ class _DevelopmentJobDispatcher(_JobDispatcher):
         Parameters:
         Parameters:
             job (Job^): The job to submit on an executor with an available worker.
             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()
         rs = _TaskFunctionWrapper(job.id, job.task).execute()
         self._update_job_status(job, rs)
         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
 # 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.
 # specific language governing permissions and limitations under the License.
 
 
-import datetime
 import multiprocessing as mp
 import multiprocessing as mp
 from concurrent.futures import Executor, ProcessPoolExecutor
 from concurrent.futures import Executor, ProcessPoolExecutor
 from functools import partial
 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.")
             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]
         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 = 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))
         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._nb_available_workers += 1
             self._logger.debug(f"Setting nb_available_workers to {self._nb_available_workers} in the callback method.")
             self._logger.debug(f"Setting nb_available_workers to {self._nb_available_workers} in the callback method.")
         self._update_job_status(job, ft.result())
         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.id,
             job._task.id,
             job._task.id,
             job._status,
             job._status,
+            {status: timestamp.isoformat() for status, timestamp in job._status_change_records.items()},
             job._force,
             job._force,
             job.submit_id,
             job.submit_id,
             job.submit_entity_id,
             job.submit_entity_id,
             job._creation_date.isoformat(),
             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),
             cls.__serialize_subscribers(job._subscribers),
             job._stacktrace,
             job._stacktrace,
             version=job._version,
             version=job._version,
@@ -52,12 +51,11 @@ class _JobConverter(_AbstractConverter):
         )
         )
 
 
         job._status = model.status  # type: ignore
         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._force = model.force  # type: ignore
         job._creation_date = datetime.fromisoformat(model.creation_date)  # 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:
         for it in model.subscribers:
             try:
             try:
                 fct_module, fct_name = it.get("fct_module"), it.get("fct_name")
                 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.
 # specific language governing permissions and limitations under the License.
 
 
 from dataclasses import dataclass
 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 .._repository._base_taipy_model import _BaseModel
 from .job_id import JobId
 from .job_id import JobId
@@ -22,12 +22,11 @@ class _JobModel(_BaseModel):
     id: JobId
     id: JobId
     task_id: str
     task_id: str
     status: Status
     status: Status
+    status_change_records: Dict[str, str]
     force: bool
     force: bool
     submit_id: str
     submit_id: str
     submit_entity_id: str
     submit_entity_id: str
     creation_date: str
     creation_date: str
-    execution_started_at: Optional[str]
-    execution_ended_at: Optional[str]
     subscribers: List[Dict]
     subscribers: List[Dict]
     stacktrace: List[str]
     stacktrace: List[str]
     version: str
     version: str
@@ -38,12 +37,11 @@ class _JobModel(_BaseModel):
             id=data["id"],
             id=data["id"],
             task_id=data["task_id"],
             task_id=data["task_id"],
             status=Status._from_repr(data["status"]),
             status=Status._from_repr(data["status"]),
+            status_change_records=_BaseModel._deserialize_attribute(data["status_change_records"]),
             force=data["force"],
             force=data["force"],
             submit_id=data["submit_id"],
             submit_id=data["submit_id"],
             submit_entity_id=data["submit_entity_id"],
             submit_entity_id=data["submit_entity_id"],
             creation_date=data["creation_date"],
             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"]),
             subscribers=_BaseModel._deserialize_attribute(data["subscribers"]),
             stacktrace=_BaseModel._deserialize_attribute(data["stacktrace"]),
             stacktrace=_BaseModel._deserialize_attribute(data["stacktrace"]),
             version=data["version"],
             version=data["version"],
@@ -54,12 +52,11 @@ class _JobModel(_BaseModel):
             self.id,
             self.id,
             self.task_id,
             self.task_id,
             repr(self.status),
             repr(self.status),
+            _BaseModel._serialize_attribute(self.status_change_records),
             self.force,
             self.force,
             self.submit_id,
             self.submit_id,
             self.submit_entity_id,
             self.submit_entity_id,
             self.creation_date,
             self.creation_date,
-            self.execution_started_at,
-            self.execution_ended_at,
             _BaseModel._serialize_attribute(self.subscribers),
             _BaseModel._serialize_attribute(self.subscribers),
             _BaseModel._serialize_attribute(self.stacktrace),
             _BaseModel._serialize_attribute(self.stacktrace),
             self.version,
             self.version,

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

@@ -12,7 +12,7 @@
 __all__ = ["Job"]
 __all__ = ["Job"]
 
 
 from datetime import datetime
 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
 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
     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,
     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.
     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._creation_date = datetime.now()
         self._submit_id: str = submit_id
         self._submit_id: str = submit_id
         self._submit_entity_id: str = submit_entity_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._subscribers: List[Callable] = []
         self._stacktrace: List[str] = []
         self._stacktrace: List[str] = []
         self.__logger = _TaipyLogger._get_logger()
         self.__logger = _TaipyLogger._get_logger()
@@ -134,6 +133,7 @@ class Job(_Entity, _Labeled):
     @status.setter  # type: ignore
     @status.setter  # type: ignore
     @_self_setter(_MANAGER_NAME)
     @_self_setter(_MANAGER_NAME)
     def status(self, val):
     def status(self, val):
+        self._status_change_records[val.name] = datetime.now()
         self._status = val
         self._status = val
 
 
     @property  # type: ignore
     @property  # type: ignore
@@ -148,36 +148,113 @@ class Job(_Entity, _Labeled):
 
 
     @property
     @property
     @_self_reload(_MANAGER_NAME)
     @_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
     @property
     @_self_reload(_MANAGER_NAME)
     @_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
     @property
     @_self_reload(_MANAGER_NAME)
     @_self_reload(_MANAGER_NAME)
     def execution_duration(self) -> Optional[float]:
     def execution_duration(self) -> Optional[float]:
         """Get the duration of the job execution in seconds.
         """Get the duration of the job execution in seconds.
+        The execution time is the duration from the job running to the job completion.
 
 
         Returns:
         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
     @property  # type: ignore
     @_self_reload(_MANAGER_NAME)
     @_self_reload(_MANAGER_NAME)

+ 2 - 2
taipy/core/orchestrator.py

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

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

@@ -141,29 +141,43 @@ class Submission(_Entity, _Labeled):
 
 
     @property
     @property
     @_self_reload(_MANAGER_NAME)
     @_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
         return None
 
 
     @property
     @property
     @_self_reload(_MANAGER_NAME)
     @_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
         return None
 
 
     @property
     @property
     @_self_reload(_MANAGER_NAME)
     @_self_reload(_MANAGER_NAME)
     def execution_duration(self) -> Optional[float]:
     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:
         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
         return None
 
 
     def get_label(self) -> str:
     def get_label(self) -> str:

+ 1 - 7
taipy/core/taipy.py

@@ -26,7 +26,7 @@ from .common._check_instance import (
     _is_submission,
     _is_submission,
     _is_task,
     _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.data_node_config import DataNodeConfig
 from .config.scenario_config import ScenarioConfig
 from .config.scenario_config import ScenarioConfig
 from .cycle._cycle_manager_factory import _CycleManagerFactory
 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)
     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:
 def clean_all_entities(version_number: str) -> bool:
     """Deletes all entities associated with the specified version.
     """Deletes all entities associated with the specified version.
     This function cleans all entities, including jobs, submissions, scenarios, cycles, sequences, tasks, and data nodes.
     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_client_var_name,
     _get_data_type,
     _get_data_type,
     _get_expr_var_name,
     _get_expr_var_name,
+    _get_lambda_id,
     _getscopeattr,
     _getscopeattr,
     _getscopeattr_drill,
     _getscopeattr_drill,
     _is_boolean,
     _is_boolean,
@@ -153,18 +154,24 @@ class _Builder:
         hashes = {}
         hashes = {}
         # Bind potential function and expressions in self.attributes
         # Bind potential function and expressions in self.attributes
         for k, v in attributes.items():
         for k, v in attributes.items():
-            val = v
             hash_name = hash_names.get(k)
             hash_name = hash_names.get(k)
             if hash_name is None:
             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
                     # need to unescape the double quotes that were escaped during preprocessing
                     (val, hash_name) = _Builder.__parse_attribute_value(gui, v.replace('\\"', '"'))
                     (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:
                 if val is not None or hash_name:
                     attributes[k] = val
                     attributes[k] = val

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

@@ -139,5 +139,9 @@ class _ElementApiGenerator(object, metaclass=_Singleton):
         return type(
         return type(
             classname,
             classname,
             (ElementBaseClass,),
             (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
         return key
 
 
     def _is_callable(self, name: str):
     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:
     def _parse_property(self, key: str, value: t.Any) -> t.Any:
         if isinstance(value, (str, dict, Iterable)):
         if isinstance(value, (str, dict, Iterable)):
@@ -121,10 +123,10 @@ class _Element(ABC):
                 return None
                 return None
             args = [arg.arg for arg in lambda_fn.args.args]
             args = [arg.arg for arg in lambda_fn.args.args]
             targets = [
             targets = [
-                compr.target.id  # type: ignore[attr-defined]
+                comprehension.target.id  # type: ignore[attr-defined]
                 for node in ast.walk(lambda_fn.body)
                 for node in ast.walk(lambda_fn.body)
                 if isinstance(node, ast.ListComp)
                 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)
             tree = _TransformVarToValue(self.__calling_frame, args + targets + _python_builtins).visit(lambda_fn)
             ast.fix_missing_locations(tree)
             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")
             on_change_fn = self._get_user_function("on_change")
         if callable(on_change_fn):
         if callable(on_change_fn):
             try:
             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()
                     args[0] = self.__get_state()
-                if argcount > 1:
+                if arg_count > 1:
                     args[1] = var_name
                     args[1] = var_name
-                if argcount > 2:
+                if arg_count > 2:
                     args[2] = value
                     args[2] = value
-                if argcount > 3:
+                if arg_count > 3:
                     args[3] = current_context
                     args[3] = current_context
                 on_change_fn(*args)
                 on_change_fn(*args)
             except Exception as e:  # pragma: no cover
             except Exception as e:  # pragma: no cover
@@ -849,22 +849,22 @@ class Gui:
     def _get_user_content_url(
     def _get_user_content_url(
         self, path: t.Optional[str] = None, query_args: t.Optional[t.Dict[str, str]] = None
         self, path: t.Optional[str] = None, query_args: t.Optional[t.Dict[str, str]] = None
     ) -> t.Optional[str]:
     ) -> 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:
     def __serve_user_content(self, path: str) -> t.Any:
         self.__set_client_id_in_context()
         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: t.Optional[t.Union[t.Callable, str]] = None
         cb_function_name = 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 = self.__process_content_provider
             cb_function_name = cb_function.__name__
             cb_function_name = cb_function.__name__
         else:
         else:
-            cb_function_name = qargs.get(Gui.__USER_CONTENT_CB)
+            cb_function_name = q_args.get(Gui.__USER_CONTENT_CB)
             if cb_function_name:
             if cb_function_name:
                 cb_function = self._get_user_function(cb_function_name)
                 cb_function = self._get_user_function(cb_function_name)
                 if not callable(cb_function):
                 if not callable(cb_function):
@@ -891,8 +891,8 @@ class Gui:
                 args: t.List[t.Any] = []
                 args: t.List[t.Any] = []
                 if path:
                 if path:
                     args.append(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)
                 ret = self._call_function_with_state(cb_function, args)
                 if ret is None:
                 if ret is None:
                     _warn(f"{cb_function_name}() callback function must return a value.")
                     _warn(f"{cb_function_name}() callback function must return a value.")
@@ -932,8 +932,8 @@ class Gui:
                 if libs is None:
                 if libs is None:
                     libs = []
                     libs = []
                     libraries[lib.get_name()] = 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():
                 for element_name, elt in lib.get_elements().items():
                     if not isinstance(elt, Element):
                     if not isinstance(elt, Element):
                         continue
                         continue
@@ -942,7 +942,7 @@ class Gui:
                         elt_dict["render function"] = elt._render_xhtml.__code__.co_name
                         elt_dict["render function"] = elt._render_xhtml.__code__.co_name
                     else:
                     else:
                         elt_dict["react name"] = elt._get_js_name(element_name)
                         elt_dict["react name"] = elt._get_js_name(element_name)
-                    elts.append(elt_dict)
+                    elements.append(elt_dict)
         status.update({"libraries": libraries})
         status.update({"libraries": libraries})
 
 
     def _serve_status(self, template: Path) -> t.Dict[str, t.Dict[str, str]]:
     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,
     _setscopeattr_drill,
     _setscopeattr_drill,
 )
 )
+from ._lambda import _get_lambda_id
 from ._locals_context import _LocalsContext
 from ._locals_context import _LocalsContext
 from ._map_dict import _MapDict
 from ._map_dict import _MapDict
 from ._runtime_manager import _RuntimeManager
 from ._runtime_manager import _RuntimeManager

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

@@ -25,6 +25,7 @@ if t.TYPE_CHECKING:
 from . import (
 from . import (
     _get_client_var_name,
     _get_client_var_name,
     _get_expr_var_name,
     _get_expr_var_name,
+    _get_lambda_id,
     _getscopeattr,
     _getscopeattr,
     _getscopeattr_drill,
     _getscopeattr_drill,
     _hasscopeattr,
     _hasscopeattr,
@@ -100,19 +101,23 @@ class _Evaluator:
             st = ast.parse('f"{' + e + '}"' if _Evaluator.__EXPR_EDGE_CASE_F_STRING.match(e) else e)
             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]
             args = [arg.arg for node in ast.walk(st) if isinstance(node, ast.arguments) for arg in node.args]
             targets = [
             targets = [
-                compr.target.id  # type: ignore[attr-defined]
+                comprehension.target.id  # type: ignore[attr-defined]
                 for node in ast.walk(st)
                 for node in ast.walk(st)
                 if isinstance(node, ast.ListComp)
                 if isinstance(node, ast.ListComp)
-                for compr in node.generators
+                for comprehension in node.generators
             ]
             ]
+            functionsCalls = set()
             for node in ast.walk(st):
             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]
                     var_name = node.id.split(sep=".")[0]
                     if var_name in builtin_vars:
                     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:
                     elif var_name not in args and var_name not in targets and var_name not in non_vars:
                         try:
                         try:
                             if lazy_declare and var_name.startswith("__"):
                             if lazy_declare and var_name.startswith("__"):
@@ -136,6 +141,7 @@ class _Evaluator:
         expr_hash: t.Optional[str],
         expr_hash: t.Optional[str],
         expr_evaluated: t.Optional[t.Any],
         expr_evaluated: t.Optional[t.Any],
         var_map: t.Dict[str, str],
         var_map: t.Dict[str, str],
+        lambda_expr: t.Optional[bool] = False,
     ):
     ):
         if expr in self.__expr_to_hash:
         if expr in self.__expr_to_hash:
             expr_hash = self.__expr_to_hash[expr]
             expr_hash = self.__expr_to_hash[expr]
@@ -143,7 +149,8 @@ class _Evaluator:
             return expr_hash
             return expr_hash
         if expr_hash is None:
         if expr_hash is None:
             expr_hash = _get_expr_var_name(expr)
             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
             # edge case, only a single variable
             expr_hash = f"tpec_{_get_client_var_name(expr)}"
             expr_hash = f"tpec_{_get_client_var_name(expr)}"
         self.__expr_to_hash[expr] = expr_hash
         self.__expr_to_hash[expr] = expr_hash
@@ -223,6 +230,9 @@ class _Evaluator:
     ) -> t.Any:
     ) -> t.Any:
         if not self._is_expression(expr) and not lambda_expr:
         if not self._is_expression(expr) and not lambda_expr:
             return 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)
         var_val, var_map = ({}, {}) if lambda_expr else self._analyze_expression(gui, expr, lazy_declare)
         expr_hash = None
         expr_hash = None
         is_edge_case = False
         is_edge_case = False
@@ -252,8 +262,10 @@ class _Evaluator:
         except Exception as e:
         except Exception as e:
             _warn(f"Cannot evaluate expression '{not_encoded_expr if is_edge_case else expr_string}'", e)
             _warn(f"Cannot evaluate expression '{not_encoded_expr if is_edge_case else expr_string}'", e)
             expr_evaluated = None
             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
         # 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]):
     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>]",
                         "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."
                         "doc": "Allows the styling of table cells.<br/>See <a href=\"#dynamic-styling\">below</a> for details."
                     },
                     },
                     {
                     {
                         "name": "tooltip",
                         "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."
                         "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>]",
                         "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."
                         "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>]",
                         "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."
                         "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
     jobs = submission.jobs
     orchestrator.stop()
     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_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]
     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_1s.execution_duration >= 1
     assert jobs_2s.execution_duration >= 2
     assert jobs_2s.execution_duration >= 2
 
 
     assert submission.execution_duration >= 3
     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
 @pytest.mark.standalone
@@ -562,19 +564,21 @@ def test_submit_duration_standalone_mode():
     scenario = Scenario("scenario", {task_1, task_2}, {})
     scenario = Scenario("scenario", {task_1, task_2}, {})
     _ScenarioManager._set(scenario)
     _ScenarioManager._set(scenario)
     submission = taipy.submit(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()
     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_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]
     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_1s.execution_duration >= 1
     assert jobs_2s.execution_duration >= 2
     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_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
 # 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.
 # specific language governing permissions and limitations under the License.
 
 
-from datetime import timedelta
+from datetime import datetime, timedelta
 from time import sleep
 from time import sleep
 from typing import Union, cast
 from typing import Union, cast
 from unittest import mock
 from unittest import mock
 from unittest.mock import MagicMock
 from unittest.mock import MagicMock
 
 
+import freezegun
 import pytest
 import pytest
 
 
 from taipy.config.common.scope import Scope
 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
     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():
 def test_is_deletable():
     with mock.patch("taipy.core.job._job_manager._JobManager._is_deletable") as mock_submit:
     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"))
         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
     # 1 submission update event for is_completed
     scenario.submit()
     scenario.submit()
     snapshot = all_evts.capture()
     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.CYCLE, 0) == 0
     assert snapshot.entity_type_collected.get(EventEntityType.DATA_NODE, 0) == 7
     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.TASK, 0) == 0
     assert snapshot.entity_type_collected.get(EventEntityType.SEQUENCE, 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.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.entity_type_collected.get(EventEntityType.SUBMISSION, 0) == 5
     assert snapshot.operation_collected.get(EventOperation.CREATION, 0) == 2
     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.operation_collected.get(EventOperation.SUBMISSION, 0) == 1
 
 
     assert snapshot.attr_name_collected["last_edit_date"] == 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
 from datetime import datetime
 
 
+import freezegun
 import pytest
 import pytest
 
 
 from taipy.core import TaskId
 from taipy.core import TaskId
@@ -919,15 +920,24 @@ def test_execution_duration():
     submission.jobs = [job_1, job_2]
     submission.jobs = [job_1, job_2]
     _SubmissionManagerFactory._build_manager()._set(submission)
     _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:
 class TestCore:
-    def test_run_core_with_depracated_message(self, caplog):
+    def test_run_core_with_deprecated_message(self, caplog):
         with pytest.warns(DeprecationWarning):
         with pytest.warns(DeprecationWarning):
             core = Core()
             core = Core()
         core.run()
         core.run()