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