From 388630d22a9f0abb26fe0a262b2d5709f7c3b7ab Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Maurice=20Hern=C3=A9?= <maurice.herne@rwth-aachen.de>
Date: Thu, 7 Nov 2024 01:02:36 +0100
Subject: [PATCH] BREAKING CHANGES!

---
 examples/json_based/create_json_state.py      |   9 +-
 .../definitions/desired_states/common.py      |   3 +-
 .../desired_states/remove_solutions.py        |   9 +-
 examples/python_based/README.md               |   9 +-
 examples/python_based/process_notebooks.py    |   9 +-
 src/Notebook_Processor/__init__.py            |   1 +
 .../models/configuration_model.py             |   4 +-
 .../desired_state/actions/__init__.py         |  43 ++++
 .../desired_state/actions/action_clear.py     |  40 +++
 .../actions/action_clear_outputs.py           |  41 +++
 .../desired_state/actions/action_delete.py    |  20 ++
 .../actions/action_replace_fields.py          |  30 +++
 .../actions/action_replace_in_source.py       |  23 ++
 .../desired_state/actions/action_report.py    |  28 ++
 .../actions/action_update_metadata.py         |  26 ++
 .../desired_state/cell_modifier.py            | 240 +++---------------
 tests/conftest.py                             |  17 +-
 tests/desired_state/test_cell_modifier.py     |  47 +---
 tests/desired_state/test_predefined_action.py |  71 +++---
 19 files changed, 372 insertions(+), 298 deletions(-)
 create mode 100644 src/Notebook_Processor/desired_state/actions/__init__.py
 create mode 100644 src/Notebook_Processor/desired_state/actions/action_clear.py
 create mode 100644 src/Notebook_Processor/desired_state/actions/action_clear_outputs.py
 create mode 100644 src/Notebook_Processor/desired_state/actions/action_delete.py
 create mode 100644 src/Notebook_Processor/desired_state/actions/action_replace_fields.py
 create mode 100644 src/Notebook_Processor/desired_state/actions/action_replace_in_source.py
 create mode 100644 src/Notebook_Processor/desired_state/actions/action_report.py
 create mode 100644 src/Notebook_Processor/desired_state/actions/action_update_metadata.py

diff --git a/examples/json_based/create_json_state.py b/examples/json_based/create_json_state.py
index d2f7dea..5bf11e6 100644
--- a/examples/json_based/create_json_state.py
+++ b/examples/json_based/create_json_state.py
@@ -10,6 +10,7 @@ from Notebook_Processor import (
     NotebookMetadataModel,
     CellModifier,
     CellSelector,
+    actions,
 )
 
 julia_metadata = DesiredState(
@@ -73,27 +74,27 @@ common = DesiredState(
             selectors=[
                 CellSelector(cell_type="markdown"),
                 ],
-            action=CellModifier.Action.REPLACE_IN_SOURCE,
+            action=actions.replace_in_source,
             extra_arguments={"old": "Old text", "new": "New text"},
             ),
         CellModifier(
             selectors=[
                 CellSelector(cell_type="markdown", cell_source_contains="> **Solution:**"),
                 ],
-            action=CellModifier.Action.DELETE,
+            action=actions.delete,
             ),
         CellModifier(
             selectors=[
                 CellSelector(cell_type="code", cell_source_contains="# Start your code here:"),
                 ],
-            action=CellModifier.Action.CLEAR,
+            action=actions.clear,
             extra_arguments={"source": ["# Start your code here:"]},
             ),
         CellModifier(
             selectors=[
                 CellSelector(cell_type="code", cell_source_contains="# Test your function here:"),
                 ],
-            action=CellModifier.Action.CLEAR,
+            action=actions.clear,
             extra_arguments={"source": ["# Test your function here:"]},
             ),
         ],
diff --git a/examples/multi_module/definitions/desired_states/common.py b/examples/multi_module/definitions/desired_states/common.py
index 9679404..5f50929 100644
--- a/examples/multi_module/definitions/desired_states/common.py
+++ b/examples/multi_module/definitions/desired_states/common.py
@@ -6,6 +6,7 @@ from Notebook_Processor import (
     DesiredState,
     CellModifier,
     CellSelector,
+    actions,
 )
 
 
@@ -15,7 +16,7 @@ COMMON = DesiredState(
             selectors=[
                 CellSelector(cell_type="markdown")
                 ],
-            action=CellModifier.Action.REPLACE_IN_SOURCE,
+            action=actions.replace_in_source,
             extra_arguments={
                 "old": "Old text",
                 "new": "New text",
diff --git a/examples/multi_module/definitions/desired_states/remove_solutions.py b/examples/multi_module/definitions/desired_states/remove_solutions.py
index bd5fedf..adde05c 100644
--- a/examples/multi_module/definitions/desired_states/remove_solutions.py
+++ b/examples/multi_module/definitions/desired_states/remove_solutions.py
@@ -6,6 +6,7 @@ from Notebook_Processor import (
     DesiredState,
     CellModifier,
     CellSelector,
+    actions,
 )
 
 
@@ -15,26 +16,24 @@ REMOVE_SOLUTIONS = DesiredState(
             selectors=[
                 CellSelector(cell_type="markdown", cell_source_contains="> **Solution:**"),
                 ],
-            action=CellModifier.Action.DELETE,
+            action=actions.delete,
             ),
         CellModifier(
             selectors=[
                 CellSelector(cell_type="code", cell_source_contains="# Start your code here:"),
                 ],
-            action=CellModifier.Action.CLEAR,
+            action=actions.clear,
             extra_arguments={
                 "source": ["# Start your code here:"],
-                "preserve_metadata": False,
                 },
             ),
         CellModifier(
             selectors=[
                 CellSelector(cell_type="code", cell_source_contains="# Test your function here:"),
                 ],
-            action=CellModifier.Action.CLEAR,
+            action=actions.clear,
             extra_arguments={
                 "source": ["# Test your function here:"],
-                "preserve_metadata": False,
                 },
             ),
         ],
diff --git a/examples/python_based/README.md b/examples/python_based/README.md
index 2e2fac6..afe7193 100644
--- a/examples/python_based/README.md
+++ b/examples/python_based/README.md
@@ -16,6 +16,7 @@ from Notebook_Processor import (
     NotebookMetadataModel,
     CellModifier,
     CellSelector,
+    actions,
 )
 ```
 
@@ -87,27 +88,27 @@ common = DesiredState(
             selectors=[
                 CellSelector(cell_type="markdown"),
                 ],
-            action=CellModifier.Action.REPLACE_IN_SOURCE,
+            action=actions.replace_in_source,
             extra_arguments={"old": "Old text", "new": "New text"},
             ),
         CellModifier(
             selectors=[
                 CellSelector(cell_type="markdown", cell_source_contains="> **Solution:**"),
                 ],
-            action=CellModifier.Action.DELETE,
+            action=actions.delete,
             ),
         CellModifier(
             selectors=[
                 CellSelector(cell_type="code", cell_source_contains="# Start your code here:"),
                 ],
-            action=CellModifier.Action.CLEAR,
+            action=actions.clear,
             extra_arguments={"source": ["# Start your code here:"]},
             ),
         CellModifier(
             selectors=[
                 CellSelector(cell_type="code", cell_source_contains="# Test your function here:"),
                 ],
-            action=CellModifier.Action.CLEAR,
+            action=actions.clear,
             extra_arguments={"source": ["# Test your function here:"]},
             ),
         ],
diff --git a/examples/python_based/process_notebooks.py b/examples/python_based/process_notebooks.py
index 069f76d..cf76ccc 100644
--- a/examples/python_based/process_notebooks.py
+++ b/examples/python_based/process_notebooks.py
@@ -11,6 +11,7 @@ from Notebook_Processor import (
     NotebookMetadataModel,
     CellModifier,
     CellSelector,
+    actions,
 )
 
 julia_metadata = DesiredState(
@@ -74,27 +75,27 @@ common = DesiredState(
             selectors=[
                 CellSelector(cell_type="markdown"),
                 ],
-            action=CellModifier.Action.REPLACE_IN_SOURCE,
+            action=actions.replace_in_source,
             extra_arguments={"old": "Old text", "new": "New text"},
             ),
         CellModifier(
             selectors=[
                 CellSelector(cell_type="markdown", cell_source_contains="> **Solution:**"),
                 ],
-            action=CellModifier.Action.DELETE,
+            action=actions.delete,
             ),
         CellModifier(
             selectors=[
                 CellSelector(cell_type="code", cell_source_contains="# Start your code here:"),
                 ],
-            action=CellModifier.Action.CLEAR,
+            action=actions.clear,
             extra_arguments={"source": ["# Start your code here:"]},
             ),
         CellModifier(
             selectors=[
                 CellSelector(cell_type="code", cell_source_contains="# Test your function here:"),
                 ],
-            action=CellModifier.Action.CLEAR,
+            action=actions.clear,
             extra_arguments={"source": ["# Test your function here:"]},
             ),
         ],
diff --git a/src/Notebook_Processor/__init__.py b/src/Notebook_Processor/__init__.py
index 305bc56..3973696 100644
--- a/src/Notebook_Processor/__init__.py
+++ b/src/Notebook_Processor/__init__.py
@@ -31,4 +31,5 @@ from .desired_state import (
     NotebookSelector,
     CellSelector,
     CellModifier,
+    actions,
 )
diff --git a/src/Notebook_Processor/configuration/models/configuration_model.py b/src/Notebook_Processor/configuration/models/configuration_model.py
index de35bd7..8b80452 100644
--- a/src/Notebook_Processor/configuration/models/configuration_model.py
+++ b/src/Notebook_Processor/configuration/models/configuration_model.py
@@ -29,7 +29,9 @@ class ConfigurationModel(ConfiguredBaseModel):
         Causes defaults to get validated as a side effect.
         Since this only happens during the startup of the application, I consider this to be a positive side effect that protects against errors.
         """
-        assert isinstance(data, dict)
+        if not isinstance(data, dict):
+            raise TypeError(f"data must be a dict, not {type(data)}")  # pragma: no cover  # I don't know of any way to trigger this error but I feel better having it here just in case.
+
         for field, info in cls.model_fields.items():
             data.setdefault(field, environ.get(field, info.default))
         return data
diff --git a/src/Notebook_Processor/desired_state/actions/__init__.py b/src/Notebook_Processor/desired_state/actions/__init__.py
new file mode 100644
index 0000000..b28de81
--- /dev/null
+++ b/src/Notebook_Processor/desired_state/actions/__init__.py
@@ -0,0 +1,43 @@
+"""
+This module contains the actions that can be performed on the notebook cells.
+
+It is a collection of functions that take a cell and return a new cell with the desired modifications.
+
+All functions have the following signature:
+
+```python
+def action_name(cell: NotebookCellAnnotation, *,
+                mandatory_keyword_argument: type,
+                optional_keyword_argument: type = default_value,
+                **kwargs,  # pylint: disable=unused-argument
+                ) -> NotebookCellAnnotation:
+    \"\"\"
+    Description of the action
+
+    :param cell NotebookCellAnnotation:
+    The cell to be modified
+
+    :param mandatory_keyword_argument type:
+    Description of the mandatory keyword argument
+
+    :param optional_keyword_argument type:
+    Description of the optional keyword argument
+
+    :param kwargs dict:
+    Unused keyword arguments
+
+    :return NotebookCellAnnotation:
+    The modified cell
+    \"\"\"
+    # Perform the action
+    return cell
+```
+"""
+
+from .action_clear_outputs import clear_outputs
+from .action_clear import clear
+from .action_delete import delete
+# from .action_replace_fields import replace_fields
+from .action_replace_in_source import replace_in_source
+from .action_report import report
+from .action_update_metadata import update_metadata
diff --git a/src/Notebook_Processor/desired_state/actions/action_clear.py b/src/Notebook_Processor/desired_state/actions/action_clear.py
new file mode 100644
index 0000000..459e762
--- /dev/null
+++ b/src/Notebook_Processor/desired_state/actions/action_clear.py
@@ -0,0 +1,40 @@
+"""
+Provides the `clear` action
+"""
+
+from logging import (
+    getLogger,
+)
+
+from Notebook_Processor.notebook_models import (
+    NotebookCellAnnotation,
+)
+
+
+module_logger = getLogger(__name__)
+module_logger.debug("Loaded %s", __name__)
+
+
+def clear(cell: NotebookCellAnnotation, preserve_id: bool = True, preserve_metadata: bool = False, **kwargs) -> NotebookCellAnnotation:
+    """
+    Clear the cell (returns a fresh instance of the cell)
+    The cell will be initialized with the given keyword arguments
+
+    :param preserve_id bool:
+    Preserve the id of the cell
+
+    :param preserve_metadata bool:
+    Preserve the metadata of the cell
+
+    :param kwargs dict:
+    Additional keyword arguments to initialize the cell. Directly passed to the cell constructor.
+    """
+    cell_class = type(cell)
+    if kwargs.get("cell_type", cell.cell_type) != cell.cell_type:
+        module_logger.warning("Clear doesn't support changing the cell type. Using %s", cell.cell_type)
+    kwargs["cell_type"] = cell.cell_type
+    if preserve_id:
+        kwargs.setdefault("id", cell.id)
+    if preserve_metadata:
+        kwargs.setdefault("metadata", cell.metadata)
+    return cell_class(**kwargs)
diff --git a/src/Notebook_Processor/desired_state/actions/action_clear_outputs.py b/src/Notebook_Processor/desired_state/actions/action_clear_outputs.py
new file mode 100644
index 0000000..8e4d154
--- /dev/null
+++ b/src/Notebook_Processor/desired_state/actions/action_clear_outputs.py
@@ -0,0 +1,41 @@
+"""
+Provides the `clear_outputs` action
+"""
+
+from logging import (
+    getLogger,
+)
+
+from Notebook_Processor.notebook_models import (
+    NotebookCellAnnotation,
+)
+
+
+module_logger = getLogger(__name__)
+module_logger.debug("Loaded %s", __name__)
+
+
+def clear_outputs(cell: NotebookCellAnnotation,
+                  **kwargs  # pylint: disable=unused-argument
+                  ) -> NotebookCellAnnotation:
+    """
+    Clear the output of the cell
+
+    :param cell NotebookCellAnnotation:
+    The cell to be modified
+
+    :param kwargs dict:
+    Unused keyword arguments
+
+    :return NotebookCellAnnotation:
+    """
+    if hasattr(cell, "outputs"):
+        return cell.model_copy(
+            deep=True,
+            update={
+                "outputs": []
+                }
+            )
+    # The following could happen for cells that don't have an output such as Markdown Cells.
+    module_logger.warning("Cell '%s' has no field 'outputs'. Skipping clearing of outputs.", cell.id)
+    return cell
diff --git a/src/Notebook_Processor/desired_state/actions/action_delete.py b/src/Notebook_Processor/desired_state/actions/action_delete.py
new file mode 100644
index 0000000..614ef1e
--- /dev/null
+++ b/src/Notebook_Processor/desired_state/actions/action_delete.py
@@ -0,0 +1,20 @@
+"""
+Provides the `delete` action
+"""
+
+from typing import (
+    Optional,
+)
+
+from Notebook_Processor.notebook_models import (
+    NotebookCellAnnotation,
+)
+
+
+def delete(cell: Optional[NotebookCellAnnotation] = None,  # pylint: disable=unused-argument
+           **kwargs,  # pylint: disable=unused-argument
+           ) -> None:
+    """
+    Delete the cell (always returns None)
+    """
+    return None
diff --git a/src/Notebook_Processor/desired_state/actions/action_replace_fields.py b/src/Notebook_Processor/desired_state/actions/action_replace_fields.py
new file mode 100644
index 0000000..fd18560
--- /dev/null
+++ b/src/Notebook_Processor/desired_state/actions/action_replace_fields.py
@@ -0,0 +1,30 @@
+# """
+# Provides the `replace_fields` action
+# """
+
+# from Notebook_Processor.notebook_models import (
+#     NotebookCellAnnotation,
+# )
+
+# I don't want to remove this yet but I am unsure of the value of this function.
+# It is currently also broken due to isinstance not being able to handle complex annotations.
+# @staticmethod
+# def replace_fields(cell: NotebookCellAnnotation, *,
+#                    fields: dict[str, Any],
+#                    clear_undefined: bool = False,
+#                    **kwargs,  # pylint: disable=unused-argument
+#                    ) -> NotebookCellAnnotation:
+#     """
+#     Replace the fields in the cell with the given values
+#     """
+#     for field in cell.model_fields_set:
+#         if new := fields.get(field, False):
+#             if not isinstance(new, cell.model_fields[field].annotation):
+#                 try:
+#                     new = cell.model_fields[field].annotation(new)
+#                 except Exception as exc:
+#                     raise ValidationError(f"Expected {cell.model_fields[field].annotation}, got {type(new)}") from exc
+#             cell.__dict__[field] = new
+#         elif clear_undefined:
+#             cell.__dict__[field] = None
+#     return cell
diff --git a/src/Notebook_Processor/desired_state/actions/action_replace_in_source.py b/src/Notebook_Processor/desired_state/actions/action_replace_in_source.py
new file mode 100644
index 0000000..e990e32
--- /dev/null
+++ b/src/Notebook_Processor/desired_state/actions/action_replace_in_source.py
@@ -0,0 +1,23 @@
+"""
+Provides the `replace_in_source` action
+"""
+
+from Notebook_Processor.notebook_models import (
+    NotebookCellAnnotation,
+)
+
+
+def replace_in_source(cell: NotebookCellAnnotation, *,
+                      old: str,
+                      new: str,
+                      **kwargs,  # pylint: disable=unused-argument
+                      ) -> NotebookCellAnnotation:
+    """
+    Replace the given string in the source of the cell
+    """
+    return cell.model_copy(
+        deep=True,
+        update={
+            "source": [line.replace(old, new) for line in cell.source]
+            }
+        )
diff --git a/src/Notebook_Processor/desired_state/actions/action_report.py b/src/Notebook_Processor/desired_state/actions/action_report.py
new file mode 100644
index 0000000..75bb79c
--- /dev/null
+++ b/src/Notebook_Processor/desired_state/actions/action_report.py
@@ -0,0 +1,28 @@
+"""
+Provides the `report` action
+"""
+
+from typing import (
+    Callable,
+    Any,
+)
+
+from Notebook_Processor.notebook_models import (
+    NotebookCellAnnotation,
+)
+
+
+def report(cell: NotebookCellAnnotation, *,
+           logger: Callable[[str], None | Any] = print,
+           field: str | None = "id",
+           **kwargs,  # pylint: disable=unused-argument
+           ) -> NotebookCellAnnotation:
+    """
+    Report the cell (returns the cell without any modifications)
+    """
+    try:
+        logger(cell.__dict__[field])
+    except KeyError:
+        logger(f"Requested field {field} not found in cell, logging the cell itself")
+        logger(cell)
+    return cell
diff --git a/src/Notebook_Processor/desired_state/actions/action_update_metadata.py b/src/Notebook_Processor/desired_state/actions/action_update_metadata.py
new file mode 100644
index 0000000..e25a21e
--- /dev/null
+++ b/src/Notebook_Processor/desired_state/actions/action_update_metadata.py
@@ -0,0 +1,26 @@
+"""
+Provides the `update_metadata` action
+"""
+
+from typing import (
+    Any,
+)
+
+from Notebook_Processor.notebook_models import (
+    NotebookCellAnnotation,
+)
+
+
+def update_metadata(cell: NotebookCellAnnotation, *,
+                    metadata: dict[str, Any],
+                    **kwargs,  # pylint: disable=unused-argument
+                    ) -> NotebookCellAnnotation:
+    """
+    Update the metadata of the cell
+    """
+    return cell.model_copy(
+        deep=True,
+        update={
+            "metadata": metadata
+            }
+        )
diff --git a/src/Notebook_Processor/desired_state/cell_modifier.py b/src/Notebook_Processor/desired_state/cell_modifier.py
index 2e0f903..0cd859b 100644
--- a/src/Notebook_Processor/desired_state/cell_modifier.py
+++ b/src/Notebook_Processor/desired_state/cell_modifier.py
@@ -7,15 +7,11 @@ It also provides some built-in actions that can be applied to cells.
 
 import inspect
 from typing import (
-    Optional,
     Any,
     Callable,
     Literal,
     Self,
 )
-from enum import (
-    Enum,
-)
 from logging import (
     getLogger,
 )
@@ -39,7 +35,7 @@ from ..notebook_models import (
 from .selector import (
     CellSelector,
 )
-
+from . import actions
 
 module_logger = getLogger(__name__)
 module_logger.debug("Loaded %s", __name__)
@@ -56,7 +52,7 @@ class CellModifier(BaseModel):
     The mode to use when applying the selectors. "ALL" requires all selectors to match, "ANY" requires at least one selector to match.
 
     :param action Callable: The action to be applied to the cell.
-    Can be a custom callable or one fo the provided actions from the Action enum. The `action` will be called with the cell as the first and only positional argument.
+    Can be a custom callable or one of the provided actions from the actions submodule. The `action` will be called with the cell as the first and only positional argument.
 
     :param extra_arguments dict: will be passed as keyword arguments to the action.
     Ensure to supply all required arguments in the `extra_arguments` dictionary.
@@ -64,210 +60,39 @@ class CellModifier(BaseModel):
     ### If you want to use a custom function as an action, ensure that it satisfies the following form:
 
     ```python
-    def custom_action(cell: NotebookCellAnnotation, *,
-                      extra_arg_1: annotation,
-                      extra_arg_2: annotation,
-                      extra_arg_n: annotation,
-                      **kwargs) -> NotebookCellAnnotation:
+    def action_name(cell: NotebookCellAnnotation, *,
+                    mandatory_keyword_argument: type,
+                    optional_keyword_argument: type = default_value,
+                    **kwargs,  # pylint: disable=unused-argument
+                    ) -> NotebookCellAnnotation:
         \"\"\"
-        # Custom Action Name
-        Custom action to be applied to the cell
-
-        :param cell NotebookCellAnnotation: The cell to be modified
-        :param extra_arg_1 annotation: Needed for something
-        :param extra_arg_2 annotation: Needed for something else
-        :param extra_arg_n annotation: Needed for something else
-        :param kwargs dict: Additional keyword arguments needed so no errors are thrown for additional unused arguments
+        Description of the action
+
+        :param cell NotebookCellAnnotation:
+        The cell to be modified
+
+        :param mandatory_keyword_argument type:
+        Description of the mandatory keyword argument
+
+        :param optional_keyword_argument type:
+        Description of the optional keyword argument
+
+        :param kwargs dict:
+        Unused keyword arguments
+
+        :return NotebookCellAnnotation:
+        The modified cell
         \"\"\"
-        # Your code here
+        # Perform the action
         return cell
     ```
 
     The function signature is checked during model validation to ensure the function signature is not offending.
     """
-    class Action(Enum):
-        """
-        An Enum of builtin actions that can be applied to cells
-        """
-
-        @staticmethod
-        def delete(cell: Optional[NotebookCellAnnotation] = None,  # pylint: disable=unused-argument
-                   **kwargs,  # pylint: disable=unused-argument
-                   ):
-            """
-            Delete the cell (always returns None)
-            """
-            return None
-
-        @staticmethod
-        def clear(cell: NotebookCellAnnotation, preserve_id: bool = True, preserve_metadata: bool = False, **kwargs) -> NotebookCellAnnotation:
-            """
-            Clear the cell (returns a fresh instance of the cell)
-            The cell will be initialized with the given keyword arguments
-
-            :param preserve_id bool:
-            Preserve the id of the cell
-
-            :param preserve_metadata bool:
-            Preserve the metadata of the cell
-
-            :param kwargs dict:
-            Additional keyword arguments to initialize the cell. Directly passed to the cell constructor.
-            """
-            cell_class = type(cell)
-            if kwargs.get("cell_type", cell.cell_type) != cell.cell_type:
-                module_logger.warning("Clear doesn't support changing the cell type. Using %s", cell.cell_type)
-            kwargs["cell_type"] = cell.cell_type
-            if preserve_id:
-                kwargs.setdefault("id", cell.id)
-            if preserve_metadata:
-                kwargs.setdefault("metadata", cell.metadata)
-            return cell_class(**kwargs)
-
-        @staticmethod
-        def clear_outputs(cell: NotebookCellAnnotation,
-                          **kwargs  # pylint: disable=unused-argument
-                          ) -> NotebookCellAnnotation:
-            """
-            Clear the output of the cell
-
-            :param cell NotebookCellAnnotation:
-            The cell to be modified
-
-            :param kwargs dict:
-            Unused keyword arguments
-
-            :return NotebookCellAnnotation:
-            """
-            if hasattr(cell, "outputs"):
-                return cell.model_copy(
-                    deep=True,
-                    update={
-                        "outputs": []
-                        }
-                    )
-            # The following could happen for cells that don't have an output such as Markdown Cells.
-            module_logger.warning("Cell '%s' has no field 'outputs'. Skipping clearing of outputs.", cell.id)
-            return cell
-
-        # I don't want to remove this yet but I am unsure of the value of this function.
-        # It is currently also broken due to isinstance not being able to handle complex annotations.
-        # @staticmethod
-        # def replace_fields(cell: NotebookCellAnnotation, *,
-        #                    fields: dict[str, Any],
-        #                    clear_undefined: bool = False,
-        #                    **kwargs,  # pylint: disable=unused-argument
-        #                    ) -> NotebookCellAnnotation:
-        #     """
-        #     Replace the fields in the cell with the given values
-        #     """
-        #     for field in cell.model_fields_set:
-        #         if new := fields.get(field, False):
-        #             if not isinstance(new, cell.model_fields[field].annotation):
-        #                 try:
-        #                     new = cell.model_fields[field].annotation(new)
-        #                 except Exception as exc:
-        #                     raise ValidationError(f"Expected {cell.model_fields[field].annotation}, got {type(new)}") from exc
-        #             cell.__dict__[field] = new
-        #         elif clear_undefined:
-        #             cell.__dict__[field] = None
-        #     return cell
-
-        @staticmethod
-        def replace_in_source(cell: NotebookCellAnnotation, *,
-                              old: str,
-                              new: str,
-                              **kwargs,  # pylint: disable=unused-argument
-                              ) -> NotebookCellAnnotation:
-            """
-            Replace the given string in the source of the cell
-            """
-            return cell.model_copy(
-                deep=True,
-                update={
-                    "source": [line.replace(old, new) for line in cell.source]
-                    }
-                )
-
-        @staticmethod
-        def update_metadata(cell: NotebookCellAnnotation, *,
-                            metadata: dict[str, Any],
-                            **kwargs,  # pylint: disable=unused-argument
-                            ) -> NotebookCellAnnotation:
-            """
-            Update the metadata of the cell
-            """
-            return cell.model_copy(
-                deep=True,
-                update={
-                    "metadata": metadata
-                    }
-                )
-
-        @staticmethod
-        def report(cell: NotebookCellAnnotation, *,
-                   logger: Callable[[str], None | Any] = print,
-                   field: str | None = "id",
-                   **kwargs,  # pylint: disable=unused-argument
-                   ) -> NotebookCellAnnotation:
-            """
-            Report the cell (returns the cell without any modifications)
-            """
-            try:
-                logger(cell.__dict__[field])
-            except KeyError:
-                logger(f"Requested field {field} not found in cell, logging the cell itself")
-                logger(cell)
-            return cell
-
-        DELETE = delete
-        CLEAR = clear
-        CLEAR_OUTPUTS = clear_outputs
-        # REPLACE_FIELDS = replace_fields
-        REPLACE_IN_SOURCE = replace_in_source
-        UPDATE_METADATA = update_metadata
-        REPORT = report
-
-        @classmethod
-        def get_members(cls):
-            """
-            # A workaround for the Enum not having a working __members__ attribute.
-            __members__ returns an empty list because Enum aparrently doesn't list mappings to Callable
-            """
-            return [
-                "DELETE",
-                "CLEAR",
-                # "REPLACE_FIELDS",
-                "REPLACE_IN_SOURCE",
-                "UPDATE_METADATA",
-                "REPORT",
-                ]
-
-        @classmethod
-        def custom_init(cls, action: str) -> Callable:
-            """
-            # A workaround for the Enum not having a working __members__ attribute.
-            __members__ returns an empty list because Enum aparrently doesn't list mappings to Callable
-            """
-            match action:
-                case "DELETE":
-                    return cls.DELETE
-                case "CLEAR":
-                    return cls.CLEAR
-                # case "REPLACE_FIELDS":
-                #     return cls.REPLACE_FIELDS
-                case "REPLACE_IN_SOURCE":
-                    return cls.REPLACE_IN_SOURCE
-                case "UPDATE_METADATA":
-                    return cls.UPDATE_METADATA
-                case "REPORT":
-                    return cls.REPORT
-                case _:
-                    raise ValueError(f"Action {action} not found in the Action Enum")
 
     selectors: list[CellSelector] = []
     selector_mode: Literal["ALL", "ANY"] = config.DEFAULT_SELECTOR_MODE
-    action: Action | Callable
+    action: Callable
     extra_arguments: dict[str, Any] = {}
 
     def __call__(self, cell: NotebookCellAnnotation) -> NotebookCellAnnotation:
@@ -287,13 +112,14 @@ class CellModifier(BaseModel):
 
     @field_serializer("action")
     def _serialize_action(self, value: Callable) -> str:
-        if value.__name__.upper() in self.Action.get_members():
-            return value.__name__.upper()
+        """
+        Serialize the action to a string when saving to JSON
+        """
         return value.__name__
 
     @field_validator("action", mode="before")
     @classmethod
-    def _validate_action(cls, value: Any) -> Action | Callable:
+    def _validate_action(cls, value: Any) -> Callable:
         """
         A function to deserialize an action when loading from JSON
 
@@ -303,8 +129,8 @@ class CellModifier(BaseModel):
         """
         if isinstance(value, str):
             try:
-                value = cls.Action.custom_init(value)
-            except ValueError as exc:
+                value = getattr(actions, value.lower())
+            except AttributeError as exc:
                 raise NotImplementedError(f"Custom Actions can currently not be loaded from a JSON state. Offending Action: {value}") from exc
         return value
 
@@ -329,13 +155,13 @@ class CellModifier(BaseModel):
         if signature[sig_keys[0]].kind not in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD):
             raise ValueError(
                 f"Offending function signature: Action {self.action.__name__} must have the first parameter 'cell' as a positional argument.",
-                f"Found: {sig_keys[0], signature[sig_keys[0]]}",
+                f"Found: {sig_keys[0]}, {signature[sig_keys[0]]}",
                 )
 
         if signature[sig_keys[-1]].kind != inspect.Parameter.VAR_KEYWORD:
             raise ValueError(
                 f"Offending function signature: Action {self.action.__name__} must accept keyword arguments.",
-                f"The last paramter should be '**kwargs', found: {sig_keys[-1], signature[sig_keys[-1]].kind}",
+                f"The last paramter should be '**kwargs', found: {sig_keys[-1]}, {signature[sig_keys[-1]].kind}",
                 )
 
         missing_annotations = [
diff --git a/tests/conftest.py b/tests/conftest.py
index f2e4fc1..2ec4c6e 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -3,7 +3,14 @@ import shutil
 
 import pytest
 
-from Notebook_Processor import DesiredState, NotebookSelector, NotebookMetadataModel, CellModifier, CellSelector
+from Notebook_Processor import (
+    DesiredState,
+    NotebookSelector,
+    NotebookMetadataModel,
+    CellModifier,
+    CellSelector,
+    actions,
+)
 from Notebook_Processor.notebook_models import (
     NotebookModel,
     NotebookCodeCellModel,
@@ -106,27 +113,27 @@ def desired_states() -> list[DesiredState]:
                     selectors=[
                         CellSelector(cell_type="markdown"),
                         ],
-                    action=CellModifier.Action.REPLACE_IN_SOURCE,
+                    action=actions.replace_in_source,
                     extra_arguments={"old": "Murwan Siddig, Stefan Pilot", "new": ""},
                     ),
                 CellModifier(
                     selectors=[
                         CellSelector(cell_type="markdown", cell_source_contains="> **Solution:**"),
                         ],
-                    action=CellModifier.Action.DELETE,
+                    action=actions.delete,
                     ),
                 CellModifier(
                     selectors=[
                         CellSelector(cell_type="code", cell_source_contains="# Start your code here:"),
                         ],
-                    action=CellModifier.Action.CLEAR,
+                    action=actions.clear,
                     extra_arguments={"source": ["# Start your code here:"]},
                     ),
                 CellModifier(
                     selectors=[
                         CellSelector(cell_type="code", cell_source_contains="# Test your function here:"),
                         ],
-                    action=CellModifier.Action.CLEAR,
+                    action=actions.clear,
                     extra_arguments={"source": ["# Test your function here:"]},
                     ),
                 ],
diff --git a/tests/desired_state/test_cell_modifier.py b/tests/desired_state/test_cell_modifier.py
index e173b8a..74d7a59 100644
--- a/tests/desired_state/test_cell_modifier.py
+++ b/tests/desired_state/test_cell_modifier.py
@@ -14,6 +14,7 @@ from Notebook_Processor.notebook_models import (
 from Notebook_Processor.desired_state import (
     CellModifier,
     CellSelector,
+    actions,
 )
 from Notebook_Processor.configuration import (
     config,
@@ -24,28 +25,28 @@ def test_init():
     with pytest.raises(ValidationError):
         CellModifier()
     modifier = CellModifier(
-        action=CellModifier.Action.REPORT,
+        action=actions.report,
     )
     assert modifier.selectors == []
     assert modifier.selector_mode == config.DEFAULT_SELECTOR_MODE
-    assert modifier.action == CellModifier.Action.REPORT
+    assert modifier.action == actions.report
     assert modifier.extra_arguments == {}
 
 
 def test_override_default_selector_mode():
     assert CellModifier(
-        action=CellModifier.Action.REPORT,
+        action=actions.report,
         selector_mode="ALL",
     ).selector_mode == "ALL"
     assert CellModifier(
-        action=CellModifier.Action.REPORT,
+        action=actions.report,
         selector_mode="ANY",
     ).selector_mode == "ANY"
 
 
 def test_selector_ANY(run_code_cell: NotebookCellAnnotation, markdown_solution_cell: NotebookCellAnnotation, capsys):
     modifier = CellModifier(
-        action=CellModifier.Action.REPORT,
+        action=actions.report,
         selectors=[CellSelector(cell_type="code"), CellSelector(cell_type="markdown")],
         selector_mode="ANY",
     )
@@ -59,7 +60,7 @@ def test_selector_ANY(run_code_cell: NotebookCellAnnotation, markdown_solution_c
 
 def test_selector_ALL_impossible(run_code_cell: NotebookCellAnnotation, markdown_solution_cell: NotebookCellAnnotation, capsys):
     modifier_impossible = CellModifier(
-        action=CellModifier.Action.REPORT,
+        action=actions.report,
         selectors=[CellSelector(cell_type="code"), CellSelector(cell_type="markdown")],
         selector_mode="ALL",
     )
@@ -73,7 +74,7 @@ def test_selector_ALL_impossible(run_code_cell: NotebookCellAnnotation, markdown
 
 def test_selector_ALL_possible(run_code_cell: NotebookCellAnnotation, markdown_solution_cell: NotebookCellAnnotation, capsys):
     modifier_possible = CellModifier(
-        action=CellModifier.Action.REPORT,
+        action=actions.report,
         selectors=[CellSelector(cell_type="code")],
         selector_mode="ALL",
     )
@@ -87,7 +88,7 @@ def test_selector_ALL_possible(run_code_cell: NotebookCellAnnotation, markdown_s
 
 def test_modifier_call(markdown_solution_cell, capsys):
     modifier = CellModifier(
-        action=CellModifier.Action.REPORT,
+        action=actions.report,
     )
     capsys.readouterr()
     assert modifier(markdown_solution_cell) == markdown_solution_cell
@@ -96,7 +97,7 @@ def test_modifier_call(markdown_solution_cell, capsys):
 
 def test_action_validator_additional():
     CellModifier(
-        action=CellModifier.Action.REPORT,
+        action=actions.report,
         extra_arguments={"noise": "LOUD"},
     )
 
@@ -104,7 +105,7 @@ def test_action_validator_additional():
 def test_action_validator_missing():
     with pytest.raises(ValidationError):
         CellModifier(
-            action=CellModifier.Action.UPDATE_METADATA,
+            action=actions.update_metadata,
             # extra_arguments={"metadata": {}},  # Minimal required extra_arguments
         )
 
@@ -112,14 +113,14 @@ def test_action_validator_missing():
 def test_action_validator_wrong_type():
     with pytest.raises(ValidationError):
         CellModifier(
-            action=CellModifier.Action.UPDATE_METADATA,
+            action=actions.update_metadata,
             extra_arguments={"metadata": "metadatastring"},
         )
 
 
 def test_action_validator_correct_extra_arguments():
     CellModifier(
-        action=CellModifier.Action.UPDATE_METADATA,
+        action=actions.update_metadata,
         extra_arguments={"metadata": {}},
     )
 
@@ -261,25 +262,3 @@ def test_action_validator_offending_signature_VAR_POSITIONAL(run_code_cell):
         extra_arguments={"args": ("x", "y")},
         )
     config.ACTION_SIGNATURE_VALIDATION = "STRICT"
-
-
-def test_action_get_members():
-    assert CellModifier.Action.get_members() == [
-        "DELETE",
-        "CLEAR",
-        # "REPLACE_FIELDS",
-        "REPLACE_IN_SOURCE",
-        "UPDATE_METADATA",
-        "REPORT",
-        ]
-
-
-def test_action_custom_init():
-    assert CellModifier.Action.custom_init("DELETE") == CellModifier.Action.DELETE
-    assert CellModifier.Action.custom_init("CLEAR") == CellModifier.Action.CLEAR
-    # assert CellModifier.Action.custom_init("REPLACE_FIELDS") == CellModifier.Action.REPLACE_FIELDS
-    assert CellModifier.Action.custom_init("REPLACE_IN_SOURCE") == CellModifier.Action.REPLACE_IN_SOURCE
-    assert CellModifier.Action.custom_init("UPDATE_METADATA") == CellModifier.Action.UPDATE_METADATA
-    assert CellModifier.Action.custom_init("REPORT") == CellModifier.Action.REPORT
-    with pytest.raises(ValueError):
-        CellModifier.Action.custom_init("INVALID")
diff --git a/tests/desired_state/test_predefined_action.py b/tests/desired_state/test_predefined_action.py
index 6cefea7..a038b9c 100644
--- a/tests/desired_state/test_predefined_action.py
+++ b/tests/desired_state/test_predefined_action.py
@@ -2,8 +2,13 @@ from typing import Callable
 
 import pytest
 
-from Notebook_Processor.notebook_models import NotebookCodeCellModel, NotebookMarkdownCellModel
-from Notebook_Processor.desired_state import CellModifier
+from Notebook_Processor.notebook_models import (
+    NotebookCodeCellModel,
+    NotebookMarkdownCellModel,
+)
+from Notebook_Processor.desired_state import (
+    actions,
+)
 
 
 @pytest.fixture
@@ -57,16 +62,16 @@ def extra_args() -> dict:
     }
 
 
-def test_DELETE(code_cell, markdown_cell, extra_args):
-    assert isinstance(CellModifier.Action.DELETE, Callable)
-    assert CellModifier.Action.DELETE(code_cell, **extra_args) is None
-    assert CellModifier.Action.DELETE(markdown_cell, **extra_args) is None
+def test_delete(code_cell, markdown_cell, extra_args):
+    assert isinstance(actions.delete, Callable)
+    assert actions.delete(code_cell, **extra_args) is None
+    assert actions.delete(markdown_cell, **extra_args) is None
 
 
-def test_CLEAR_default(code_cell: NotebookCodeCellModel, markdown_cell: NotebookMarkdownCellModel, extra_args):
-    assert isinstance(CellModifier.Action.CLEAR, Callable)
+def test_clear_default(code_cell: NotebookCodeCellModel, markdown_cell: NotebookMarkdownCellModel, extra_args):
+    assert isinstance(actions.clear, Callable)
     # Coce Cell
-    modified_code_cell = CellModifier.Action.CLEAR(code_cell, **extra_args)
+    modified_code_cell = actions.clear(code_cell, **extra_args)
     code_cell_defaults = {key: value.default for key, value in code_cell.model_fields.items()}
     assert isinstance(modified_code_cell, NotebookCodeCellModel)
     assert modified_code_cell.id == code_cell.id
@@ -77,7 +82,7 @@ def test_CLEAR_default(code_cell: NotebookCodeCellModel, markdown_cell: Notebook
     assert modified_code_cell.outputs == code_cell_defaults["outputs"]
 
     # Markdown Cell
-    modified_markdown_cell = CellModifier.Action.CLEAR(markdown_cell, **extra_args)
+    modified_markdown_cell = actions.clear(markdown_cell, **extra_args)
     markdown_cell_defaults = {key: value.default for key, value in markdown_cell.model_fields.items()}
     assert isinstance(modified_markdown_cell, NotebookMarkdownCellModel)
     assert modified_markdown_cell.id == markdown_cell.id
@@ -86,13 +91,13 @@ def test_CLEAR_default(code_cell: NotebookCodeCellModel, markdown_cell: Notebook
     assert modified_markdown_cell.source == markdown_cell_defaults["source"]
 
 
-def test_CLEAR_args(code_cell: NotebookCodeCellModel, markdown_cell: NotebookMarkdownCellModel, extra_args, caplog):
-    assert isinstance(CellModifier.Action.CLEAR, Callable)
+def test_clear_args(code_cell: NotebookCodeCellModel, markdown_cell: NotebookMarkdownCellModel, extra_args, caplog):
+    assert isinstance(actions.clear, Callable)
 
     # Code Cell
     caplog.clear()
     caplog.set_level("INFO")
-    modified_code_cell = CellModifier.Action.CLEAR(
+    modified_code_cell = actions.clear(
         code_cell,
         cell_type="markdown",  # Should log a warning and internally be replaced with the correct cell_type
         preserve_id=False,
@@ -112,7 +117,7 @@ def test_CLEAR_args(code_cell: NotebookCodeCellModel, markdown_cell: NotebookMar
     assert caplog.messages[1].startswith("Cell doesn't have an ID (mandatory field: id).")
 
     # Markdown Cell
-    modified_markdown_cell = CellModifier.Action.CLEAR(
+    modified_markdown_cell = actions.clear(
         markdown_cell,
         cell_type="code",  # Should log a warning and internally be replaced with the correct cell_type
         preserve_id=False,
@@ -126,9 +131,9 @@ def test_CLEAR_args(code_cell: NotebookCodeCellModel, markdown_cell: NotebookMar
     assert modified_markdown_cell.source == ["Goodnight, World!"]
 
 
-def test_CLEAR_OUTPUTS_code(code_cell, metadata):
-    assert isinstance(CellModifier.Action.CLEAR_OUTPUTS, Callable)
-    modified_code_cell = CellModifier.Action.CLEAR_OUTPUTS(code_cell)
+def test_clear_outputs_code(code_cell, metadata):
+    assert isinstance(actions.clear_outputs, Callable)
+    modified_code_cell = actions.clear_outputs(code_cell)
     assert modified_code_cell.outputs == []
     assert modified_code_cell == NotebookCodeCellModel(
         id="test_code",
@@ -139,23 +144,23 @@ def test_CLEAR_OUTPUTS_code(code_cell, metadata):
     )
 
 
-def test_CLEAR_OUTPUTS_markdown(markdown_cell, metadata, caplog):
-    assert isinstance(CellModifier.Action.CLEAR_OUTPUTS, Callable)
+def test_clear_outputs_markdown(markdown_cell, caplog):
+    assert isinstance(actions.clear_outputs, Callable)
     caplog.clear()
-    modified_markdown_cell = CellModifier.Action.CLEAR_OUTPUTS(markdown_cell)
+    modified_markdown_cell = actions.clear_outputs(markdown_cell)
     assert caplog.messages[0] == "Cell '" + markdown_cell.id + "' has no field 'outputs'. Skipping clearing of outputs."
     assert modified_markdown_cell == markdown_cell
 
 
-def test_REPLACE_IN_SOURCE(code_cell, markdown_cell, metadata, extra_args):
-    assert isinstance(CellModifier.Action.REPLACE_IN_SOURCE, Callable)
-    modified_code_cell = CellModifier.Action.REPLACE_IN_SOURCE(
+def test_replace_in_source(code_cell, markdown_cell, metadata, extra_args):
+    assert isinstance(actions.replace_in_source, Callable)
+    modified_code_cell = actions.replace_in_source(
         code_cell,
         old="Hello",
         new="Goodbye",
         **extra_args
         )
-    modified_markdown_cell = CellModifier.Action.REPLACE_IN_SOURCE(
+    modified_markdown_cell = actions.replace_in_source(
         markdown_cell,
         old="Hello",
         new="Goodbye",
@@ -186,9 +191,9 @@ def test_REPLACE_IN_SOURCE(code_cell, markdown_cell, metadata, extra_args):
     )
 
 
-def test_UPDATE_METADATA(markdown_cell, extra_args):
-    assert isinstance(CellModifier.Action.UPDATE_METADATA, Callable)
-    assert CellModifier.Action.UPDATE_METADATA(
+def test_update_metadata(markdown_cell, extra_args):
+    assert isinstance(actions.update_metadata, Callable)
+    assert actions.update_metadata(
         markdown_cell,
         metadata={"tags": ["new"]},
         **extra_args
@@ -197,17 +202,17 @@ def test_UPDATE_METADATA(markdown_cell, extra_args):
             }
 
 
-def test_REPORT(code_cell, markdown_cell, capsys, extra_args):
-    assert isinstance(CellModifier.Action.REPORT, Callable)
-    assert CellModifier.Action.REPORT(code_cell, logger=print, **extra_args) == code_cell
+def test_report(code_cell, markdown_cell, capsys, extra_args):
+    assert isinstance(actions.report, Callable)
+    assert actions.report(code_cell, logger=print, **extra_args) == code_cell
     captured = capsys.readouterr()
     assert captured.out == "test_code\n"
-    assert CellModifier.Action.REPORT(markdown_cell, logger=print, **extra_args) == markdown_cell
+    assert actions.report(markdown_cell, logger=print, **extra_args) == markdown_cell
     captured = capsys.readouterr()
     assert captured.out == "test_md\n"
 
 
-def test_REPORT_nonexistent_field(code_cell, capsys):
-    assert CellModifier.Action.REPORT(code_cell, field="nonexistent_field") == code_cell
+def test_report_nonexistent_field(code_cell, capsys):
+    assert actions.report(code_cell, field="nonexistent_field") == code_cell
     captured = capsys.readouterr()
     assert captured.out.startswith("Requested field nonexistent_field not found in cell, logging the cell itself")
-- 
GitLab