瀏覽代碼

Merge branch 'feature/#551-Exposing-metric-control' of github.com:Avaiga/taipy into feature/#551-Exposing-metric-control

namnguyen 1 年之前
父節點
當前提交
af4dd427e9

+ 39 - 0
.github/workflows/manage-stale-issue-pr.yml

@@ -0,0 +1,39 @@
+name: Manage Stale Issues and PRs
+
+on:
+  schedule:
+    # Run once every day at 9 AM UTC
+    - cron: 00 9 * * *
+
+jobs:
+  stale-issues-and-prs:
+    name: Comment on possible stable issues and PRs, and close stale PRs
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/stale@v9
+        with:
+          operations-per-run: 50  # Max number of operations per run (including fetch or close issues and PRs, set or update labels, add comments, delete branches, etc.)
+          include-only-assigned: true
+          days-before-stale: 14  # Idle number of days before marking issues/PRs stale
+          stale-issue-message: "This issue has been labelled as \"🥶Waiting for contributor\" because it has been inactive for more than 14 days. If you would like to continue working on this issue, please add another comment or create a PR that links to this issue. If a PR has already been created which refers to this issue, then you should explicitly mention this issue in the relevant PR. Otherwise, you will be unassigned in 14 days. For more information please refer to the contributing guidelines."
+          stale-issue-label: "🥶Waiting for contributor"
+          stale-pr-message: "This PR has been labelled as \"🥶Waiting for contributor\" because it has been inactive for more than 14 days. If you would like to continue working on this PR, then please add new commit or another comment, otherwise this PR will be closed in 14 days. For more information please refer to the contributing guidelines."
+          stale-pr-label: "🥶Waiting for contributor"
+          days-before-pr-close: 14
+          close-pr-message: "This PR has been closed because it has been marked as \"🥶Waiting for contributor\" for more than 14 days with no activity."
+          delete-branch: true  # Delete branch after closing a stale PR
+          days-before-issue-close: -1  # Never close an issue
+          exempt-issue-labels: "❌ Blocked,💬 Discussion"  # Issues with label ❌ Blocked or 💬 Discussion are exempted from stale
+          exempt-pr-labels: "❌ Blocked,💬 Discussion"  # PRs with label ❌ Blocked or 💬 Discussion are exempted from stale
+          remove-stale-when-updated: true
+
+  unassign-issues-labeled-waiting-for-contributor-after-14-days-of-inactivity:
+    name: Unassign issues labeled \"🥶Waiting for contributor\" after 14 days of inactivity.
+    runs-on: ubuntu-latest
+    steps:
+      - uses: boundfoxstudios/action-unassign-contributor-after-days-of-inactivity@v1
+        with:
+          last-activity: 14
+          labels: "🥶Waiting for contributor"
+          labels-to-remove: "🥶Waiting for contributor"
+          message: "This issue has been unassigned automatically because it has been marked as \"🥶Waiting for contributor\" for more than 14 days with no activity."

+ 61 - 48
CONTRIBUTING.md

@@ -6,7 +6,14 @@ Every little help and credit will always be given.
 There are multiple ways to contribute to Taipy: code, but also reporting bugs, creating feature requests, helping
 other users in our forums, [stack**overflow**](https://stackoverflow.com/), etc.
 
-Today the only way to communicate with the Taipy team is by GitHub issues.
+For questions, please get in touch on [Discord](https://discord.com/invite/SJyz2VJGxV) or on GitHub with a discussion or an issue.
+
+## Code organisation
+
+Taipy is organised in two main repositories:
+
+- [taipy](https://github.com/Avaiga/taipy) is the main repository that containing the code of Taipy packages.
+- [taipy-doc](https://github.com/Avaiga/taipy-doc) is the documentation repository.
 
 ## Never contributed on an open source project before ?
 
@@ -36,18 +43,54 @@ Do not hesitate to create an issue or pull request directly on the
 
 ## Implement Features
 
-The Taipy team manages its backlog in private. Each issue that will be done during our current sprint is
-attached to the `current sprint`. Please, do not work on it, the Taipy team is on it.
+The Taipy team manages its backlog in private. Each issue that is or is going to be engaged by the
+Taipy team is attached to the "🔒 Staff only" label or has already assigned to a Taipy team member.
+Please, do not work on it, the Taipy team is on it.
 
-## Code organisation
+All other issues are sorted by labels and are available for a contribution. If you are new to the
+project, you can start with the "good first issue" or "🆘 Help wanted" label. You can also start with
+issue with higher priority like "Critical" or "High". The higher the priority, the more value it
+will bring to Taipy.
+
+If you want to work on an issue, please add a comment and wait to be assigned to the issue to inform
+the community that you are working on it.
+
+### Contribution workflow
 
-Taipy is organised in five main repositories:
+1. Make your [own fork](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) of the repository
+   target by the issue. Clone it on our local machine, then go inside the directory.
 
-- [taipy-config](https://github.com/Avaiga/taipy-config).
-- [taipy-core](https://github.com/Avaiga/taipy-core).
-- [taipy-gui](https://github.com/Avaiga/taipy-gui).
-- [taipy-rest](https://github.com/Avaiga/taipy-rest).
-- [taipy](https://github.com/Avaiga/taipy) brings previous packages in a single one.
+2. We are working with [Pipenv](https://github.com/pypa/pipenv) for our virtualenv.
+   Create a local env and install development package by running `$ pipenv install --dev`, then run tests with
+   `$ pipenv run pytest` to verify your setup.
+
+3. For convention help, we provide a [pre-commit](https://pre-commit.com/hooks.html) file.
+   This tool will run before each commit and will automatically reformat code or raise warnings and errors based on the
+   code format or Python typing.
+   You can install and setup it up by doing:
+   ```bash
+   $ pipenv install pre-commit
+   $ pipenv run python -m pre-commit install
+   ```
+
+4. Make the changes.<br/>
+   You may want to also add your GitHub login as a new line of the `contributors.txt` file located at the root
+   of this repository. We are using it to list our contributors in the Taipy documentation
+   (see the "Contributing > Contributors" section) and thank them.
+
+5. Create a [pull request from your fork](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork).<br/>
+   Keep your pull request as __draft__ until your work is finished.
+   Do not hesitate to add a comment for help or questions.
+   Before you submit a pull request for review from your forked repo, check that it meets these guidelines:
+     - The code and the branch name follow the [Taipy coding style](#coding-style-and-best-practices).
+     - Include tests.
+     - Code is [rebase](http://stackoverflow.com/a/7244456/1110993).
+     - License is present.
+     - pre-commit works - without mypy error.
+     - Taipy tests are passing.
+
+6. The Taipy team will have a look at your Pull Request and will give feedback. If every requirement is valid, your
+   work will be added in the next release, congratulation!
 
 ## Coding style and best practices
 
@@ -80,46 +123,16 @@ Where:
 - `[IssueSummary]` is a short summary of the issue topic, not including spaces, using Camel case or lower-case,
   dash-separated words. This summary, with its dash (‘-’) symbol prefix, is optional.
 
+## Important Notes
 
-## Contribution workflow
-
-Find an issue without the label `current sprint` and add a comment on it to inform the community that you are
-working on it.
-
-1. Make your [own fork](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) of the repository
-   target by the issue. Clone it on our local machine, then go inside the directory.
-
-2. We are working with [Pipenv](https://github.com/pypa/pipenv) for our virtualenv.
-   Create a local env and install development package by running `pipenv install --dev`, then run tests with `pipenv
-   run pytest` to verify your setup.
-
-3. For convention help, we provide a [pre-commit](https://pre-commit.com/hooks.html) file.
-   This tool will run before each commit and will automatically reformat code or raise warnings and errors based on the
-   code format or Python typing.
-   You can install and setup it up by doing:
-   ```bash
-   pipenv install pre-commit
-   pipenv run python -m pre-commit install
-   ```
-
-4. Make the changes.<br/>
-   You may want to also add your GitHub login as a new line of the `contributors.txt` file located at the root
-   of this repository. We are using it to list our contributors in the Taipy documentation
-   (see the "Contributing > Contributors" section) and thank them.
-
-5. Create a [pull request from your fork](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork).<br/>
-   Keep your pull request as __draft__ until your work is finished.
-   Do not hesitate to add a comment for help or questions.
-   Before you submit a pull request for review from your forked repo, check that it meets these guidelines:
-    - Include tests.
-    - Code is [rebase](http://stackoverflow.com/a/7244456/1110993).
-    - License is present.
-    - pre-commit works - without mypy error.
-    - GitHub's actions are passing.
-
-6. The taipy team will have a look at your Pull Request and will give feedback. If every requirement is valid, your
-   work will be added in the next release, congratulation!
+- If your PR is not created or there is no other activity within 14 days of being assigned to the issue, a warning message will appear on the issue, and the issue will be marked as "🥶Waiting for contributor".
+- If your issue is marked as "🥶Waiting for contributor", you will be unassigned after 14 days of inactivity.
+- Similarly, if there is no activity within 14 days of your PR, the PR will be marked as "🥶Waiting for contributor".
+- If your PR is marked as "🥶Waiting for contributor", it will be closed after 14 days of inactivity.
 
+We do this in order to keep our backlog moving quickly. Please don't take it personally if your issue or PR gets closed
+because of this 14-day inactivity time limit. You can always reopen the issue or PR if you're still interested in working
+on it.
 
 ## Dependency management
 

+ 13 - 18
taipy/core/_entity/_ready_to_run_property.py

@@ -11,6 +11,7 @@
 
 from typing import TYPE_CHECKING, Dict, Set, Union
 
+from ..common.reason import Reason
 from ..notification import EventOperation, Notifier, _make_event
 
 if TYPE_CHECKING:
@@ -28,7 +29,7 @@ class _ReadyToRunProperty:
 
     # A nested dictionary of the submittable entities (Scenario, Sequence, Task) and
     # the data nodes that make it not ready_to_run with the reason(s)
-    _submittable_id_datanodes: Dict[Union["ScenarioId", "SequenceId", "TaskId"], Dict["DataNodeId", Set[str]]] = {}
+    _submittable_id_datanodes: Dict[Union["ScenarioId", "SequenceId", "TaskId"], Reason] = {}
 
     @classmethod
     def _add(cls, dn: "DataNode", reason: str) -> None:
@@ -40,13 +41,13 @@ class _ReadyToRunProperty:
 
         for scenario_parent in parent_entities.get(Scenario._MANAGER_NAME, []):
             if dn in scenario_parent.get_inputs():
-                _ReadyToRunProperty.__add(scenario_parent, dn, reason)
+                cls.__add(scenario_parent, dn, reason)
         for sequence_parent in parent_entities.get(Sequence._MANAGER_NAME, []):
             if dn in sequence_parent.get_inputs():
-                _ReadyToRunProperty.__add(sequence_parent, dn, reason)
+                cls.__add(sequence_parent, dn, reason)
         for task_parent in parent_entities.get(Task._MANAGER_NAME, []):
             if dn in task_parent.input.values():
-                _ReadyToRunProperty.__add(task_parent, dn, reason)
+                cls.__add(task_parent, dn, reason)
 
     @classmethod
     def _remove(cls, datanode: "DataNode", reason: str) -> None:
@@ -58,18 +59,14 @@ class _ReadyToRunProperty:
         to_remove_dn = False
         for submittable_id in submittable_ids:
             # check remove the reason
-            if reason in cls._submittable_id_datanodes.get(submittable_id, {}).get(datanode.id, set()):
-                cls._submittable_id_datanodes[submittable_id][datanode.id].remove(reason)
-            if len(cls._submittable_id_datanodes.get(submittable_id, {}).get(datanode.id, set())) == 0:
-                to_remove_dn = True
-                cls._submittable_id_datanodes.get(submittable_id, {}).pop(datanode.id, None)
-                if (
-                    submittable_id in cls._submittable_id_datanodes
-                    and len(cls._submittable_id_datanodes[submittable_id]) == 0
-                ):
+            reason_entity = cls._submittable_id_datanodes.get(submittable_id)
+            if reason_entity is not None:
+                reason_entity._remove_reason(datanode.id, reason)
+                to_remove_dn = not reason_entity._entity_id_exists_in_reason(datanode.id)
+                if reason_entity:
                     submittable = tp_get(submittable_id)
                     cls.__publish_submittable_property_event(submittable, True)
-                    cls._submittable_id_datanodes.pop(submittable_id, None)
+                    cls._submittable_id_datanodes.pop(submittable_id)
 
         if to_remove_dn:
             cls._datanode_id_submittables.pop(datanode.id)
@@ -84,10 +81,8 @@ class _ReadyToRunProperty:
             cls.__publish_submittable_property_event(submittable, False)
 
         if submittable.id not in cls._submittable_id_datanodes:
-            cls._submittable_id_datanodes[submittable.id] = {}
-        if datanode.id not in cls._submittable_id_datanodes[submittable.id]:
-            cls._submittable_id_datanodes[submittable.id][datanode.id] = set()
-        cls._submittable_id_datanodes[submittable.id][datanode.id].add(reason)
+            cls._submittable_id_datanodes[submittable.id] = Reason(submittable.id)
+        cls._submittable_id_datanodes[submittable.id]._add_reason(datanode.id, reason)
 
     @staticmethod
     def __publish_submittable_property_event(

+ 13 - 3
taipy/core/_entity/submittable.py

@@ -17,6 +17,7 @@ import networkx as nx
 
 from ..common._listattributes import _ListAttributes
 from ..common._utils import _Subscriber
+from ..common.reason import Reason
 from ..data.data_node import DataNode
 from ..job.job import Job
 from ..submission.submission import Submission
@@ -81,13 +82,22 @@ class Submittable:
         all_data_nodes_in_dag = {node for node in dag.nodes if isinstance(node, DataNode)}
         return all_data_nodes_in_dag - self.__get_inputs(dag) - self.__get_outputs(dag)
 
-    def is_ready_to_run(self) -> bool:
+    def is_ready_to_run(self) -> Reason:
         """Indicate if the entity is ready to be run.
 
         Returns:
-            True if the given entity is ready to be run. False otherwise.
+            A Reason object that can function as a Boolean value.
+            which is True if the given entity is ready to be run or there is no reason to be blocked, False otherwise.
         """
-        return all(dn.is_ready_for_reading for dn in self.get_inputs())
+        reason = Reason(self._submittable_id)
+
+        for node in self.get_inputs():
+            if node._edit_in_progress:
+                reason._add_reason(node.id, node._build_edit_in_progress_reason())
+            if not node._last_edit_date:
+                reason._add_reason(node.id, node._build_not_written_reason())
+
+        return reason
 
     def data_nodes_being_edited(self) -> Set[DataNode]:
         """Return the set of data nodes of the submittable entity that are being edited.

+ 4 - 0
taipy/core/_manager/_manager.py

@@ -27,6 +27,10 @@ class _Manager(Generic[EntityType]):
     _logger = _TaipyLogger._get_logger()
     _ENTITY_NAME: str = "Entity"
 
+    @classmethod
+    def _build_not_submittable_entity_reason(cls, entity_id: str) -> str:
+        return f"Entity {entity_id} is not a submittable entity"
+
     @classmethod
     def _delete_all(cls):
         """

+ 41 - 0
taipy/core/common/reason.py

@@ -0,0 +1,41 @@
+# 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 typing import Dict, Set
+
+
+class Reason:
+    def __init__(self, entity_id: str) -> None:
+        self.entity_id: str = entity_id
+        self._reasons: Dict[str, Set[str]] = {}
+
+    def _add_reason(self, entity_id: str, reason: str) -> "Reason":
+        if entity_id not in self._reasons:
+            self._reasons[entity_id] = set()
+        self._reasons[entity_id].add(reason)
+        return self
+
+    def _remove_reason(self, entity_id: str, reason: str) -> "Reason":
+        if entity_id in self._reasons and reason in self._reasons[entity_id]:
+            self._reasons[entity_id].remove(reason)
+            if len(self._reasons[entity_id]) == 0:
+                del self._reasons[entity_id]
+        return self
+
+    def _entity_id_exists_in_reason(self, entity_id: str) -> bool:
+        return entity_id in self._reasons
+
+    def __bool__(self) -> bool:
+        return len(self._reasons) == 0
+
+    @property
+    def reasons(self) -> str:
+        return "; ".join("; ".join(reason) for reason in self._reasons.values()) + "." if self._reasons else ""

+ 6 - 0
taipy/core/data/data_node.py

@@ -227,6 +227,9 @@ class DataNode(_Entity, _Labeled):
     def last_edit_date(self, val):
         self._last_edit_date = val
 
+    def _build_not_written_reason(self) -> str:
+        return f"DataNode {self.id} is not written"
+
     @property  # type: ignore
     @_self_reload(_MANAGER_NAME)
     def scope(self):
@@ -292,6 +295,9 @@ class DataNode(_Entity, _Labeled):
     def edit_in_progress(self, val):
         self._edit_in_progress = val
 
+    def _build_edit_in_progress_reason(self) -> str:
+        return f"DataNode {self.id} is being edited"
+
     @property  # type: ignore
     @_self_reload(_MANAGER_NAME)
     def editor_id(self):

+ 10 - 2
taipy/core/scenario/_scenario_manager.py

@@ -23,6 +23,7 @@ from .._manager._manager import _Manager
 from .._repository._abstract_repository import _AbstractRepository
 from .._version._version_manager_factory import _VersionManagerFactory
 from .._version._version_mixin import _VersionMixin
+from ..common.reason import Reason
 from ..common.warn_if_inputs_not_ready import _warn_if_inputs_not_ready
 from ..config.scenario_config import ScenarioConfig
 from ..cycle._cycle_manager_factory import _CycleManagerFactory
@@ -199,10 +200,17 @@ class _ScenarioManager(_Manager[Scenario], _VersionMixin):
         return scenario
 
     @classmethod
-    def _is_submittable(cls, scenario: Union[Scenario, ScenarioId]) -> bool:
+    def _is_submittable(cls, scenario: Union[Scenario, ScenarioId]) -> Reason:
         if isinstance(scenario, str):
             scenario = cls._get(scenario)
-        return isinstance(scenario, Scenario) and scenario.is_ready_to_run()
+
+        if not isinstance(scenario, Scenario):
+            scenario = str(scenario)
+            reason = Reason((scenario))
+            reason._add_reason(scenario, cls._build_not_submittable_entity_reason(scenario))
+            return reason
+
+        return scenario.is_ready_to_run()
 
     @classmethod
     def _submit(

+ 10 - 2
taipy/core/sequence/_sequence_manager.py

@@ -18,6 +18,7 @@ from .._entity._entity_ids import _EntityIds
 from .._manager._manager import _Manager
 from .._version._version_mixin import _VersionMixin
 from ..common._utils import _Subscriber
+from ..common.reason import Reason
 from ..common.warn_if_inputs_not_ready import _warn_if_inputs_not_ready
 from ..exceptions.exceptions import (
     InvalidSequenceId,
@@ -296,10 +297,17 @@ class _SequenceManager(_Manager[Sequence], _VersionMixin):
         Notifier.publish(_make_event(sequence, EventOperation.UPDATE, attribute_name="subscribers"))
 
     @classmethod
-    def _is_submittable(cls, sequence: Union[Sequence, SequenceId]) -> bool:
+    def _is_submittable(cls, sequence: Union[Sequence, SequenceId]) -> Reason:
         if isinstance(sequence, str):
             sequence = cls._get(sequence)
-        return isinstance(sequence, Sequence) and sequence.is_ready_to_run()
+
+        if not isinstance(sequence, Sequence):
+            sequence = str(sequence)
+            reason = Reason(sequence)
+            reason._add_reason(sequence, cls._build_not_submittable_entity_reason(sequence))
+            return reason
+
+        return sequence.is_ready_to_run()
 
     @classmethod
     def _submit(

+ 8 - 7
taipy/core/taipy.py

@@ -33,6 +33,7 @@ from .common._check_instance import (
     _is_task,
 )
 from .common._warnings import _warn_deprecated, _warn_no_core_service
+from .common.reason import Reason
 from .config.data_node_config import DataNodeConfig
 from .config.scenario_config import ScenarioConfig
 from .cycle._cycle_manager_factory import _CycleManagerFactory
@@ -89,7 +90,7 @@ def set(entity: Union[DataNode, Task, Sequence, Scenario, Cycle, Submission]):
         return _SubmissionManagerFactory._build_manager()._set(entity)
 
 
-def is_submittable(entity: Union[Scenario, ScenarioId, Sequence, SequenceId, Task, TaskId, str]) -> bool:
+def is_submittable(entity: Union[Scenario, ScenarioId, Sequence, SequenceId, Task, TaskId, str]) -> Reason:
     """Indicate if an entity can be submitted.
 
     This function checks if the given entity can be submitted for execution.
@@ -109,7 +110,7 @@ def is_submittable(entity: Union[Scenario, ScenarioId, Sequence, SequenceId, Tas
         return _TaskManagerFactory._build_manager()._is_submittable(entity)
     if isinstance(entity, str) and entity.startswith(Task._ID_PREFIX):
         return _TaskManagerFactory._build_manager()._is_submittable(TaskId(entity))
-    return False
+    return Reason(str(entity))._add_reason(str(entity), _Manager._build_not_submittable_entity_reason(str(entity)))
 
 
 def is_editable(
@@ -246,7 +247,7 @@ def submit(
             in asynchronous mode.
         timeout (Union[float, int]): The optional maximum number of seconds to wait
             for the jobs to be finished before returning.
-        **properties (dict[str, any]): A keyworded variable length list of user additional arguments
+        **properties (dict[str, any]): A key-worded variable length list of user additional arguments
             that will be stored within the `Submission^`. It can be accessed via `Submission.properties^`.
 
     Returns:
@@ -530,7 +531,7 @@ def get_scenarios(
             The default value is False.
         descending (bool): If True, sort the output list of scenarios in descending order.
             The default value is False.
-        sort_key (Literal["name", "id", "creation_date", "tags"]): The optiononal sort_key to
+        sort_key (Literal["name", "id", "creation_date", "tags"]): The optional sort_key to
             decide upon what key scenarios are sorted. The sorting is in increasing order for
             dates, in alphabetical order for name and id, and in lexicographical order for tags.
             The default value is "name".<br/>
@@ -582,7 +583,7 @@ def get_primary_scenarios(
             The default value is False.
         descending (bool): If True, sort the output list of scenarios in descending order.
             The default value is False.
-        sort_key (Literal["name", "id", "creation_date", "tags"]): The optiononal sort_key to
+        sort_key (Literal["name", "id", "creation_date", "tags"]): The optional sort_key to
             decide upon what key scenarios are sorted. The sorting is in increasing order for
             dates, in alphabetical order for name and id, and in lexicographical order for tags.
             The default value is "name".<br/>
@@ -605,7 +606,7 @@ def is_promotable(scenario: Union[Scenario, ScenarioId]) -> bool:
     as a primary scenario.
 
     Parameters:
-        scenario (Union[Scenario, ScenarioId]): The scenario to be evaluated for promotability.
+        scenario (Union[Scenario, ScenarioId]): The scenario to be evaluated for promotion.
 
     Returns:
         True if the given scenario can be promoted to be a primary scenario. False otherwise.
@@ -987,7 +988,7 @@ def export_scenario(
     override: bool = False,
     include_data: bool = False,
 ):
-    """Export all related entities of a scenario to a archive zip file.
+    """Export all related entities of a scenario to an archive zip file.
 
     This function exports all related entities of the specified scenario to the
     specified archive zip file.

+ 17 - 2
taipy/core/task/_task_manager.py

@@ -20,6 +20,7 @@ from .._orchestrator._abstract_orchestrator import _AbstractOrchestrator
 from .._repository._abstract_repository import _AbstractRepository
 from .._version._version_manager_factory import _VersionManagerFactory
 from .._version._version_mixin import _VersionMixin
+from ..common.reason import Reason
 from ..common.warn_if_inputs_not_ready import _warn_if_inputs_not_ready
 from ..config.task_config import TaskConfig
 from ..cycle.cycle_id import CycleId
@@ -163,10 +164,24 @@ class _TaskManager(_Manager[Task], _VersionMixin):
         return entity_ids
 
     @classmethod
-    def _is_submittable(cls, task: Union[Task, TaskId]) -> bool:
+    def _is_submittable(cls, task: Union[Task, TaskId]) -> Reason:
         if isinstance(task, str):
             task = cls._get(task)
-        return isinstance(task, Task) and all(input_dn.is_ready_for_reading for input_dn in task.input.values())
+        if not isinstance(task, Task):
+            task = str(task)
+            reason = Reason(task)
+            reason._add_reason(task, cls._build_not_submittable_entity_reason(task))
+        else:
+            reason = Reason(task.id)
+            data_manager = _DataManagerFactory._build_manager()
+            for node in task.input.values():
+                node = data_manager._get(node)
+                if node._edit_in_progress:
+                    reason._add_reason(node.id, node._build_edit_in_progress_reason())
+                if not node._last_edit_date:
+                    reason._add_reason(node.id, node._build_not_written_reason())
+
+        return reason
 
     @classmethod
     def _submit(

+ 34 - 14
tests/core/_entity/test_ready_to_run_property.py

@@ -14,6 +14,7 @@ from taipy import ScenarioId, SequenceId, TaskId
 from taipy.config.common.frequency import Frequency
 from taipy.config.config import Config
 from taipy.core._entity._ready_to_run_property import _ReadyToRunProperty
+from taipy.core.common.reason import Reason
 from taipy.core.scenario._scenario_manager_factory import _ScenarioManagerFactory
 from taipy.core.sequence._sequence_manager_factory import _SequenceManagerFactory
 from taipy.core.task._task_manager_factory import _TaskManagerFactory
@@ -32,6 +33,7 @@ def test_scenario_without_input_is_ready_to_run():
     scenario = scenario_manager._create(scenario_config)
 
     assert scenario_manager._is_submittable(scenario)
+    assert isinstance(scenario_manager._is_submittable(scenario), Reason)
     assert scenario.id not in _ReadyToRunProperty._submittable_id_datanodes
 
 
@@ -44,6 +46,7 @@ def test_scenario_submittable_with_inputs_is_ready_to_run():
     scenario = scenario_manager._create(scenario_config)
 
     assert scenario_manager._is_submittable(scenario)
+    assert isinstance(scenario_manager._is_submittable(scenario), Reason)
     assert scenario.id not in _ReadyToRunProperty._submittable_id_datanodes
 
 
@@ -58,7 +61,7 @@ def test_scenario_submittable_even_with_output_not_ready_to_run():
     dn_3 = scenario.dn_3
 
     assert not dn_3.is_ready_for_reading
-    assert scenario_manager._is_submittable(scenario)
+    assert isinstance(scenario_manager._is_submittable(scenario), Reason)
     assert scenario.id not in _ReadyToRunProperty._submittable_id_datanodes
 
 
@@ -75,6 +78,7 @@ def test_scenario_not_submittable_not_in_property_because_it_is_lazy():
     assert dn_1.is_ready_for_reading
     assert not dn_2.is_ready_for_reading
     assert not scenario_manager._is_submittable(scenario)
+    assert isinstance(scenario_manager._is_submittable(scenario), Reason)
 
     # Since it is a lazy property, the scenario and the datanodes is not yet in the dictionary
     assert scenario.id not in _ReadyToRunProperty._submittable_id_datanodes
@@ -93,14 +97,16 @@ def test_scenario_not_submittable_if_one_input_edit_in_progress():
 
     assert not dn_1.is_ready_for_reading
     assert not scenario_manager._is_submittable(scenario)
+    assert isinstance(scenario_manager._is_submittable(scenario), Reason)
 
     assert scenario.id in _ReadyToRunProperty._submittable_id_datanodes
-    assert dn_1.id in _ReadyToRunProperty._submittable_id_datanodes[scenario.id]
+    assert dn_1.id in _ReadyToRunProperty._submittable_id_datanodes[scenario.id]._reasons
     assert dn_1.id in _ReadyToRunProperty._datanode_id_submittables
     assert scenario.id in _ReadyToRunProperty._datanode_id_submittables[dn_1.id]
-    assert _ReadyToRunProperty._submittable_id_datanodes[scenario.id][dn_1.id] == {
+    assert _ReadyToRunProperty._submittable_id_datanodes[scenario.id]._reasons[dn_1.id] == {
         f"DataNode {dn_1.id} is being edited"
     }
+    assert _ReadyToRunProperty._submittable_id_datanodes[scenario.id].reasons == f"DataNode {dn_1.id} is being edited."
 
 
 def test_scenario_not_submittable_for_multiple_reasons():
@@ -119,21 +125,25 @@ def test_scenario_not_submittable_for_multiple_reasons():
     assert not dn_1.is_ready_for_reading
     assert not dn_2.is_ready_for_reading
     assert not scenario_manager._is_submittable(scenario)
+    assert isinstance(scenario_manager._is_submittable(scenario), Reason)
 
     assert scenario.id in _ReadyToRunProperty._submittable_id_datanodes
-    assert dn_1.id in _ReadyToRunProperty._submittable_id_datanodes[scenario.id]
-    assert dn_2.id in _ReadyToRunProperty._submittable_id_datanodes[scenario.id]
+    assert dn_1.id in _ReadyToRunProperty._submittable_id_datanodes[scenario.id]._reasons
+    assert dn_2.id in _ReadyToRunProperty._submittable_id_datanodes[scenario.id]._reasons
     assert dn_1.id in _ReadyToRunProperty._datanode_id_submittables
     assert dn_2.id in _ReadyToRunProperty._datanode_id_submittables
     assert scenario.id in _ReadyToRunProperty._datanode_id_submittables[dn_1.id]
-    assert _ReadyToRunProperty._submittable_id_datanodes[scenario.id][dn_1.id] == {
+    assert _ReadyToRunProperty._submittable_id_datanodes[scenario.id]._reasons[dn_1.id] == {
         f"DataNode {dn_1.id} is being edited"
     }
     assert scenario.id in _ReadyToRunProperty._datanode_id_submittables[dn_2.id]
-    assert _ReadyToRunProperty._submittable_id_datanodes[scenario.id][dn_2.id] == {
+    assert _ReadyToRunProperty._submittable_id_datanodes[scenario.id]._reasons[dn_2.id] == {
         f"DataNode {dn_2.id} is being edited",
         f"DataNode {dn_2.id} is not written",
     }
+    reason_str = _ReadyToRunProperty._submittable_id_datanodes[scenario.id].reasons
+    assert f"DataNode {dn_2.id} is being edited" in reason_str
+    assert f"DataNode {dn_2.id} is not written" in reason_str
 
 
 def test_writing_input_remove_reasons():
@@ -146,17 +156,22 @@ def test_writing_input_remove_reasons():
 
     assert not dn_1.is_ready_for_reading
     assert not scenario_manager._is_submittable(scenario)
+    assert isinstance(scenario_manager._is_submittable(scenario), Reason)
     # Since it is a lazy property, the scenario is not yet in the dictionary
     assert scenario.id not in _ReadyToRunProperty._submittable_id_datanodes
 
     dn_1.lock_edit()
-    assert _ReadyToRunProperty._submittable_id_datanodes[scenario.id][dn_1.id] == {
+    assert _ReadyToRunProperty._submittable_id_datanodes[scenario.id]._reasons[dn_1.id] == {
         f"DataNode {dn_1.id} is being edited",
         f"DataNode {dn_1.id} is not written",
     }
+    reason_str = _ReadyToRunProperty._submittable_id_datanodes[scenario.id].reasons
+    assert f"DataNode {dn_1.id} is being edited" in reason_str
+    assert f"DataNode {dn_1.id} is not written" in reason_str
 
     dn_1.write(10)
     assert scenario_manager._is_submittable(scenario)
+    assert isinstance(scenario_manager._is_submittable(scenario), Reason)
     assert scenario.id not in _ReadyToRunProperty._submittable_id_datanodes
     assert dn_1.id not in _ReadyToRunProperty._datanode_id_submittables
 
@@ -165,20 +180,25 @@ def identity(arg):
     return arg
 
 
-def __assert_not_submittable_becomes_submittable_when_dn_edited(sequence, manager, dn):
+def __assert_not_submittable_becomes_submittable_when_dn_edited(entity, manager, dn):
     assert not dn.is_ready_for_reading
-    assert not manager._is_submittable(sequence)
+    assert not manager._is_submittable(entity)
     # Since it is a lazy property, the sequence is not yet in the dictionary
-    assert sequence.id not in _ReadyToRunProperty._submittable_id_datanodes
+    assert entity.id not in _ReadyToRunProperty._submittable_id_datanodes
 
     dn.lock_edit()
-    assert _ReadyToRunProperty._submittable_id_datanodes[sequence.id][dn.id] == {
+    assert _ReadyToRunProperty._submittable_id_datanodes[entity.id]._reasons[dn.id] == {
         f"DataNode {dn.id} is being edited",
         f"DataNode {dn.id} is not written",
     }
+    reason_str = _ReadyToRunProperty._submittable_id_datanodes[entity.id].reasons
+    assert f"DataNode {dn.id} is being edited" in reason_str
+    assert f"DataNode {dn.id} is not written" in reason_str
+
     dn.write("ANY VALUE")
-    assert manager._is_submittable(sequence)
-    assert sequence.id not in _ReadyToRunProperty._submittable_id_datanodes
+    assert manager._is_submittable(entity)
+    assert isinstance(manager._is_submittable(entity), Reason)
+    assert entity.id not in _ReadyToRunProperty._submittable_id_datanodes
     assert dn.id not in _ReadyToRunProperty._datanode_id_submittables
 
 

+ 69 - 0
tests/core/common/test_reason.py

@@ -0,0 +1,69 @@
+# 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 taipy.core.common.reason import Reason
+
+
+def test_create_reason():
+    reason = Reason("entity_id")
+    assert reason.entity_id == "entity_id"
+    assert reason._reasons == {}
+    assert reason
+    assert not reason._entity_id_exists_in_reason("entity_id")
+    assert reason.reasons == ""
+
+
+def test_add_and_remove_reason():
+    reason = Reason("entity_id")
+    reason._add_reason("entity_id_1", "Some reason")
+    assert reason._reasons == {"entity_id_1": {"Some reason"}}
+    assert not reason
+    assert reason._entity_id_exists_in_reason("entity_id_1")
+    assert reason.reasons == "Some reason."
+
+    reason._add_reason("entity_id_1", "Another reason")
+    reason._add_reason("entity_id_2", "Some more reason")
+    assert reason._reasons == {"entity_id_1": {"Some reason", "Another reason"}, "entity_id_2": {"Some more reason"}}
+    assert not reason
+    assert reason._entity_id_exists_in_reason("entity_id_1")
+    assert reason._entity_id_exists_in_reason("entity_id_2")
+
+    reason._remove_reason("entity_id_1", "Some reason")
+    assert reason._reasons == {"entity_id_1": {"Another reason"}, "entity_id_2": {"Some more reason"}}
+    assert not reason
+    assert reason._entity_id_exists_in_reason("entity_id_1")
+    assert reason._entity_id_exists_in_reason("entity_id_2")
+
+    reason._remove_reason("entity_id_2", "Some more reason")
+    assert reason._reasons == {"entity_id_1": {"Another reason"}}
+    assert not reason
+    assert reason._entity_id_exists_in_reason("entity_id_1")
+    assert not reason._entity_id_exists_in_reason("entity_id_2")
+
+    reason._remove_reason("entity_id_1", "Another reason")
+    assert reason._reasons == {}
+    assert reason
+    assert not reason._entity_id_exists_in_reason("entity_id_1")
+
+
+def test_get_reason_string_from_reason():
+    reason = Reason("entity_id")
+    reason._add_reason("entity_id_1", "Some reason")
+    assert reason.reasons == "Some reason."
+
+    reason._add_reason("entity_id_2", "Some more reason")
+    assert reason.reasons == "Some reason; Some more reason."
+
+    reason._add_reason("entity_id_1", "Another reason")
+    assert reason.reasons.count(";") == 2
+    assert "Some reason" in reason.reasons
+    assert "Another reason" in reason.reasons
+    assert "Some more reason" in reason.reasons