Repository: simonsobs/ocs

Issue:
Calling a param decorated operation directly with no params results in hidden error
If an Operation is decorated with `@ocs_agent.param` and is called directly with `OCSAgent.start()`, as might be done within an Agent (i.e. in the [Lakeshore 372 Agent](https://github.com/simonsobs/socs/blob/5798dcf1e7c3f86eb726905460bbf193f3feb03d/agents/lakeshore372/LS372_agent.py#L208)), but without any parameters, then the `ParamHandler` is going to fail and an `AttributeError` will be caught.

However, this error doesn't propagate to the client, because there is no associated session (which returns on OK status.) To the Client it looks like the operation has never run, and no indication of a problem is made in the Agent logs, they only reflect that the start call was made.

This was encountered when developing the Lakeshore425 Agent: https://github.com/simonsobs/socs/pull/225

I think we should do a few things:
1. Log the caught exceptions within `start()` in the Agent logs
2. Add a test for `ParamHandler` for this scenario
3. Handle params being `None` within `ParamHandler`, taking the defaults set in the decorator

The ParamHandler runs into issues on this line: https://github.com/simonsobs/ocs/blob/aa6fda03e9e1e82f0c318e15d6d5f48c9b550836/ocs/ocs_agent.py#L1208

**OCS Version:** 0.9.0+10

Discussion:

Patch:
diff --git a/ocs/ocs_agent.py b/ocs/ocs_agent.py
index fc06a6e..1efc9db 100644
--- a/ocs/ocs_agent.py
+++ b/ocs/ocs_agent.py
@@ -694,8 +694,10 @@ class OCSAgent(ApplicationSession):
                     handler = ParamHandler(params)
                     params = handler.batch(op.launcher._ocs_prescreen)
                 except ParamError as err:
+                    self.log.error("Caught ParamError during start call: {err}", err=err)
                     return (ocs.ERROR, err.msg, {})
                 except Exception as err:
+                    self.log.error("Caught Exception during start call: {err}", err=err)
                     return (ocs.ERROR, f'CRASH: during param pre-processing: {str(err)}', {})
 
             # Mark as started.
@@ -1302,6 +1304,8 @@ class ParamHandler:
     """
 
     def __init__(self, params):
+        if params is None:
+            params = {}
         self._params = params
         self._checked = set()
 
@@ -1389,6 +1393,9 @@ class ParamHandler:
                 # Free cast from int to float.
                 if type is float and isinstance(value, int):
                     value = float(value)
+                # Fix type after json conversion
+                if type is tuple and isinstance(value, list) and cast in [tuple, None]:
+                    value = tuple(value)
                 if not isinstance(value, type):
                     raise ParamError(f"Param '{key}'={value} is not of required type ({type})")
             if choices is not None:


Test patch:
diff --git a/tests/test_ocs_agent.py b/tests/test_ocs_agent.py
index e6c1a83..53cc3d2 100644
--- a/tests/test_ocs_agent.py
+++ b/tests/test_ocs_agent.py
@@ -43,6 +43,12 @@ def tfunc_raise(session, a):
     return tfunc(session, a)
 
 
+@param('test', default=1)
+def tfunc_param_dec(session, a):
+    """tfunc but decorated with @ocs_agent.param."""
+    return True, 'Task completed successfully'
+
+
 @pytest.fixture
 def mock_agent():
     """Test fixture to setup a mocked OCSAgent.
@@ -261,6 +267,19 @@ def test_start_unregistered_task(mock_agent):
     assert res[2] == {}
 
 
+def test_start_task_none_params(mock_agent):
+    """Test passing params=None to task decorated with @param that has set
+    defaults.
+
+    See issue: https://github.com/simonsobs/ocs/issues/251
+
+    """
+    mock_agent.register_task('test_task', tfunc_param_dec)
+    res = mock_agent.start('test_task', params=None)
+    print(res)
+    assert res[0] == ocs.OK
+
+
 # Wait
 @pytest_twisted.inlineCallbacks
 def test_wait(mock_agent):
@@ -503,12 +522,14 @@ def test_params_get():
         'float_param': 1e8,
         'numerical_string_param': '145.12',
         'none_param': None,
+        'tuple_param': [20., 120.],  # gets cast to list by json conversion
     })
 
     # Basic successes
     params.get('int_param', type=int)
     params.get('string_param', type=str)
     params.get('float_param', type=float)
+    params.get('tuple_param', type=tuple)
 
     # Tricky successes
     params.get('int_param', type=float)