Repository: dwavesystems/dwave-cloud-client

Issue:
Support nested property dictionaries in solver filtering kwargs
To select solvers with, e.g. `{"topology": {"shape": "chimera"}}` property, we'll say:
```
client.get_solvers(topology__shape="chimera")
```
or
```
client.get_solvers(topology__shape__eq="chimera")
```

Discussion:

Patch:
diff --git a/dwave/cloud/client.py b/dwave/cloud/client.py
index 7008cd9..d468257 100644
--- a/dwave/cloud/client.py
+++ b/dwave/cloud/client.py
@@ -602,9 +602,10 @@ class Client(object):
                 See `Filtering forms` and `Operators` below.
 
         Solver filters are defined, similarly to Django QuerySet filters, with
-        keyword arguments of form `<name>__<operator>=<value>`. Each `<operator>`
-        is a predicate (boolean) function that acts on two arguments: value of
-        feature `<name>` and the required `<value>`.
+        keyword arguments of form `<key1>__...__<keyN>[__<operator>]=<value>`.
+        Each `<operator>` is a predicate (boolean) function that acts on two
+        arguments: value of feature `<name>` (described with keys path
+        `<key1.key2...keyN>`) and the required `<value>`.
 
         Feature `<name>` can be:
 
@@ -613,6 +614,7 @@ class Client(object):
            `num_active_qubits`, `avg_load`)
         2) a solver parameter, available in :obj:`Solver.parameters`
         3) a solver property, available in :obj:`Solver.properties`
+        4) a path describing a property in nested dictionaries
 
         Filtering forms are:
 
@@ -727,7 +729,8 @@ class Client(object):
                                                     # require Ising, QUBO or both to be supported
                 name='DW_2000Q_3',                  # full solver name/ID match
                 name__regex='.*2000.*',             # partial/regex-based solver name match
-                chip_id__regex='DW_.*'              # chip ID prefix must be DW_
+                chip_id__regex='DW_.*',             # chip ID prefix must be DW_
+                topology__type__eq="chimera"        # topology.type must be chimera
             )
         """
 
@@ -792,18 +795,38 @@ class Client(object):
             'issuperset': with_valid_lhs(lambda prop, val: _set(prop).issuperset(_set(val))),
         }
 
-        def predicate(solver, name, opname, val):
-            if name in solver.derived_properties:
-                op = ops[opname or 'eq']
-                return op(getattr(solver, name), val)
-            elif name in solver.parameters:
-                op = ops[opname or 'available']
-                return op(solver.parameters[name], val)
-            elif name in solver.properties:
-                op = ops[opname or 'eq']
-                return op(solver.properties[name], val)
+        def predicate(solver, query, val):
+            # needs to handle kwargs like these:
+            #  key=val
+            #  key__op=val
+            #  key__key=val
+            #  key__key__op=val
+            # LHS is split on __ in `query`
+            assert len(query) >= 1
+
+            potential_path, potential_op_name = query[:-1], query[-1]
+
+            if potential_op_name in ops:
+                # op is explicit, and potential path is correct
+                op_name = potential_op_name
+            else:
+                # op is implied and depends on property type, path is the whole query
+                op_name = None
+                potential_path = query
+
+            path = '.'.join(potential_path)
+
+            if path in solver.derived_properties:
+                op = ops[op_name or 'eq']
+                return op(getattr(solver, path), val)
+            elif pluck(solver.parameters, path, None) is not None:
+                op = ops[op_name or 'available']
+                return op(pluck(solver.parameters, path), val)
+            elif pluck(solver.properties, path, None) is not None:
+                op = ops[op_name or 'eq']
+                return op(pluck(solver.properties, path), val)
             else:
-                op = ops[opname or 'eq']
+                op = ops[op_name or 'eq']
                 return op(None, val)
 
         # param validation
@@ -828,8 +851,8 @@ class Client(object):
 
         predicates = []
         for lhs, val in filters.items():
-            propname, opname = (lhs.rsplit('__', 1) + [None])[:2]
-            predicates.append(partial(predicate, name=propname, opname=opname, val=val))
+            query = lhs.split('__')
+            predicates.append(partial(predicate, query=query, val=val))
 
         _LOGGER.debug("Filtering solvers with predicates=%r", predicates)
 


Test patch:
diff --git a/tests/test_client.py b/tests/test_client.py
index 9d2bae4..f6c3a75 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -246,6 +246,10 @@ class FeatureBasedSolverSelection(unittest.TestCase):
                 "parameters": {
                     "num_reads": "Number of samples to return.",
                     "postprocess": "either 'sampling' or 'optimization'"
+                },
+                "topology": {
+                    "type": "chimera",
+                    "shape": [16, 16, 4]
                 }
             },
             "id": "solver1",
@@ -264,6 +268,10 @@ class FeatureBasedSolverSelection(unittest.TestCase):
                     "flux_biases": "Supported ...",
                     "anneal_schedule": "Supported ..."
                 },
+                "topology": {
+                    "type": "pegasus",
+                    "shape": [6, 6, 12]
+                },
                 "vfyc": True
             },
             "id": "solver2",
@@ -278,6 +286,10 @@ class FeatureBasedSolverSelection(unittest.TestCase):
                 "num_reads_range": [0, 1000],
                 "parameters": {"num_reads": "Number of samples to return."},
                 "vfyc": False,
+                "topology": {
+                    "type": "chimera",
+                    "shape": [4, 4, 4]
+                },
                 # the following are only present in this solver
                 "some_set": [1, 2],
                 "some_range": [1, 2],
@@ -463,6 +475,17 @@ class FeatureBasedSolverSelection(unittest.TestCase):
         self.assertSolvers(self.client.get_solvers(num_qubits__within=(3, 6), flux_biases=True), [self.solver2])
         self.assertSolvers(self.client.get_solvers(num_qubits=5, flux_biases=True), [self.solver2])
 
+    def test_nested_properties_leaf_lookup(self):
+        self.assertSolvers(self.client.get_solvers(topology__type="chimera"), [self.solver1, self.solver3])
+        self.assertSolvers(self.client.get_solvers(topology__type="pegasus"), [self.solver2])
+        self.assertSolvers(self.client.get_solvers(topology__type__eq="pegasus"), [self.solver2])
+        self.assertSolvers(self.client.get_solvers(topology__shape=[6,6,12]), [self.solver2])
+        self.assertSolvers(self.client.get_solvers(topology__type="chimera", topology__shape__contains=16), [self.solver1])
+
+    def test_nested_properties_intermediate_key_lookup(self):
+        self.assertSolvers(self.client.get_solvers(topology__contains="shape"), self.solvers)
+        self.assertSolvers(self.client.get_solvers(topology={"type": "pegasus", "shape": [6, 6, 12]}), [self.solver2])
+
     def test_anneal_schedule(self):
         self.assertSolvers(self.client.get_solvers(anneal_schedule__available=True), [self.solver2])
         self.assertSolvers(self.client.get_solvers(anneal_schedule=True), [self.solver2])