Repository: AgnostiqHQ/covalent (python)

Issue:
Ability to update the executor of a pre-defined or imported `Electron` object
### What should we add?

There should be some clean way for the user to update the executor of an `Electron` once it is has been defined or imported from some external Python package. See #1646 for the full context.

@santoshkumarradha has good suggestions here: https://github.com/AgnostiqHQ/covalent/discussions/1646#discussioncomment-5901761

> hey @arosen93 , this is a long backlogged item on our plate. Ideally the best UX is to have something like
> 
> ### UX1
> ```python
> from my_package import energy_electron # imports a direct electron object
> 
> energy_electron.executor=MyExecutor()
> ```
> 
> Could @kessler-frost confirm if using `energy_electron.metadata["executor"]=MyExecutor()` would be a valid solution? I'm uncertain if it would properly set everything needed. I recall encountering some issues with UX1 in the past, but perhaps they have since been resolved and we can revisit the matter.
> 
> Meanwhile I am just curious if using a UX2 would be a good fit for creating custom electron packages.
> 
> ### UX2
> ```python
> from my_package import energy_electron
> small_cell=energy_electron(**parameters,executor=MyExecutor(num_cpu=2)) # Returns an electron function 
> super_cell=energy_electron(**parameters,executor=MyExecutor(num_cpu=45)) # Returns an electron function 
> 
> @ct.lattice
> def workflow():
>     energy_supercell=super_cell("NaCl") #different resource electron
>     energy_smallcell=small_cell("NaCl") #different resource electron
> ```
> 
> In certain scenarios, UX2 may be more suitable as it allows for the creation of multiple electron instances with unique parameters through a factory pattern. This is not achievable in UX1, which is designed to return a singleton electron instance with a specific tied instance. Infact our initial interest in UX1 was coming in from a perspective of changing executor inside an interactive notebook after electrons have been defined.
> 
> LMK your thoughts.


### Describe alternatives you've considered.

Modifying the `electron_object.metadata` directly, but this doesn't seem like a "suggested" approach.

Pull request description:
Adding `executor` as a property
<!--
⚠️ 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.
-->

- [x] I have added the tests to cover my changes.
- [x] I have updated the documentation and CHANGELOG accordingly.
- [x] I have read the CONTRIBUTING document.

Resolves #1647 


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d1677767..db48bdf9 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
+
+- `executor` property to `Electron` class, allowing updation of executor after electron function definition
+
 ### Operations
 
 - Nightly frequency set to midnight EST 
diff --git a/covalent/_workflow/electron.py b/covalent/_workflow/electron.py
index 0c753551..db6f168e 100644
--- a/covalent/_workflow/electron.py
+++ b/covalent/_workflow/electron.py
@@ -386,9 +386,7 @@ class Electron:
                 and k in DEFAULT_METADATA_VALUES
                 and not self.get_metadata(k)
             ):
-                meta = active_lattice.get_metadata(k)
-                if not meta:
-                    meta = DEFAULT_METADATA_VALUES[k]
+                meta = active_lattice.get_metadata(k) or DEFAULT_METADATA_VALUES[k]
                 self.set_metadata(k, meta)
 
         # Handle sublattices by injecting _build_sublattice_graph node
@@ -637,6 +635,16 @@ class Electron:
         }
 
 
+# Dynamically adding properties to the Electron class
+# This is being done this way so that it's easier to add
+# or remove properties in the future without having to
+# edit the class definition itself.
+Electron.executor = property(lambda self: self.get_metadata("executor"))
+Electron.executor = Electron.executor.setter(
+    lambda self, value: self.set_metadata("executor", value)
+)
+
+
 def electron(
     _func: Optional[Callable] = None,
     *,
@@ -648,8 +656,9 @@ def electron(
     deps_pip: Union[DepsPip, list] = None,
     call_before: Union[List[DepsCall], DepsCall] = [],
     call_after: Union[List[DepsCall], DepsCall] = [],
-) -> Callable:
-    """Electron decorator to be called upon a function. Returns the wrapper function with the same functionality as `_func`.
+) -> Callable:  # sourcery skip: assign-if-exp
+    """
+    Electron decorator to be called upon a function. Returns the wrapper function with the same functionality as `_func`.
 
     Args:
         _func: function to be decorated
@@ -666,6 +675,7 @@ def electron(
 
     Returns:
         :obj:`Electron <covalent._workflow.electron.Electron>` : Electron object inside which the decorated function exists.
+
     """
 
     if backend:
@@ -727,7 +737,13 @@ def electron(
     constraints = encode_metadata(constraints)
 
     def decorator_electron(func=None):
-        """Electron decorator function. Note that the electron_object defined below is an example of an unbound electron, i.e. electron without a node id."""
+        """
+        Electron decorator function. Note that the electron_object
+        defined below is an example of an unbound electron, i.e.
+        electron without a node id.
+
+        """
+
         electron_object = Electron(func)
         for k, v in constraints.items():
             electron_object.set_metadata(k, v)


Test patch:
diff --git a/tests/covalent_tests/workflow/electron_test.py b/tests/covalent_tests/workflow/electron_test.py
index 03acdb5d..717679bd 100644
--- a/tests/covalent_tests/workflow/electron_test.py
+++ b/tests/covalent_tests/workflow/electron_test.py
@@ -484,3 +484,18 @@ def test_electron_auto_task_groups_iter():
     assert getitem_x_gid == tup_electron_gid
     assert getitem_y_gid == tup_electron_gid
     assert all(tg.get_node_value(i, "task_group_id") == i for i in [2, 4, 5, 6])
+
+
+def test_electron_executor_property():
+    """
+    Test that the executor property assignment
+    of an electron works as expected.
+    """
+
+    @ct.electron
+    def mock_task():
+        pass
+
+    mock_task_electron = mock_task.electron_object
+    mock_task_electron.executor = "mock"
+    assert mock_task_electron.metadata["executor"] == "mock"