Repository: softlayer/softlayer-python
Pull request #659 (2015-12-04 20:53:14)

Adjusts how filters work with call-api. Adds --output-python option
I'm not 100% on the change as is. There are a couple things I want to solve here:

 * Inserting JSON through a CLI command feels really bad to me. We're very close to just making REST calls with curl at this point. Therefore, I changed the way filters are defined. Instead of accepting JSON, you can specify filters with the format `-f 'property=value'`. You can also filter into relational properties with a dot-delimited format. E.G. `-f nested.property=value`. `-f` can be specified multiple times.
 * Since this is an experimentation tool, it should provide a way to convert these commands into real code. There seems to be a need for several features which go beyond this goal: complex arguments, filters, etc. For these cases we can do what we can but at a certain point code will need to be written either in another slcli command or with user-created scripts. To aid in this, the `--output-python` option was added to `call-api`. This outputs python code that shows an example of using softlayer-python to make the API call that `call-api` would have made. For example:

```
> slcli call-api Account getVirtualGuests \
                                         -f 'virtualGuests.datacenter.name=dal05' \
                                         -f 'virtualGuests.maxCpu=4' \
                                         --mask=id,hostname,datacenter.name,maxCpu test \
                                         --limit=10 \
                                         --output-python
import SoftLayer

client = SoftLayer.create_client_from_env()
result = client.call(u'Account',
                     u'getVirtualGuests',
                     u'test',
                     filter={u'virtualGuests': {u'datacenter': {u'name': {'operation': u'_= dal05'}}, u'maxCpu': {'operation': 4}}},
                     limit=1,
                     mask=u'id,hostname,datacenter.name,maxCpu')
```

allmightyspiff: The -f option makes a lot of sense to me, and seems much more manageable than doing raw json.

My only complaint is that there doesn't seem to be any way to manage date filters in the command line. That might require a different switch though, due to how different filtering based off dates is from everything else.

if you decide not to include date filters, might be a good idea to mention that in the help section somewhere.

Patch:
diff --git a/SoftLayer/CLI/call_api.py b/SoftLayer/CLI/call_api.py
index 11afe683..31b0a76c 100644
--- a/SoftLayer/CLI/call_api.py
+++ b/SoftLayer/CLI/call_api.py
@@ -1,21 +1,47 @@
 """Call arbitrary API endpoints."""
-import json
-
 import click
 
 from SoftLayer.CLI import environment
 from SoftLayer.CLI import formatting
+from SoftLayer.CLI import helpers
+from SoftLayer import utils
+
+
+def _build_filters(_filters):
+    """Builds filters using the filter options passed into the CLI.
+
+    This only supports the equals keyword at the moment.
+    """
+    root = utils.NestedDict({})
+    for _filter in _filters:
+        # split "some.key=value" into ["some.key", "value"]
+        key, value = _filter.split('=', 1)
+
+        current = root
+        # split "some.key" into ["some", "key"]
+        parts = [part.strip() for part in key.split('.')]
 
-# pylint: disable=unused-argument
+        # Actually drill down and add the filter
+        for part in parts[:-1]:
+            current = current[part]
+        current[parts[-1]] = utils.query_filter(value.strip())
 
+    return root.to_dict()
 
-def validate_filter(ctx, param, value):
-    """Try to parse the given filter as a JSON string."""
-    try:
-        if value:
-            return json.loads(value)
-    except ValueError:
-        raise click.BadParameter('filters need to be in JSON format')
+
+def _build_python_example(args, kwargs):
+    sorted_kwargs = sorted(kwargs.items())
+
+    call_str = 'import SoftLayer\n\n'
+    call_str += 'client = SoftLayer.create_client_from_env()\n'
+    call_str += 'result = client.call('
+    arg_list = [repr(arg) for arg in args]
+    arg_list += [key + '=' + repr(value)
+                 for key, value in sorted_kwargs if value]
+    call_str += ',\n                     '.join(arg_list)
+    call_str += ')'
+
+    return call_str
 
 
 @click.command('call', short_help="Call arbitrary API endpoints.")
@@ -23,14 +49,18 @@ def validate_filter(ctx, param, value):
 @click.argument('method')
 @click.argument('parameters', nargs=-1)
 @click.option('--id', '_id', help="Init parameter")
-@click.option('--filter', '_filter',
-              callback=validate_filter,
-              help="Object filter in a JSON string")
+@helpers.multi_option('--filter', '-f', '_filters',
+                      help="Object filters. This should be of the form: "
+                      "'property=value' or 'nested.property=value'. Complex "
+                      "filters like betweenDate are not currently supported.")
 @click.option('--mask', help="String-based object mask")
 @click.option('--limit', type=click.INT, help="Result limit")
 @click.option('--offset', type=click.INT, help="Result offset")
+@click.option('--output-python / --no-output-python',
+              help="Show python example code instead of executing the call")
 @environment.pass_env
-def cli(env, service, method, parameters, _id, _filter, mask, limit, offset):
+def cli(env, service, method, parameters, _id, _filters, mask, limit, offset,
+        output_python=False):
     """Call arbitrary API endpoints with the given SERVICE and METHOD.
 
     \b
@@ -40,11 +70,23 @@ def cli(env, service, method, parameters, _id, _filter, mask, limit, offset):
     slcli call-api Virtual_Guest getObject --id=12345
     slcli call-api Metric_Tracking_Object getBandwidthData --id=1234 \\
         "2015-01-01 00:00:00" "2015-01-1 12:00:00" public
+    slcli call-api Account getVirtualGuests \\
+        -f 'virtualGuests.datacenter.name=dal05' \\
+        -f 'virtualGuests.maxCpu=4' \\
+        --mask=id,hostname,datacenter.name,maxCpu
     """
-    result = env.client.call(service, method, *parameters,
-                             id=_id,
-                             filter=_filter,
-                             mask=mask,
-                             limit=limit,
-                             offset=offset)
-    env.fout(formatting.iter_to_table(result))
+
+    args = [service, method] + list(parameters)
+    kwargs = {
+        'id': _id,
+        'filter': _build_filters(_filters),
+        'mask': mask,
+        'limit': limit,
+        'offset': offset,
+    }
+
+    if output_python:
+        env.out(_build_python_example(args, kwargs))
+    else:
+        result = env.client.call(*args, **kwargs)
+        env.fout(formatting.iter_to_table(result))


Test patch:
diff --git a/tests/CLI/modules/call_api_tests.py b/tests/CLI/modules/call_api_tests.py
index 14164efa..79d12e4b 100644
--- a/tests/CLI/modules/call_api_tests.py
+++ b/tests/CLI/modules/call_api_tests.py
@@ -4,13 +4,62 @@
 
     :license: MIT, see LICENSE for more details.
 """
+import json
+
+from SoftLayer.CLI import call_api
 from SoftLayer import testing
 
-import json
+
+class BuildFilterTests(testing.TestCase):
+
+    def test_empty(self):
+        assert call_api._build_filters([]) == {}
+
+    def test_basic(self):
+        result = call_api._build_filters(['property=value'])
+        assert result == {'property': {'operation': '_= value'}}
+
+    def test_nested(self):
+        result = call_api._build_filters(['nested.property=value'])
+        assert result == {'nested': {'property': {'operation': '_= value'}}}
+
+    def test_multi(self):
+        result = call_api._build_filters(['prop1=value1', 'prop2=prop2'])
+        assert result == {
+            'prop1': {'operation': '_= value1'},
+            'prop2': {'operation': '_= prop2'},
+        }
 
 
 class CallCliTests(testing.TestCase):
 
+    def test_python_output(self):
+        result = self.run_command(['call-api', 'Service', 'method',
+                                   '--mask=some.mask',
+                                   '--limit=20',
+                                   '--offset=40',
+                                   '--id=100',
+                                   '-f nested.property=5432',
+                                   '--output-python'])
+
+        self.assertEqual(result.exit_code, 0)
+        # NOTE(kmcdonald): Python 3 no longer inserts 'u' before unicode
+        # string literals but python 2 does. These are stripped out to make
+        # this test pass on both python versions.
+        stripped_output = result.output.replace("u'", "'")
+        self.assertIsNotNone(stripped_output, """import SoftLayer
+
+client = SoftLayer.create_client_from_env()
+result = client.call(u'Service',
+                     u'method',
+                     filter={u'nested': {u'property': {'operation': 5432}}},
+                     id=u'100',
+                     limit=20,
+                     mask=u'some.mask',
+                     offset=40)
+""")
+        self.assertEqual(self.calls(), [], "no API calls were made")
+
     def test_options(self):
         mock = self.set_mock('SoftLayer_Service', 'method')
         mock.return_value = 'test'
@@ -19,7 +68,9 @@ def test_options(self):
                                    '--mask=some.mask',
                                    '--limit=20',
                                    '--offset=40',
-                                   '--id=100'])
+                                   '--id=100',
+                                   '-f property=1234',
+                                   '-f nested.property=5432'])
 
         self.assertEqual(result.exit_code, 0)
         self.assertEqual(json.loads(result.output), 'test')
@@ -27,7 +78,11 @@ def test_options(self):
                                 mask='mask[some.mask]',
                                 limit=20,
                                 offset=40,
-                                identifier='100')
+                                identifier='100',
+                                filter={
+                                    'property': {'operation': 1234},
+                                    'nested': {'property': {'operation': 5432}}
+                                })
 
     def test_object(self):
         mock = self.set_mock('SoftLayer_Service', 'method')
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..b9f9ca83
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,3 @@
+import logging
+
+logging.basicConfig(level=logging.DEBUG)