Repository: AgnostiqHQ/covalent (python)

Issue:
Redispatch workflows: SDK
To support iterative workflow development, we want to introduce the ability to *redispatch* previously run workflows. 
This issue tracks the necessary changes to the SDK. The main task is to implement the `LocalDispatcher.redispatch` static method.

## UX

- `ct.redispatch(dispatch_id)()` — retries workflow with the same inputs
- `ct.redispatch(dispatch_id, replace_electrons={"task_1": task_1}, reuse_previous_results=False)(*new_args, **new_kwargs)`: reruns workflow with all instances of `task_1` replaced by the new definition of `task_1` and new lattice inputs
    - `new_args` and `new_kwargs` are the new lattice inputs
    - electron replacements can modify metadata and function bodies but not function signatures
    - If `reuse_previous_results=True`, try to reuse task results from the previous dispatch.
    
    ```python
    @ct.electron
    def task_1(x):
    	pass
    
    @ct.electron
    def task_2(x):
        return x*x
    
    @ct.lattice
    def workflow(x):
    	task_1(task_2(x))
      task_1(x)
    
    dispatch_id = ct.dispatch(workflow)(5)
    # TG:
    # 5 -> task_2 -> task_1
    # 5 -> task_1
    
    ### later, possibly in a new python module
    
    @ct.electron
    def new_task_1():
    	### new definition of task 1
    
    new_dispatch_id = ct.redispatch(
        dispatch_id, 
        replace_electrons={"task_1": new_task_1}
    )
    
    # TG:
    # 5 -> task_2 -> new_task_1
    # 5 -> new_task_1
    ```
    

## Details

- Signature

```python
@staticmethod
def redispatch(
	dispatch_id: str,
	replace_electrons: Dict[str, Callable],
	reuse_previous_results: bool = False,
	dispatcher_addr: str = None
) -> Callable
```

- Parameters:
    - `dispatch_id` is a previous dispatch
    - `replace_electrons` is a mapping from electron names to functions decorated with `ct.electron`. This indicates the electrons in the previous workflow to modify. For each specified electron name, all electrons with that name in the previous workflow will be replaced by the provided electron.
    - `reuse_previous_results`: indicates whether the user wishes to avoid rerunning previously completed electrons that do not depend on any electrons to be replaced.
- Returns:
    - A **callable** `wrapper` such that
        - `wrapper()` requests Covalent to re-run the workflow `dispatch_id` with the specified electron replacements and with the same lattice inputs as before.
        - `wrapper(*args, **kwargs)` downloads the previous workflow function for `dispatch_id` , rebuilds the transport graph with the provided lattice inputs, and submits the rebuilt lattice as a new dispatch with the specified electron replacements.
        
        The details of these web requests are described below.
        
- [Example UX](https://github.com/AgnostiqHQ/covalent/blob/redispatch-exp/tests/functional_tests/workflow_stack_test.py#L841):
    
    ```python
    @ct.electron
    def sum_task(x, y):
        return x + y
    
    @ct.electron
    def mult_task(x, y):
        return x * y
    
    @ct.electron
    def square_task(x):
        return x * x
    
    @ct.electron
    def cube_task(x):
        return x**3
    
    @ct.lattice
    def workflow(x, y):
        res_1 = sum_task(x, y)
        res_2 = square_task(res_1)
        return res_2
    
    dispatch_id = ct.dispatch(workflow)(2, 3)
    res_obj = ct.get_result(dispatch_id, wait=True)
    assert res_obj.result == 25
    
    # Replace all instances of "square_task" with cube_task
    redispatch_id = ct.redispatch(
    	dispatch_id, 
    	replace_electrons={"square_task": cube_task},
    	)()
    res_obj_2 = ct.get_result(redispatch_id, wait=True)
    assert res_obj_2.result == 125
    
    # Replace all instances of "sum_task" with mult_task,
    # and also rebuild the graph with new lattice inputs (2, 5).
    redispatch_id = ct.redispatch(
    	dispatch_id, 
    	replace_electrons={"sum_task": mult_task}
    	)(2, 5)
    res_obj_3 = ct.get_result(redispatch_id, wait=True)
    assert res_obj_3.result == 100
    
    redispatch_id = ct.redispatch(dispatch_id)(2, 5)
    res_obj_4 = ct.get_result(redispatch_id, wait=True)
    assert res_obj_4.result == 49
    
    # Later...
    @ct.electron(executor="awslambda")
    def square_task(x):
        return x * x
    
    # Replace the executor for `square_task` with "awslambda"
    ct.dispatch(dispatch_id, replace_electrons={"square_task": square_task})()

    ### Example involving `reuse_previous_results`

    redispatch_id = ct.redispatch(
        dispatch_id, replace_electrons={"square_task": cube_task}, reuse_previous_results=True
    )()
    res_obj_5 = ct.get_result(redispatch_id, wait=True)
    assert res_obj_5.result == 125

    # Check that node 0 isn't re-run since it is not a descendent
    # of the modified node
    assert res_obj_5.get_node_result(0)["start_time"] == res_obj_5.get_node_result(0)["end_time"]

    
    ```
    

- Calling `wrapper()` or `wrapper(*args, **kwargs)` makes a POST request to the server endpoint `/api/redispatch` with JSON body
    
    
    ```python
    {
    	"json_lattice": json_lattice | "",
    	"dispatch_id": dispatch_id,
    	"electron_updates": {
    		key_1: ElectronUpdate,
    		key_2: ElectronUpdate,
    		...
    	},
    	"reuse_previous_results": False | True,
    }
    ```
    
    - `json_lattice` is an empty string if the workflow is redispatched with the same inputs - the first case in the “Return” spec above. Otherwise, it is the JSON-serialized lattice for dispatch `dispatch_id` whose compute graph has been rebuilt with the new workflow inputs.
    - The keys in the `“electron_updates"` dictionary are the keys of the `replace_electrons` dictionary. Each ElectronUpdate dictionary represents the parts of the existing electron to modify:
        - function
        - function string
        - metadata
    
    Mimic the implementation of `ct.dispatch()`.
    
- **Schemas**: Let `e` be an Electron object.
    - `ElectronUpdate`
        
        ```python
        {
        	"name": e.function.__name__,
        	"function": TransportObject(e.function).to_dict(),
        	"function_string": get_serialized_function_str(e.function),
        	"metadata": MetadataUpdate(e),
        }
        ```
        
    - `MetadataUpdate(e)` is the JSON-encoded metadata explicitly specified in the electron decorator. For example,
        
        ```python
        dep = ct.DepsPip(["numpy"])
        
        @ct.electron(executor="awslambda", deps_pip=dep)
        def foo(*args, **kwargs)
        	pass
        
        @ct.electron
        def bar(*args, **kwargs)
        
        ct.redispatch(dispatch_id, replace_electrons={"foo": foo, "bar": bar})()
        ```
        
        Produces the following MetadataUpdate JSON
        
        ```python
        # MetadataUpdate(foo)
        {
        	"executor": "awslambda",
        	"executor_data": {},
        	"deps": {
        						"pip": dep.to_dict(),
        					}
        }
        
        # MetadataUpdate(bar)
        {}
        ```
        

## Comments

The main work involved is to generate the `ElectronUpdate` dictionaries. This is not entirely straightforward because applying the electron decorator to a callable does not produce an `Electron` object, but rather a wrapper that instantiates an `Electron` when called, and then immediately either

- Adds a node to the transport graph, if there is an “active lattice”, or
- Calls the underlying function, if there is no active lattice.

One would like to just return the instantiated `Electron` without proceeding to the next step of manipulating the transport graph or invoking the underlying function

In other words, given an expression like

```python
@ct.electron
def foo(*args, **kwargs)
```

we want to somehow get a hold of the object `Electron(function=foo)` in order to access its attributes, such as metadata.

Suggestion: repurpose `DispatchInfo` and `active_dispatch_info_manager`. 

- In the `wrapper` function generated by `decorator_electron`: return the `electron_object` if `active_dispatch_info_manager` is holding onto a DispatchInfo object.

To generate the `ElectronUpdate` for an electron:

- Create a `DispatchInfo` object with the previous `dispatch_id`, and claim it using the `active_dispatch_info_manager` .
- Call the wrapper function with empty arguments.
- Query the returned `Electron` object.

See [here](https://github.com/AgnostiqHQ/covalent/blob/redispatch-exp/covalent/_dispatcher_plugins/utils/redispatch_helpers.py#L43) for example.

## Acceptance Criteria

- [ ] `redispatch` is implemented as a static method of `LocalDispatcher` (`covalent/_dispatcher_plugins/local.py`) and exposed in the main `covalent` namespace
- [ ] Unit tests



Pull request description:
Implementing Covalent re-dispatch functionality
Fixes #1475 

<!--
⚠️ Your pull request title should be short detailed and understandable for all.
⚠️ Increment the version number in the /VERSION file. If this is a bugfix, increment the patch version (the rightmost number), for example 0.18.2 becomes 0.18.3. If it's a new feature, increment the minor version (the middle number), for example 0.18.2 becomes 0.19.0.
⚠️ Add a note to /CHANGELOG.md with your version number and the date, summarizing the changes.
⚠️ If your pull request fixes an open issue, please link to the issue.
⚠️ Rebase the latest changes from the develop branch. Assuming origin points to https://github.com/AgnostiqHQ/covalent, you should run git rebase origin/develop.
-->

- [ ] I have added the tests to cover my changes.
- [ ] I have updated the documentation and CHANGELOG accordingly.
- [ ] I have read the CONTRIBUTING document.


Patch:
diff --git a/.github/workflows/boilerplate.yml b/.github/workflows/boilerplate.yml
index 3484bbb0..a8e3a79e 100644
--- a/.github/workflows/boilerplate.yml
+++ b/.github/workflows/boilerplate.yml
@@ -38,7 +38,7 @@ jobs:
             **/*.py
       - name: Boilerplate
         env:
-          boilerplate: |-
+          boilerplate2021: |-
             # Copyright 2021 Agnostiq Inc.
             #
             # This file is part of Covalent.
@@ -58,9 +58,52 @@ jobs:
             # FITNESS FOR A PARTICULAR PURPOSE. See the License for more details.
             #
             # Relief from the License may be granted by purchasing a commercial license.
+
+          boilerplate2022: |-
+            # Copyright 2022 Agnostiq Inc.
+            #
+            # This file is part of Covalent.
+            #
+            # Licensed under the GNU Affero General Public License 3.0 (the "License").
+            # A copy of the License may be obtained with this software package or at
+            #
+            #      https://www.gnu.org/licenses/agpl-3.0.en.html
+            #
+            # Use of this file is prohibited except in compliance with the License. Any
+            # modifications or derivative works of this file must retain this copyright
+            # notice, and modified files must contain a notice indicating that they have
+            # been altered from the originals.
+            #
+            # Covalent is distributed in the hope that it will be useful, but WITHOUT
+            # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+            # FITNESS FOR A PARTICULAR PURPOSE. See the License for more details.
+            #
+            # Relief from the License may be granted by purchasing a commercial license.
+
+          boilerplate2023: |-
+            # Copyright 2023 Agnostiq Inc.
+            #
+            # This file is part of Covalent.
+            #
+            # Licensed under the GNU Affero General Public License 3.0 (the "License").
+            # A copy of the License may be obtained with this software package or at
+            #
+            #      https://www.gnu.org/licenses/agpl-3.0.en.html
+            #
+            # Use of this file is prohibited except in compliance with the License. Any
+            # modifications or derivative works of this file must retain this copyright
+            # notice, and modified files must contain a notice indicating that they have
+            # been altered from the originals.
+            #
+            # Covalent is distributed in the hope that it will be useful, but WITHOUT
+            # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+            # FITNESS FOR A PARTICULAR PURPOSE. See the License for more details.
+            #
+            # Relief from the License may be granted by purchasing a commercial license.
+
         run: |
           for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
-            if [[ ! $( cat $file | tr -d '\r' ) =~ "$boilerplate" ]] ; then
+            if [[ ! $( cat $file | tr -d '\r' ) =~ "$boilerplate2021" && ! $( cat $file | tr -d '\r' ) =~ "$boilerplate2022" && ! $( cat $file | tr -d '\r' ) =~ "$boilerplate2023" ]] ; then
               printf "Boilerplate is missing from $file.\n"
               printf "The first 20 lines of $file are\n\n"
               cat $file | tr -d '\r' | cat -ET | head -n 20
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6a562f2a..1b283220 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [UNRELEASED]
 
+### Added
+
+- Workflow re-dispatching functionality.
+
 ## [0.214.0-rc.0] - 2023-01-25
 
 ### Authors
diff --git a/covalent/__init__.py b/covalent/__init__.py
index b355087b..df171271 100644
--- a/covalent/__init__.py
+++ b/covalent/__init__.py
@@ -26,6 +26,7 @@ from . import _file_transfer as fs  # nopycln: import
 from . import executor, leptons  # nopycln: import
 from ._dispatcher_plugins import local_dispatch as dispatch  # nopycln: import
 from ._dispatcher_plugins import local_dispatch_sync as dispatch_sync  # nopycln: import
+from ._dispatcher_plugins import local_redispatch as redispatch  # nopycln: import
 from ._file_transfer import strategies as fs_strategies  # nopycln: import
 from ._results_manager.results_manager import cancel, get_result, sync  # nopycln: import
 from ._shared_files.config import get_config, reload_config, set_config  # nopycln: import
diff --git a/covalent/_dispatcher_plugins/__init__.py b/covalent/_dispatcher_plugins/__init__.py
index ea46da1e..0b2ef1da 100644
--- a/covalent/_dispatcher_plugins/__init__.py
+++ b/covalent/_dispatcher_plugins/__init__.py
@@ -23,3 +23,4 @@ from .local import LocalDispatcher
 
 local_dispatch = LocalDispatcher.dispatch
 local_dispatch_sync = LocalDispatcher.dispatch_sync
+local_redispatch = LocalDispatcher.redispatch
diff --git a/covalent/_dispatcher_plugins/local.py b/covalent/_dispatcher_plugins/local.py
index afe2c33c..b2fa9ee9 100644
--- a/covalent/_dispatcher_plugins/local.py
+++ b/covalent/_dispatcher_plugins/local.py
@@ -20,7 +20,7 @@
 
 from copy import deepcopy
 from functools import wraps
-from typing import Callable
+from typing import Callable, Dict, List, Optional
 
 import requests
 
@@ -32,6 +32,37 @@ from .._workflow.lattice import Lattice
 from .base import BaseDispatcher
 
 
+def get_redispatch_request_body(
+    dispatch_id: str,
+    new_args: Optional[List] = None,
+    new_kwargs: Optional[Dict] = None,
+    replace_electrons: Optional[Dict[str, Callable]] = None,
+    reuse_previous_results: bool = False,
+) -> Dict:
+    """Get request body for re-dispatching a workflow."""
+    if new_args is None:
+        new_args = []
+    if new_kwargs is None:
+        new_kwargs = {}
+    if replace_electrons is None:
+        replace_electrons = {}
+    if new_args or new_kwargs:
+        res = get_result(dispatch_id)
+        lat = res.lattice
+        lat.build_graph(*new_args, **new_kwargs)
+        json_lattice = lat.serialize_to_json()
+    else:
+        json_lattice = None
+    updates = {k: v.electron_object.as_transportable_dict for k, v in replace_electrons.items()}
+
+    return {
+        "json_lattice": json_lattice,
+        "dispatch_id": dispatch_id,
+        "electron_updates": updates,
+        "reuse_previous_results": reuse_previous_results,
+    }
+
+
 class LocalDispatcher(BaseDispatcher):
     """
     Local dispatcher which sends the workflow to the locally running
@@ -137,3 +168,30 @@ class LocalDispatcher(BaseDispatcher):
             )
 
         return wrapper
+
+    @staticmethod
+    def redispatch(
+        dispatch_id: str,
+        dispatcher_addr: str = None,
+        replace_electrons: Dict[str, Callable] = None,
+        reuse_previous_results: bool = False,
+    ) -> Callable:
+        if dispatcher_addr is None:
+            dispatcher_addr = (
+                get_config("dispatcher.address") + ":" + str(get_config("dispatcher.port"))
+            )
+
+        if replace_electrons is None:
+            replace_electrons = {}
+
+        def func(*new_args, **new_kwargs):
+            body = get_redispatch_request_body(
+                dispatch_id, new_args, new_kwargs, replace_electrons, reuse_previous_results
+            )
+
+            test_url = f"http://{dispatcher_addr}/api/redispatch"
+            r = requests.post(test_url, json=body)
+            r.raise_for_status()
+            return r.content.decode("utf-8").strip().replace('"', "")
+
+        return func
diff --git a/covalent/_shared_files/utils.py b/covalent/_shared_files/utils.py
index 7db3b607..051bde14 100644
--- a/covalent/_shared_files/utils.py
+++ b/covalent/_shared_files/utils.py
@@ -92,6 +92,11 @@ def get_time(time_delta: timedelta) -> str:
     return f"{days}-{hours}:{minutes}:{seconds}"
 
 
+def filter_null_metadata(meta_dict: dict) -> Dict:
+    """Filter out metadata that is None or empty."""
+    return {k: v for k, v in meta_dict.items() if v}
+
+
 def get_serialized_function_str(function):
     """
     Generates a string representation of a function definition
diff --git a/covalent/_workflow/electron.py b/covalent/_workflow/electron.py
index 501f29cc..8aee091d 100644
--- a/covalent/_workflow/electron.py
+++ b/covalent/_workflow/electron.py
@@ -25,7 +25,7 @@ import operator
 from builtins import list
 from dataclasses import asdict
 from functools import wraps
-from typing import TYPE_CHECKING, Any, Callable, Iterable, List, Optional, Union
+from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Union
 
 from .._file_transfer.enums import Order
 from .._file_transfer.file_transfer import FileTransfer
@@ -40,7 +40,11 @@ from .._shared_files.defaults import (
     prefix_separator,
     sublattice_prefix,
 )
-from .._shared_files.utils import get_named_params, get_serialized_function_str
+from .._shared_files.utils import (
+    filter_null_metadata,
+    get_named_params,
+    get_serialized_function_str,
+)
 from .depsbash import DepsBash
 from .depscall import RESERVED_RETVAL_KEY__FILES, DepsCall
 from .depspip import DepsPip
@@ -524,6 +528,16 @@ class Electron:
             node_id=self.node_id,
         )
 
+    @property
+    def as_transportable_dict(self) -> Dict:
+        """Get transportable electron object and metadata."""
+        return {
+            "name": self.function.__name__,
+            "function": TransportableObject(self.function).to_dict(),
+            "function_string": get_serialized_function_str(self.function),
+            "metadata": filter_null_metadata(self.metadata),
+        }
+
 
 def electron(
     _func: Optional[Callable] = None,
@@ -615,14 +629,18 @@ def electron(
     constraints = encode_metadata(constraints)
 
     def decorator_electron(func=None):
+        """Electron decorator function"""
+        electron_object = Electron(func)
+        for k, v in constraints.items():
+            electron_object.set_metadata(k, v)
+        electron_object.__doc__ = func.__doc__
+
         @wraps(func)
         def wrapper(*args, **kwargs):
-            electron_object = Electron(func)
-            for k, v in constraints.items():
-                electron_object.set_metadata(k, v)
-            electron_object.__doc__ = func.__doc__
             return electron_object(*args, **kwargs)
 
+        wrapper.electron_object = electron_object
+
         return wrapper
 
     if _func is None:  # decorator is called with arguments


Test patch:
diff --git a/tests/covalent_tests/dispatcher_plugins/local_test.py b/tests/covalent_tests/dispatcher_plugins/local_test.py
new file mode 100644
index 00000000..32cc7686
--- /dev/null
+++ b/tests/covalent_tests/dispatcher_plugins/local_test.py
@@ -0,0 +1,98 @@
+# Copyright 2023 Agnostiq Inc.
+#
+# This file is part of Covalent.
+#
+# Licensed under the GNU Affero General Public License 3.0 (the "License").
+# A copy of the License may be obtained with this software package or at
+#
+#      https://www.gnu.org/licenses/agpl-3.0.en.html
+#
+# Use of this file is prohibited except in compliance with the License. Any
+# modifications or derivative works of this file must retain this copyright
+# notice, and modified files must contain a notice indicating that they have
+# been altered from the originals.
+#
+# Covalent is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the License for more details.
+#
+# Relief from the License may be granted by purchasing a commercial license.
+
+
+"""Unit tests for local module in dispatcher_plugins."""
+
+from unittest.mock import MagicMock
+
+import pytest
+
+import covalent as ct
+from covalent._dispatcher_plugins.local import LocalDispatcher, get_redispatch_request_body
+
+
+def test_get_redispatch_request_body_null_arguments():
+    """Test the get request body function with null arguments."""
+
+    @ct.electron
+    def identity(a):
+        return a
+
+    @ct.electron
+    def add(a, b):
+        return a + b
+
+    response = get_redispatch_request_body(
+        "mock-dispatch-id",
+    )
+    assert response == {
+        "json_lattice": None,
+        "dispatch_id": "mock-dispatch-id",
+        "electron_updates": {},
+        "reuse_previous_results": False,
+    }
+
+
+def test_get_redispatch_request_body_args_kwargs(mocker):
+    """Test the get request body function when args/kwargs is not null."""
+    mock_electron = MagicMock()
+    get_result_mock = mocker.patch("covalent._dispatcher_plugins.local.get_result")
+    get_result_mock().lattice.serialize_to_json.return_value = "mock-json-lattice"
+
+    response = get_redispatch_request_body(
+        "mock-dispatch-id",
+        new_args=[1, 2],
+        new_kwargs={"a": 1, "b": 2},
+        replace_electrons={"mock-task-id": mock_electron},
+    )
+    assert response == {
+        "json_lattice": "mock-json-lattice",
+        "dispatch_id": "mock-dispatch-id",
+        "electron_updates": {"mock-task-id": mock_electron.electron_object.as_transportable_dict},
+        "reuse_previous_results": False,
+    }
+    get_result_mock().lattice.build_graph.assert_called_once_with(*[1, 2], **{"a": 1, "b": 2})
+
+
+@pytest.mark.parametrize(
+    "replace_electrons,expected_arg",
+    [(None, {}), ({"mock-electron-1": "mock-electron-2"}, {"mock-electron-1": "mock-electron-2"})],
+)
+def test_redispatch(mocker, replace_electrons, expected_arg):
+    """Test the local re-dispatch function."""
+
+    mocker.patch("covalent._dispatcher_plugins.local.get_config", return_value="mock-config")
+    requests_mock = mocker.patch("covalent._dispatcher_plugins.local.requests")
+    get_request_body_mock = mocker.patch(
+        "covalent._dispatcher_plugins.local.get_redispatch_request_body",
+        return_value={"mock-request-body"},
+    )
+
+    local_dispatcher = LocalDispatcher()
+    func = local_dispatcher.redispatch("mock-dispatch-id", replace_electrons=replace_electrons)
+    func()
+    requests_mock.post.assert_called_once_with(
+        "http://mock-config:mock-config/api/redispatch", json={"mock-request-body"}
+    )
+    requests_mock.post().raise_for_status.assert_called_once()
+    requests_mock.post().content.decode().strip().replace.assert_called_once_with('"', "")
+
+    get_request_body_mock.assert_called_once_with("mock-dispatch-id", (), {}, expected_arg, False)
diff --git a/tests/covalent_tests/shared_files/utils_test.py b/tests/covalent_tests/shared_files/utils_test.py
new file mode 100644
index 00000000..46613452
--- /dev/null
+++ b/tests/covalent_tests/shared_files/utils_test.py
@@ -0,0 +1,40 @@
+# Copyright 2023 Agnostiq Inc.
+#
+# This file is part of Covalent.
+#
+# Licensed under the GNU Affero General Public License 3.0 (the "License").
+# A copy of the License may be obtained with this software package or at
+#
+#      https://www.gnu.org/licenses/agpl-3.0.en.html
+#
+# Use of this file is prohibited except in compliance with the License. Any
+# modifications or derivative works of this file must retain this copyright
+# notice, and modified files must contain a notice indicating that they have
+# been altered from the originals.
+#
+# Covalent is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the License for more details.
+#
+# Relief from the License may be granted by purchasing a commercial license.
+
+
+"""Unit tests for Covalent shared util functions."""
+
+import pytest
+
+from covalent._shared_files.utils import filter_null_metadata
+
+
+@pytest.mark.parametrize(
+    "meta_dict,expected",
+    [
+        ({}, {}),
+        ({"a": 1}, {"a": 1}),
+        ({"a": 1, "b": None}, {"a": 1}),
+    ],
+)
+def test_filter_null_metadata(meta_dict, expected):
+    """Test the filter null metadata function."""
+    filtered = filter_null_metadata(meta_dict)
+    assert filtered == expected
diff --git a/tests/covalent_tests/workflow/electron_test.py b/tests/covalent_tests/workflow/electron_test.py
index 3e368a1a..a9103a69 100644
--- a/tests/covalent_tests/workflow/electron_test.py
+++ b/tests/covalent_tests/workflow/electron_test.py
@@ -22,7 +22,12 @@
 
 import covalent as ct
 from covalent._shared_files.context_managers import active_lattice_manager
-from covalent._workflow.electron import Electron, to_decoded_electron_collection
+from covalent._workflow.electron import (
+    Electron,
+    filter_null_metadata,
+    get_serialized_function_str,
+    to_decoded_electron_collection,
+)
 from covalent._workflow.transport import TransportableObject, _TransportGraph, encode_metadata
 from covalent.executor.executor_plugins.local import LocalExecutor
 
@@ -256,3 +261,21 @@ def test_autogen_dict_electrons():
     assert g.nodes[2]["value"].get_deserialized() == 5
     assert g.nodes[3]["value"].get_deserialized() == 7
     assert set(g.edges) == set([(1, 0, 0), (2, 1, 0), (3, 1, 0)])
+
+
+def test_as_transportable_dict(mocker):
+    """Test the get transportable electron function."""
+
+    @ct.electron
+    def test_func(a):
+        return a
+
+    mock_metadata = {"a": 1, "b": 2, "c": None}
+    # Construct bound electron, i.e. electron with non-null function and node_id
+    electron = Electron(function=test_func, node_id=1, metadata=mock_metadata)
+    transportable_electron = electron.as_transportable_dict
+
+    assert transportable_electron["name"] == "test_func"
+    assert transportable_electron["metadata"] == filter_null_metadata(mock_metadata)
+    assert transportable_electron["function_string"] == get_serialized_function_str(test_func)
+    assert TransportableObject(test_func).to_dict() == transportable_electron["function"]