Forráskód Böngészése

Merge pull request #1459 from Avaiga/feature/reasons-refacto

Reason class refactoring
Toan Quach 10 hónapja
szülő
commit
b2cd473c4f

+ 6 - 6
taipy/core/_entity/_ready_to_run_property.py

@@ -12,7 +12,7 @@
 from typing import TYPE_CHECKING, Dict, Set, Union
 
 from ..notification import EventOperation, Notifier, _make_event
-from ..reason.reason import Reasons
+from ..reason import Reason, ReasonCollection
 
 if TYPE_CHECKING:
     from ..data.data_node import DataNode, DataNodeId
@@ -29,10 +29,10 @@ 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"], Reasons] = {}
+    _submittable_id_datanodes: Dict[Union["ScenarioId", "SequenceId", "TaskId"], ReasonCollection] = {}
 
     @classmethod
-    def _add(cls, dn: "DataNode", reason: str) -> None:
+    def _add(cls, dn: "DataNode", reason: Reason) -> None:
         from ..scenario.scenario import Scenario
         from ..sequence.sequence import Sequence
         from ..task.task import Task
@@ -50,7 +50,7 @@ class _ReadyToRunProperty:
                 cls.__add(task_parent, dn, reason)
 
     @classmethod
-    def _remove(cls, datanode: "DataNode", reason: str) -> None:
+    def _remove(cls, datanode: "DataNode", reason: Reason) -> None:
         from ..taipy import get as tp_get
 
         # check the data node status to determine the reason to be removed
@@ -72,7 +72,7 @@ class _ReadyToRunProperty:
             cls._datanode_id_submittables.pop(datanode.id)
 
     @classmethod
-    def __add(cls, submittable: Union["Scenario", "Sequence", "Task"], datanode: "DataNode", reason: str) -> None:
+    def __add(cls, submittable: Union["Scenario", "Sequence", "Task"], datanode: "DataNode", reason: Reason) -> None:
         if datanode.id not in cls._datanode_id_submittables:
             cls._datanode_id_submittables[datanode.id] = set()
         cls._datanode_id_submittables[datanode.id].add(submittable.id)
@@ -81,7 +81,7 @@ class _ReadyToRunProperty:
             cls.__publish_submittable_property_event(submittable, False)
 
         if submittable.id not in cls._submittable_id_datanodes:
-            cls._submittable_id_datanodes[submittable.id] = Reasons(submittable.id)
+            cls._submittable_id_datanodes[submittable.id] = ReasonCollection()
         cls._submittable_id_datanodes[submittable.id]._add_reason(datanode.id, reason)
 
     @staticmethod

+ 6 - 7
taipy/core/_entity/submittable.py

@@ -19,8 +19,7 @@ from ..common._listattributes import _ListAttributes
 from ..common._utils import _Subscriber
 from ..data.data_node import DataNode
 from ..job.job import Job
-from ..reason._reason_factory import _build_data_node_is_being_edited_reason, _build_data_node_is_not_written
-from ..reason.reason import Reasons
+from ..reason import DataNodeEditInProgress, DataNodeIsNotWritten, ReasonCollection
 from ..submission.submission import Submission
 from ..task.task import Task
 from ._dag import _DAG
@@ -83,22 +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) -> Reasons:
+    def is_ready_to_run(self) -> ReasonCollection:
         """Indicate if the entity is ready to be run.
 
         Returns:
             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.
         """
-        reason = Reasons(self._submittable_id)
+        reason_collection = ReasonCollection()
 
         for node in self.get_inputs():
             if node._edit_in_progress:
-                reason._add_reason(node.id, _build_data_node_is_being_edited_reason(node.id))
+                reason_collection._add_reason(node.id, DataNodeEditInProgress(node.id))
             if not node._last_edit_date:
-                reason._add_reason(node.id, _build_data_node_is_not_written(node.id))
+                reason_collection._add_reason(node.id, DataNodeIsNotWritten(node.id))
 
-        return reason
+        return reason_collection
 
     def data_nodes_being_edited(self) -> Set[DataNode]:
         """Return the set of data nodes of the submittable entity that are being edited.

+ 6 - 7
taipy/core/data/_data_manager.py

@@ -22,8 +22,7 @@ from ..config.data_node_config import DataNodeConfig
 from ..cycle.cycle_id import CycleId
 from ..exceptions.exceptions import InvalidDataNodeType
 from ..notification import Event, EventEntityType, EventOperation, Notifier, _make_event
-from ..reason._reason_factory import _build_not_global_scope_reason, _build_wrong_config_type_reason
-from ..reason.reason import Reasons
+from ..reason import NotGlobalScope, ReasonCollection, WrongConfigType
 from ..scenario.scenario_id import ScenarioId
 from ..sequence.sequence_id import SequenceId
 from ._data_fs_repository import _DataFSRepository
@@ -69,17 +68,17 @@ class _DataManager(_Manager[DataNode], _VersionMixin):
         }
 
     @classmethod
-    def _can_create(cls, config: Optional[DataNodeConfig] = None) -> Reasons:
+    def _can_create(cls, config: Optional[DataNodeConfig] = None) -> ReasonCollection:
         config_id = getattr(config, "id", None) or str(config)
-        reason = Reasons(config_id)
+        reason_collection = ReasonCollection()
 
         if config is not None:
             if not isinstance(config, DataNodeConfig):
-                reason._add_reason(config_id, _build_wrong_config_type_reason(config_id, "DataNodeConfig"))
+                reason_collection._add_reason(config_id, WrongConfigType(config_id, DataNodeConfig.__name__))
             elif config.scope is not Scope.GLOBAL:
-                reason._add_reason(config_id, _build_not_global_scope_reason(config_id))
+                reason_collection._add_reason(config_id, NotGlobalScope(config_id))
 
-        return reason
+        return reason_collection
 
     @classmethod
     def _create_and_set(

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

@@ -32,6 +32,7 @@ from ..common._warnings import _warn_deprecated
 from ..exceptions.exceptions import DataNodeIsBeingEdited, NoData
 from ..job.job_id import JobId
 from ..notification.event import Event, EventEntityType, EventOperation, _make_event
+from ..reason import DataNodeEditInProgress, DataNodeIsNotWritten
 from ._filter import _FilterDataNode
 from .data_node_id import DataNodeId, Edit
 from .operator import JoinOperator
@@ -43,13 +44,13 @@ def _update_ready_for_reading(fct):
     def _recompute_is_ready_for_reading(dn: "DataNode", *args, **kwargs):
         fct(dn, *args, **kwargs)
         if dn._edit_in_progress:
-            _ReadyToRunProperty._add(dn, f"DataNode {dn.id} is being edited")
+            _ReadyToRunProperty._add(dn, DataNodeEditInProgress(dn.id))
         else:
-            _ReadyToRunProperty._remove(dn, f"DataNode {dn.id} is being edited")
+            _ReadyToRunProperty._remove(dn, DataNodeEditInProgress(dn.id))
         if not dn._last_edit_date:
-            _ReadyToRunProperty._add(dn, f"DataNode {dn.id} is not written")
+            _ReadyToRunProperty._add(dn, DataNodeIsNotWritten(dn.id))
         else:
-            _ReadyToRunProperty._remove(dn, f"DataNode {dn.id} is not written")
+            _ReadyToRunProperty._remove(dn, DataNodeIsNotWritten(dn.id))
 
     return _recompute_is_ready_for_reading
 
@@ -396,7 +397,7 @@ class DataNode(_Entity, _Labeled):
             return self.read_or_raise()
         except NoData:
             self.__logger.warning(
-                f"Data node {self.id} from config {self.config_id} is being read but has never been " f"written."
+                f"Data node {self.id} from config {self.config_id} is being read but has never been written."
             )
             return None
 

+ 9 - 1
taipy/core/reason/__init__.py

@@ -9,4 +9,12 @@
 # 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 .reason import Reasons
+from .reason import (
+    DataNodeEditInProgress,
+    DataNodeIsNotWritten,
+    EntityIsNotSubmittableEntity,
+    NotGlobalScope,
+    Reason,
+    WrongConfigType,
+)
+from .reason_collection import ReasonCollection

+ 0 - 37
taipy/core/reason/_reason_factory.py

@@ -1,37 +0,0 @@
-# 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 Optional
-
-from ..data.data_node import DataNodeId
-
-
-def _build_data_node_is_being_edited_reason(dn_id: DataNodeId) -> str:
-    return f"DataNode {dn_id} is being edited"
-
-
-def _build_data_node_is_not_written(dn_id: DataNodeId) -> str:
-    return f"DataNode {dn_id} is not written"
-
-
-def _build_not_submittable_entity_reason(entity_id: str) -> str:
-    return f"Entity {entity_id} is not a submittable entity"
-
-
-def _build_wrong_config_type_reason(config_id: str, config_type: Optional[str]) -> str:
-    if config_type:
-        return f'Object "{config_id}" must be a valid {config_type}'
-
-    return f'Object "{config_id}" is not a valid config to be created'
-
-
-def _build_not_global_scope_reason(config_id: str) -> str:
-    return f'Data node config "{config_id}" does not have GLOBAL scope'

+ 106 - 22
taipy/core/reason/reason.py

@@ -9,33 +9,117 @@
 # 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
+from typing import Any, Optional
 
 
-class Reasons:
-    def __init__(self, entity_id: str) -> None:
-        self.entity_id: str = entity_id
-        self._reasons: Dict[str, Set[str]] = {}
+class Reason:
+    """
+    A reason explains why a specific action cannot be performed.
 
-    def _add_reason(self, entity_id: str, reason: str) -> "Reasons":
-        if entity_id not in self._reasons:
-            self._reasons[entity_id] = set()
-        self._reasons[entity_id].add(reason)
-        return self
+    This is a parent class aiming at being implemented by specific sub-classes.
 
-    def _remove_reason(self, entity_id: str, reason: str) -> "Reasons":
-        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
+    Because Taipy applications are natively multiuser, asynchronous, and dynamic,
+    some functions might not be called in some specific contexts. You can protect
+    such calls by calling other methods that return a Reasons object. It acts like a
+    boolean: True if the operation can be performed and False otherwise.
+    If the action cannot be performed, the Reasons object holds all the `reasons as a list
+    of `Reason` objects. Each `Reason` holds an explanation of why the operation cannot be
+    performed.
 
-    def _entity_id_exists_in_reason(self, entity_id: str) -> bool:
-        return entity_id in self._reasons
+    Attributes:
+        reason (str): The English representation of the reason why the action cannot be performed.
+    """
 
-    def __bool__(self) -> bool:
-        return len(self._reasons) == 0
+    def __init__(self, reason: str):
+        self._reason = reason
+
+    def __str__(self) -> str:
+        return self._reason
+
+    def __repr__(self) -> str:
+        return self._reason
+
+    def __hash__(self) -> int:
+        return hash(self._reason)
+
+    def __eq__(self, value: Any) -> bool:
+        return isinstance(value, Reason) and value._reason == self._reason
+
+
+class _DataNodeReasonMixin:
+    def __init__(self, datanode_id: str):
+        self.datanode_id = datanode_id
 
     @property
-    def reasons(self) -> str:
-        return "; ".join("; ".join(reason) for reason in self._reasons.values()) + "." if self._reasons else ""
+    def datanode(self):
+        from ..data._data_manager_factory import _DataManagerFactory
+
+        return _DataManagerFactory._build_manager()._get(self.datanode_id)
+
+
+class DataNodeEditInProgress(Reason, _DataNodeReasonMixin):
+    """
+    A `DataNode^` is being edited, which prevents specific actions from being performed.
+
+    Attributes:
+        datanode_id (str): The identifier of the `DataNode^`.
+    """
+
+    def __init__(self, datanode_id: str):
+        Reason.__init__(self, f"DataNode {datanode_id} is being edited")
+        _DataNodeReasonMixin.__init__(self, datanode_id)
+
+
+class DataNodeIsNotWritten(Reason, _DataNodeReasonMixin):
+    """
+    A `DataNode^` has never been written, which prevents specific actions from being performed.
+
+    Attributes:
+        datanode_id (str): The identifier of the `DataNode^`.
+    """
+
+    def __init__(self, datanode_id: str):
+        Reason.__init__(self, f"DataNode {datanode_id} is not written")
+        _DataNodeReasonMixin.__init__(self, datanode_id)
+
+
+class EntityIsNotSubmittableEntity(Reason):
+    """
+    An entity is not a submittable entity, which prevents specific actions from being performed.
+
+    Attributes:
+        entity_id (str): The identifier of the `Entity^`.
+    """
+
+    def __init__(self, entity_id: str):
+        Reason.__init__(self, f"Entity {entity_id} is not a submittable entity")
+
+
+class WrongConfigType(Reason):
+    """
+    A config id is not a valid expected config, which prevents specific actions from being performed.
+
+    Attributes:
+        config_id (str): The identifier of the config.
+        config_type (str): The expected config type.
+    """
+
+    def __init__(self, config_id: str, config_type: Optional[str]):
+        if config_type:
+            reason = f'Object "{config_id}" must be a valid {config_type}'
+        else:
+            reason = f'Object "{config_id}" is not a valid config to be created'
+
+        Reason.__init__(self, reason)
+
+
+class NotGlobalScope(Reason):
+    """
+    A data node config does not have a GLOBAL scope, which prevents specific actions from being performed.
+
+    Attributes:
+        config_id (str): The identifier of the config.
+    """
+
+    def __init__(self, config_id: str):
+        Reason.__init__(self, f'Data node config "{config_id}" does not have GLOBAL scope')

+ 60 - 0
taipy/core/reason/reason_collection.py

@@ -0,0 +1,60 @@
+# 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
+
+from .reason import Reason
+
+
+class ReasonCollection:
+    """
+    This class is used to store all the reasons to explain why some Taipy operations are not allowed.
+
+    Because Taipy applications are natively multiuser, asynchronous, and dynamic,
+    some functions might not be called in some specific contexts. You can protect
+    such calls by calling other methods that return a `ReasonCollection`. It acts like a
+    boolean: True if the operation can be performed and False otherwise.
+    If the action cannot be performed, the ReasonCollection holds all the individual reasons as a list
+    of `Reason` objects. Each `Reason` explains why the operation cannot be performed.
+    """
+
+    def __init__(self) -> None:
+        self._reasons: Dict[str, Set[Reason]] = {}
+
+    def _add_reason(self, entity_id: str, reason: Reason) -> "ReasonCollection":
+        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: Reason) -> "ReasonCollection":
+        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:
+        """Retrieves a collection of reasons as a string that explains why the action cannot be performed.
+
+        Returns:
+            A string that contains all the reasons why the action cannot be performed.
+        """
+        if self._reasons:
+            return "; ".join("; ".join([str(reason) for reason in reasons]) for reasons in self._reasons.values()) + "."
+        return ""

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

@@ -39,8 +39,7 @@ from ..exceptions.exceptions import (
 from ..job._job_manager_factory import _JobManagerFactory
 from ..job.job import Job
 from ..notification import EventEntityType, EventOperation, Notifier, _make_event
-from ..reason._reason_factory import _build_not_submittable_entity_reason, _build_wrong_config_type_reason
-from ..reason.reason import Reasons
+from ..reason import EntityIsNotSubmittableEntity, ReasonCollection, WrongConfigType
 from ..submission._submission_manager_factory import _SubmissionManagerFactory
 from ..submission.submission import Submission
 from ..task._task_manager_factory import _TaskManagerFactory
@@ -108,15 +107,15 @@ class _ScenarioManager(_Manager[Scenario], _VersionMixin):
         )
 
     @classmethod
-    def _can_create(cls, config: Optional[ScenarioConfig] = None) -> Reasons:
+    def _can_create(cls, config: Optional[ScenarioConfig] = None) -> ReasonCollection:
         config_id = getattr(config, "id", None) or str(config)
-        reason = Reasons(config_id)
+        reason_collector = ReasonCollection()
 
         if config is not None:
             if not isinstance(config, ScenarioConfig):
-                reason._add_reason(config_id, _build_wrong_config_type_reason(config_id, "ScenarioConfig"))
+                reason_collector._add_reason(config_id, WrongConfigType(config_id, ScenarioConfig.__name__))
 
-        return reason
+        return reason_collector
 
     @classmethod
     def _create(
@@ -202,15 +201,15 @@ class _ScenarioManager(_Manager[Scenario], _VersionMixin):
         return scenario
 
     @classmethod
-    def _is_submittable(cls, scenario: Union[Scenario, ScenarioId]) -> Reasons:
+    def _is_submittable(cls, scenario: Union[Scenario, ScenarioId]) -> ReasonCollection:
         if isinstance(scenario, str):
             scenario = cls._get(scenario)
 
         if not isinstance(scenario, Scenario):
             scenario = str(scenario)
-            reason = Reasons((scenario))
-            reason._add_reason(scenario, _build_not_submittable_entity_reason(scenario))
-            return reason
+            reason_collector = ReasonCollection()
+            reason_collector._add_reason(scenario, EntityIsNotSubmittableEntity(scenario))
+            return reason_collector
 
         return scenario.is_ready_to_run()
 

+ 5 - 6
taipy/core/sequence/_sequence_manager.py

@@ -29,8 +29,7 @@ from ..job._job_manager_factory import _JobManagerFactory
 from ..job.job import Job
 from ..notification import Event, EventEntityType, EventOperation, Notifier
 from ..notification.event import _make_event
-from ..reason._reason_factory import _build_not_submittable_entity_reason
-from ..reason.reason import Reasons
+from ..reason import EntityIsNotSubmittableEntity, ReasonCollection
 from ..scenario._scenario_manager_factory import _ScenarioManagerFactory
 from ..scenario.scenario import Scenario
 from ..scenario.scenario_id import ScenarioId
@@ -342,15 +341,15 @@ class _SequenceManager(_Manager[Sequence], _VersionMixin):
         Notifier.publish(_make_event(sequence, EventOperation.UPDATE, attribute_name="subscribers"))
 
     @classmethod
-    def _is_submittable(cls, sequence: Union[Sequence, SequenceId]) -> Reasons:
+    def _is_submittable(cls, sequence: Union[Sequence, SequenceId]) -> ReasonCollection:
         if isinstance(sequence, str):
             sequence = cls._get(sequence)
 
         if not isinstance(sequence, Sequence):
             sequence = str(sequence)
-            reason = Reasons(sequence)
-            reason._add_reason(sequence, _build_not_submittable_entity_reason(sequence))
-            return reason
+            reason_collector = ReasonCollection()
+            reason_collector._add_reason(sequence, EntityIsNotSubmittableEntity(sequence))
+            return reason_collector
 
         return sequence.is_ready_to_run()
 

+ 4 - 5
taipy/core/taipy.py

@@ -45,8 +45,7 @@ from .exceptions.exceptions import (
 from .job._job_manager_factory import _JobManagerFactory
 from .job.job import Job
 from .job.job_id import JobId
-from .reason._reason_factory import _build_not_submittable_entity_reason
-from .reason.reason import Reasons
+from .reason import EntityIsNotSubmittableEntity, ReasonCollection
 from .scenario._scenario_manager_factory import _ScenarioManagerFactory
 from .scenario.scenario import Scenario
 from .scenario.scenario_id import ScenarioId
@@ -85,7 +84,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]) -> Reasons:
+def is_submittable(entity: Union[Scenario, ScenarioId, Sequence, SequenceId, Task, TaskId, str]) -> ReasonCollection:
     """Indicate if an entity can be submitted.
 
     This function checks if the given entity can be submitted for execution.
@@ -105,7 +104,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 Reasons(str(entity))._add_reason(str(entity), _build_not_submittable_entity_reason(str(entity)))
+    return ReasonCollection()._add_reason(str(entity), EntityIsNotSubmittableEntity(str(entity)))
 
 
 def is_editable(
@@ -880,7 +879,7 @@ def get_cycles() -> List[Cycle]:
     return _CycleManagerFactory._build_manager()._get_all()
 
 
-def can_create(config: Optional[Union[ScenarioConfig, DataNodeConfig]] = None) -> Reasons:
+def can_create(config: Optional[Union[ScenarioConfig, DataNodeConfig]] = None) -> ReasonCollection:
     """Indicate if a config can be created. The config should be a scenario or data node config.
 
     If no config is provided, the function indicates if any scenario or data node config can be created.

+ 8 - 13
taipy/core/task/_task_manager.py

@@ -26,12 +26,7 @@ from ..cycle.cycle_id import CycleId
 from ..data._data_manager_factory import _DataManagerFactory
 from ..exceptions.exceptions import NonExistingTask
 from ..notification import EventEntityType, EventOperation, Notifier, _make_event
-from ..reason._reason_factory import (
-    _build_data_node_is_being_edited_reason,
-    _build_data_node_is_not_written,
-    _build_not_submittable_entity_reason,
-)
-from ..reason.reason import Reasons
+from ..reason import DataNodeEditInProgress, DataNodeIsNotWritten, EntityIsNotSubmittableEntity, ReasonCollection
 from ..scenario.scenario_id import ScenarioId
 from ..sequence.sequence_id import SequenceId
 from ..submission.submission import Submission
@@ -169,24 +164,24 @@ class _TaskManager(_Manager[Task], _VersionMixin):
         return entity_ids
 
     @classmethod
-    def _is_submittable(cls, task: Union[Task, TaskId]) -> Reasons:
+    def _is_submittable(cls, task: Union[Task, TaskId]) -> ReasonCollection:
         if isinstance(task, str):
             task = cls._get(task)
+
+        reason_collection = ReasonCollection()
         if not isinstance(task, Task):
             task = str(task)
-            reason = Reasons(task)
-            reason._add_reason(task, _build_not_submittable_entity_reason(task))
+            reason_collection._add_reason(task, EntityIsNotSubmittableEntity(task))
         else:
-            reason = Reasons(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, _build_data_node_is_being_edited_reason(node.id))
+                    reason_collection._add_reason(node.id, DataNodeEditInProgress(node.id))
                 if not node._last_edit_date:
-                    reason._add_reason(node.id, _build_data_node_is_not_written(node.id))
+                    reason_collection._add_reason(node.id, DataNodeIsNotWritten(node.id))
 
-        return reason
+        return reason_collection
 
     @classmethod
     def _submit(

+ 18 - 18
tests/core/_entity/test_ready_to_run_property.py

@@ -14,7 +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.reason.reason import Reasons
+from taipy.core.reason import DataNodeEditInProgress, DataNodeIsNotWritten, ReasonCollection
 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
@@ -33,7 +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), Reasons)
+    assert isinstance(scenario_manager._is_submittable(scenario), ReasonCollection)
     assert scenario.id not in _ReadyToRunProperty._submittable_id_datanodes
 
 
@@ -46,7 +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), Reasons)
+    assert isinstance(scenario_manager._is_submittable(scenario), ReasonCollection)
     assert scenario.id not in _ReadyToRunProperty._submittable_id_datanodes
 
 
@@ -61,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 isinstance(scenario_manager._is_submittable(scenario), Reasons)
+    assert isinstance(scenario_manager._is_submittable(scenario), ReasonCollection)
     assert scenario.id not in _ReadyToRunProperty._submittable_id_datanodes
 
 
@@ -78,7 +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), Reasons)
+    assert isinstance(scenario_manager._is_submittable(scenario), ReasonCollection)
 
     # 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
@@ -97,14 +97,14 @@ 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), Reasons)
+    assert isinstance(scenario_manager._is_submittable(scenario), ReasonCollection)
 
     assert scenario.id in _ReadyToRunProperty._submittable_id_datanodes
     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]._reasons[dn_1.id] == {
-        f"DataNode {dn_1.id} is being edited"
+        DataNodeEditInProgress(dn_1.id)
     }
     assert _ReadyToRunProperty._submittable_id_datanodes[scenario.id].reasons == f"DataNode {dn_1.id} is being edited."
 
@@ -125,7 +125,7 @@ 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), Reasons)
+    assert isinstance(scenario_manager._is_submittable(scenario), ReasonCollection)
 
     assert scenario.id in _ReadyToRunProperty._submittable_id_datanodes
     assert dn_1.id in _ReadyToRunProperty._submittable_id_datanodes[scenario.id]._reasons
@@ -134,12 +134,12 @@ def test_scenario_not_submittable_for_multiple_reasons():
     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]._reasons[dn_1.id] == {
-        f"DataNode {dn_1.id} is being edited"
+        DataNodeEditInProgress(dn_1.id)
     }
     assert scenario.id in _ReadyToRunProperty._datanode_id_submittables[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",
+        DataNodeEditInProgress(dn_2.id),
+        DataNodeIsNotWritten(dn_2.id),
     }
     reason_str = _ReadyToRunProperty._submittable_id_datanodes[scenario.id].reasons
     assert f"DataNode {dn_2.id} is being edited" in reason_str
@@ -156,14 +156,14 @@ 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), Reasons)
+    assert isinstance(scenario_manager._is_submittable(scenario), ReasonCollection)
     # 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]._reasons[dn_1.id] == {
-        f"DataNode {dn_1.id} is being edited",
-        f"DataNode {dn_1.id} is not written",
+        DataNodeEditInProgress(dn_1.id),
+        DataNodeIsNotWritten(dn_1.id),
     }
     reason_str = _ReadyToRunProperty._submittable_id_datanodes[scenario.id].reasons
     assert f"DataNode {dn_1.id} is being edited" in reason_str
@@ -171,7 +171,7 @@ def test_writing_input_remove_reasons():
 
     dn_1.write(10)
     assert scenario_manager._is_submittable(scenario)
-    assert isinstance(scenario_manager._is_submittable(scenario), Reasons)
+    assert isinstance(scenario_manager._is_submittable(scenario), ReasonCollection)
     assert scenario.id not in _ReadyToRunProperty._submittable_id_datanodes
     assert dn_1.id not in _ReadyToRunProperty._datanode_id_submittables
 
@@ -188,8 +188,8 @@ def __assert_not_submittable_becomes_submittable_when_dn_edited(entity, manager,
 
     dn.lock_edit()
     assert _ReadyToRunProperty._submittable_id_datanodes[entity.id]._reasons[dn.id] == {
-        f"DataNode {dn.id} is being edited",
-        f"DataNode {dn.id} is not written",
+        DataNodeEditInProgress(dn.id),
+        DataNodeIsNotWritten(dn.id),
     }
     reason_str = _ReadyToRunProperty._submittable_id_datanodes[entity.id].reasons
     assert f"DataNode {dn.id} is being edited" in reason_str
@@ -197,7 +197,7 @@ def __assert_not_submittable_becomes_submittable_when_dn_edited(entity, manager,
 
     dn.write("ANY VALUE")
     assert manager._is_submittable(entity)
-    assert isinstance(manager._is_submittable(entity), Reasons)
+    assert isinstance(manager._is_submittable(entity), ReasonCollection)
     assert entity.id not in _ReadyToRunProperty._submittable_id_datanodes
     assert dn.id not in _ReadyToRunProperty._datanode_id_submittables
 

+ 45 - 43
tests/core/common/test_reason.py

@@ -9,61 +9,63 @@
 # 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.reason.reason import Reasons
+from taipy.core.reason import ReasonCollection
 
 
 def test_create_reason():
-    reason = Reasons("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 == ""
+    reason_collection = ReasonCollection()
+    assert reason_collection._reasons == {}
+    assert reason_collection
+    assert not reason_collection._entity_id_exists_in_reason("entity_id")
+    assert reason_collection.reasons == ""
 
 
 def test_add_and_remove_reason():
-    reason = Reasons("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_collection = ReasonCollection()
+    reason_collection._add_reason("entity_id_1", "Some reason")
+    assert reason_collection._reasons == {"entity_id_1": {"Some reason"}}
+    assert not reason_collection
+    assert reason_collection._entity_id_exists_in_reason("entity_id_1")
+    assert reason_collection.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_collection._add_reason("entity_id_1", "Another reason")
+    reason_collection._add_reason("entity_id_2", "Some more reason")
+    assert reason_collection._reasons == {
+        "entity_id_1": {"Some reason", "Another reason"},
+        "entity_id_2": {"Some more reason"},
+    }
+    assert not reason_collection
+    assert reason_collection._entity_id_exists_in_reason("entity_id_1")
+    assert reason_collection._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_collection._remove_reason("entity_id_1", "Some reason")
+    assert reason_collection._reasons == {"entity_id_1": {"Another reason"}, "entity_id_2": {"Some more reason"}}
+    assert not reason_collection
+    assert reason_collection._entity_id_exists_in_reason("entity_id_1")
+    assert reason_collection._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_collection._remove_reason("entity_id_2", "Some more reason")
+    assert reason_collection._reasons == {"entity_id_1": {"Another reason"}}
+    assert not reason_collection
+    assert reason_collection._entity_id_exists_in_reason("entity_id_1")
+    assert not reason_collection._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")
+    reason_collection._remove_reason("entity_id_1", "Another reason")
+    assert reason_collection._reasons == {}
+    assert reason_collection
+    assert not reason_collection._entity_id_exists_in_reason("entity_id_1")
 
 
 def test_get_reason_string_from_reason():
-    reason = Reasons("entity_id")
-    reason._add_reason("entity_id_1", "Some reason")
-    assert reason.reasons == "Some reason."
+    reason_collection = ReasonCollection()
+    reason_collection._add_reason("entity_id_1", "Some reason")
+    assert reason_collection.reasons == "Some reason."
 
-    reason._add_reason("entity_id_2", "Some more reason")
-    assert reason.reasons == "Some reason; Some more reason."
+    reason_collection._add_reason("entity_id_2", "Some more reason")
+    assert reason_collection.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
+    reason_collection._add_reason("entity_id_1", "Another reason")
+    assert reason_collection.reasons.count(";") == 2
+    assert "Some reason" in reason_collection.reasons
+    assert "Another reason" in reason_collection.reasons
+    assert "Some more reason" in reason_collection.reasons

+ 8 - 2
tests/core/data/test_data_manager.py

@@ -24,6 +24,7 @@ from taipy.core.data.data_node_id import DataNodeId
 from taipy.core.data.in_memory import InMemoryDataNode
 from taipy.core.data.pickle import PickleDataNode
 from taipy.core.exceptions.exceptions import InvalidDataNodeType, ModelNotFound
+from taipy.core.reason import NotGlobalScope, WrongConfigType
 from tests.core.utils.named_temporary_file import NamedTemporaryFile
 
 
@@ -61,11 +62,16 @@ class TestDataManager:
 
         reasons = _DataManager._can_create(dn_config)
         assert bool(reasons) is False
-        assert reasons._reasons == {dn_config.id: {'Data node config "dn" does not have GLOBAL scope'}}
+        assert reasons._reasons[dn_config.id] == {NotGlobalScope(dn_config.id)}
+        assert (
+            str(list(reasons._reasons[dn_config.id])[0])
+            == f'Data node config "{dn_config.id}" does not have GLOBAL scope'
+        )
 
         reasons = _DataManager._can_create(1)
         assert bool(reasons) is False
-        assert reasons._reasons == {"1": {'Object "1" must be a valid DataNodeConfig'}}
+        assert reasons._reasons["1"] == {WrongConfigType("1", DataNodeConfig.__name__)}
+        assert str(list(reasons._reasons["1"])[0]) == 'Object "1" must be a valid DataNodeConfig'
 
     def test_create_data_node_with_name_provided(self):
         dn_config = Config.configure_data_node(id="dn", foo="bar", name="acb")

+ 6 - 2
tests/core/scenario/test_scenario_manager.py

@@ -25,6 +25,7 @@ from taipy.core._orchestrator._orchestrator import _Orchestrator
 from taipy.core._version._version_manager import _VersionManager
 from taipy.core.common import _utils
 from taipy.core.common._utils import _Subscriber
+from taipy.core.config.scenario_config import ScenarioConfig
 from taipy.core.cycle._cycle_manager import _CycleManager
 from taipy.core.data._data_manager import _DataManager
 from taipy.core.data.in_memory import InMemoryDataNode
@@ -40,6 +41,7 @@ from taipy.core.exceptions.exceptions import (
     UnauthorizedTagError,
 )
 from taipy.core.job._job_manager import _JobManager
+from taipy.core.reason import WrongConfigType
 from taipy.core.scenario._scenario_manager import _ScenarioManager
 from taipy.core.scenario._scenario_manager_factory import _ScenarioManagerFactory
 from taipy.core.scenario.scenario import Scenario
@@ -382,13 +384,15 @@ def test_can_create():
 
     reasons = _ScenarioManager._can_create(task_config)
     assert bool(reasons) is False
-    assert reasons._reasons == {task_config.id: {'Object "task" must be a valid ScenarioConfig'}}
+    assert reasons._reasons[task_config.id] == {WrongConfigType(task_config.id, ScenarioConfig.__name__)}
+    assert str(list(reasons._reasons[task_config.id])[0]) == 'Object "task" must be a valid ScenarioConfig'
     with pytest.raises(AttributeError):
         _ScenarioManager._create(task_config)
 
     reasons = _ScenarioManager._can_create(1)
     assert bool(reasons) is False
-    assert reasons._reasons == {"1": {'Object "1" must be a valid ScenarioConfig'}}
+    assert reasons._reasons["1"] == {WrongConfigType(1, ScenarioConfig.__name__)}
+    assert str(list(reasons._reasons["1"])[0]) == 'Object "1" must be a valid ScenarioConfig'
     with pytest.raises(AttributeError):
         _ScenarioManager._create(1)
 

+ 6 - 6
tests/gui_core/test_context_is_submitable.py

@@ -14,7 +14,7 @@ from unittest.mock import Mock, patch
 from taipy.config.common.scope import Scope
 from taipy.core import Job, JobId, Scenario, Task
 from taipy.core.data.pickle import PickleDataNode
-from taipy.core.reason.reason import Reasons
+from taipy.core.reason import ReasonCollection
 from taipy.gui_core._context import _GuiCoreContext
 
 a_scenario = Scenario("scenario_config_id", None, {}, sequences={"sequence": {}})
@@ -25,13 +25,13 @@ a_datanode = PickleDataNode("data_node_config_id", Scope.SCENARIO)
 
 
 def mock_is_submittable_reason(entity_id):
-    reason = Reasons(entity_id)
-    reason._add_reason(entity_id, "a reason")
-    return reason
+    reasons = ReasonCollection()
+    reasons._add_reason(entity_id, "a reason")
+    return reasons
 
 
-def mock_has_no_reason(entity_id):
-    return Reasons(entity_id)
+def mock_has_no_reason():
+    return ReasonCollection()
 
 
 def mock_core_get(entity_id):