Repository: Qiskit/qiskit

Issue:
Default UnitarySynthesis method fails as a fallback when plugin.supports_basis_gates = False
<!-- ⚠️ If you do not respect this template, your issue will be closed -->
<!-- ⚠️ Make sure to browse the opened and closed issues -->

### Information

- **Qiskit Terra version**: #6727 @ f897673b5d
- **Python version**: 3.6
- **Operating system**: osx 10.15

### What is the current behavior?

When attempting to use a `UnitarySynthesis` plugin (e.g. `AQCSynthesisPlugin` from #6727) which defines `supports_basis_gates = False`, the `UnitarySynthesis` pass will fail with:
```
~/q/qiskit-terra/qiskit/transpiler/passes/synthesis/unitary_synthesis.py in run(self, dag)
    219                 plugin_method.min_qubits is not None and n_qubits < plugin_method.min_qubits
    220             ):
--> 221                 synth_dag = default_method.run(unitary, **kwargs)
    222             else:
    223                 synth_dag = plugin_method.run(unitary, **kwargs)

~/q/qiskit-terra/qiskit/transpiler/passes/synthesis/unitary_synthesis.py in run(self, unitary, **options)
    303         # it's 1.
    304         approximation_degree = getattr(self, "_approximation_degree", 1)
--> 305         basis_gates = options["basis_gates"]
    306         coupling_map = options["coupling_map"][0]
    307         natural_direction = options["natural_direction"]

KeyError: 'basis_gates'
```

### Steps to reproduce the problem

```
import numpy as np
qc = qk.QuantumCircuit(2)
qc.unitary(np.eye(4), [0,1])

qk.transpile(qc, basis_gates=['u', 'cx'], unitary_synthesis_method='aqc')
```


### What is the expected behavior?



### Suggested solutions

From https://github.com/Qiskit/qiskit-terra/blob/6e0926fb65fc96138b62b518e2393475c3bc876f/qiskit/transpiler/passes/synthesis/unitary_synthesis.py#L186 ,
If looks like `kwargs['basis_gates']` is only set depending on the `options` of the plugin, but is expected by the `default` plugin which may be called if a circuit unitary cannot be handled by the plugin.



Discussion:

Resolving patch:
diff --git a/qiskit/transpiler/passes/synthesis/unitary_synthesis.py b/qiskit/transpiler/passes/synthesis/unitary_synthesis.py
--- a/qiskit/transpiler/passes/synthesis/unitary_synthesis.py
+++ b/qiskit/transpiler/passes/synthesis/unitary_synthesis.py
@@ -178,24 +178,44 @@ def run(self, dag: DAGCircuit) -> DAGCircuit:
         if not self._synth_gates:
             return dag
 
-        default_method = self.plugins.ext_plugins["default"].obj
         plugin_method = self.plugins.ext_plugins[self.method].obj
-        if plugin_method.supports_coupling_map:
-            dag_bit_indices = {bit: idx for idx, bit in enumerate(dag.qubits)}
-        kwargs = {}
-        if plugin_method.supports_basis_gates:
-            kwargs["basis_gates"] = self._basis_gates
-        if plugin_method.supports_natural_direction:
-            kwargs["natural_direction"] = self._natural_direction
-        if plugin_method.supports_pulse_optimize:
-            kwargs["pulse_optimize"] = self._pulse_optimize
-        if plugin_method.supports_gate_lengths:
-            kwargs["gate_lengths"] = _build_gate_lengths(self._backend_props)
-        if plugin_method.supports_gate_errors:
-            kwargs["gate_errors"] = _build_gate_errors(self._backend_props)
-        supported_bases = plugin_method.supported_bases
-        if supported_bases is not None:
-            kwargs["matched_basis"] = _choose_bases(self._basis_gates, supported_bases)
+        plugin_kwargs = {}
+        _gate_lengths = _gate_errors = None
+        dag_bit_indices = {}
+
+        if self.method == "default":
+            # If the method is the default, we only need to evaluate one set of keyword arguments.
+            # To simplify later logic, and avoid cases where static analysis might complain that we
+            # haven't initialised the "default" handler, we rebind the names so they point to the
+            # same object as the chosen method.
+            default_method = plugin_method
+            default_kwargs = plugin_kwargs
+            method_list = [(plugin_method, plugin_kwargs)]
+        else:
+            # If the method is not the default, we still need to initialise the default plugin's
+            # keyword arguments in case we have to fall back on it during the actual run.
+            default_method = self.plugins.ext_plugins["default"].obj
+            default_kwargs = {}
+            method_list = [(plugin_method, plugin_kwargs), (default_method, default_kwargs)]
+
+        for method, kwargs in method_list:
+            if method.supports_basis_gates:
+                kwargs["basis_gates"] = self._basis_gates
+            if method.supports_coupling_map:
+                dag_bit_indices = dag_bit_indices or {bit: i for i, bit in enumerate(dag.qubits)}
+            if method.supports_natural_direction:
+                kwargs["natural_direction"] = self._natural_direction
+            if method.supports_pulse_optimize:
+                kwargs["pulse_optimize"] = self._pulse_optimize
+            if method.supports_gate_lengths:
+                _gate_lengths = _gate_lengths or _build_gate_lengths(self._backend_props)
+                kwargs["gate_lengths"] = _gate_lengths
+            if method.supports_gate_errors:
+                _gate_errors = _gate_errors or _build_gate_errors(self._backend_props)
+                kwargs["gate_errors"] = _gate_errors
+            supported_bases = method.supported_bases
+            if supported_bases is not None:
+                kwargs["matched_basis"] = _choose_bases(self._basis_gates, supported_bases)
 
         # Handle approximation degree as a special case for backwards compatibility, it's
         # not part of the plugin interface and only something needed for the default
@@ -207,20 +227,21 @@ def run(self, dag: DAGCircuit) -> DAGCircuit:
         for node in dag.named_nodes(*self._synth_gates):
             if self._min_qubits is not None and len(node.qargs) < self._min_qubits:
                 continue
-            if plugin_method.supports_coupling_map:
-                kwargs["coupling_map"] = (
-                    self._coupling_map,
-                    [dag_bit_indices[x] for x in node.qargs],
-                )
             synth_dag = None
             unitary = node.op.to_matrix()
             n_qubits = len(node.qargs)
             if (plugin_method.max_qubits is not None and n_qubits > plugin_method.max_qubits) or (
                 plugin_method.min_qubits is not None and n_qubits < plugin_method.min_qubits
             ):
-                synth_dag = default_method.run(unitary, **kwargs)
+                method, kwargs = default_method, default_kwargs
             else:
-                synth_dag = plugin_method.run(unitary, **kwargs)
+                method, kwargs = plugin_method, plugin_kwargs
+            if method.supports_coupling_map:
+                kwargs["coupling_map"] = (
+                    self._coupling_map,
+                    [dag_bit_indices[x] for x in node.qargs],
+                )
+            synth_dag = method.run(unitary, **kwargs)
             if synth_dag is not None:
                 if isinstance(synth_dag, tuple):
                     dag.substitute_node_with_dag(node, synth_dag[0], wires=synth_dag[1])


Test patch:
diff --git a/test/python/transpiler/test_unitary_synthesis.py b/test/python/transpiler/test_unitary_synthesis.py
--- a/test/python/transpiler/test_unitary_synthesis.py
+++ b/test/python/transpiler/test_unitary_synthesis.py
@@ -11,7 +11,7 @@
 # that they have been altered from the originals.
 
 """
-Tests for the UnitarySynthesis transpiler pass.
+Tests for the default UnitarySynthesis transpiler pass.
 """
 
 import unittest
diff --git a/test/python/transpiler/test_unitary_synthesis_plugin.py b/test/python/transpiler/test_unitary_synthesis_plugin.py
new file mode 100644
--- /dev/null
+++ b/test/python/transpiler/test_unitary_synthesis_plugin.py
@@ -0,0 +1,239 @@
+# This code is part of Qiskit.
+#
+# (C) Copyright IBM 2021.
+#
+# This code is licensed under the Apache License, Version 2.0. You may
+# obtain a copy of this license in the LICENSE.txt file in the root directory
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""
+Tests for the UnitarySynthesis transpiler pass.
+"""
+
+import functools
+import itertools
+import unittest.mock
+
+import numpy as np
+import stevedore
+
+from qiskit.circuit import QuantumCircuit
+from qiskit.test import QiskitTestCase
+from qiskit.transpiler import PassManager
+from qiskit.transpiler.passes import UnitarySynthesis
+from qiskit.transpiler.passes.synthesis.plugin import (
+    UnitarySynthesisPlugin,
+    UnitarySynthesisPluginManager,
+    unitary_synthesis_plugin_names,
+)
+from qiskit.transpiler.passes.synthesis.unitary_synthesis import DefaultUnitarySynthesis
+
+
+class _MockExtensionManager:
+    def __init__(self, plugins):
+        self._plugins = {
+            name: stevedore.extension.Extension(name, None, plugin, plugin())
+            for name, plugin in plugins.items()
+        }
+        self._stevedore_manager = stevedore.ExtensionManager(
+            "qiskit.unitary_synthesis", invoke_on_load=True, propagate_map_exceptions=True
+        )
+
+    def names(self):
+        """Mock method to replace the stevedore names."""
+        return list(self._plugins) + self._stevedore_manager.names()
+
+    def __getitem__(self, value):
+        try:
+            return self._plugins[value]
+        except KeyError:
+            pass
+        return self._stevedore_manager[value]
+
+    def __contains__(self, value):
+        return value in self._plugins or value in self._stevedore_manager
+
+    def __iter__(self):
+        return itertools.chain(self._plugins.values(), self._stevedore_manager)
+
+
+class _MockPluginManager:
+    def __init__(self, plugins):
+        self.ext_plugins = _MockExtensionManager(plugins)
+
+
+class ControllableSynthesis(UnitarySynthesisPlugin):
+    """A dummy synthesis plugin, which can have its ``supports_`` properties changed to test
+    different parts of the synthesis plugin interface.  By default, it accepts all keyword arguments
+    and accepts all number of qubits, but if its run method is called, it just returns ``None`` to
+    indicate that the gate should not be synthesised."""
+
+    min_qubits = None
+    max_qubits = None
+    supported_bases = None
+    supports_basis_gates = True
+    supports_coupling_map = True
+    supports_gate_errors = True
+    supports_gate_lengths = True
+    supports_natural_direction = True
+    supports_pulse_optimize = True
+    run = unittest.mock.MagicMock(return_value=None)
+
+    @classmethod
+    def reset(cls):
+        """Reset the state of any internal mocks, and return class properties to their defaults."""
+        cls.run.reset_mock()
+        cls.min_qubits = None
+        cls.max_qubits = None
+        cls.supported_bases = None
+        cls.support()
+
+    @classmethod
+    def support(cls, names=None):
+        """Set the plugin to support the given keywords, and reject any that are not given.  If
+        no argument is passed, then everything will be supported.  To reject everything, explicitly
+        pass an empty iterable."""
+        if names is None:
+
+            def value(_name):
+                return True
+
+        else:
+            names = set(names)
+
+            def value(name):
+                return name in names
+
+        prefix = "supports_"
+        for name in dir(cls):
+            if name.startswith(prefix):
+                setattr(cls, name, value(name[len(prefix) :]))
+
+
+class TestUnitarySynthesisPlugin(QiskitTestCase):
+    """Tests for the synthesis plugin interface."""
+
+    # The proliferation of the "disable=no-members" lines are because pylint (very reasonably) can't
+    # detect when we've mocked out an internal object with a MagicMock.
+
+    MOCK_PLUGINS = {}
+    DEFAULT_PLUGIN = DefaultUnitarySynthesis
+
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+        cls.MOCK_PLUGINS["_controllable"] = ControllableSynthesis
+        # Decorate all test functions to patch the plugin manager with a fake one, which inserts our
+        # testing mocks into the lookup.  It's easier to do this than to correctly register them,
+        # because it's fiddly to trick importlib into registering new entry points metadata without
+        # actually installing a package before the interpreter was invoked.  We don't want to leak
+        # any testing classes out into public releases, so we just patch.
+        decorator = unittest.mock.patch(
+            "qiskit.transpiler.passes.synthesis.plugin.UnitarySynthesisPluginManager",
+            functools.partial(_MockPluginManager, plugins=cls.MOCK_PLUGINS),
+        )
+        for name in dir(cls):
+            if name.startswith("test_"):
+                setattr(cls, name, decorator(getattr(cls, name)))
+
+    def setUp(self):
+        super().setUp()
+        for plugin in self.MOCK_PLUGINS.values():
+            plugin.reset()
+
+    def mock_default_run_method(self):
+        """Return a decorator or context manager that replaces the default synthesis plugin's run
+        method with a mocked version that behaves normally, except has all the trackers attached to
+        it."""
+        # We need to mock out DefaultUnitarySynthesis.run, except it will actually get called as an
+        # instance method, so we can't just wrap the method defined on the class, but instead we
+        # need to wrap a method that has been bound to a particular instance.  This is slightly
+        # frgaile, because we're likely wrapping a _different_ instance, but since there are no
+        # arguments to __init__, and no internal state, it should be ok.  It doesn't matter if we
+        # dodged the patching of the manager class that happens elsewhere in this test suite,
+        # because we're always accessing something that the patch would delegate to the inner
+        # manager anyway.
+        inner_default = UnitarySynthesisPluginManager().ext_plugins["default"].obj
+        mock = unittest.mock.MagicMock(wraps=inner_default.run)
+        return unittest.mock.patch.object(self.DEFAULT_PLUGIN, "run", mock)
+
+    def test_mock_plugins_registered(self):
+        """This is a meta test, that the internal registering mechanisms for our dummy test plugins
+        exist and that we can call them."""
+        registered = unitary_synthesis_plugin_names()
+        for plugin in self.MOCK_PLUGINS:
+            self.assertIn(plugin, registered)
+
+    def test_call_registered_class(self):
+        """Test that a non-default plugin was called."""
+        qc = QuantumCircuit(2)
+        qc.unitary(np.eye(4, dtype=np.complex128), [0, 1])
+        pm = PassManager([UnitarySynthesis(basis_gates=["u", "cx"], method="_controllable")])
+        with self.mock_default_run_method():
+            pm.run(qc)
+            self.DEFAULT_PLUGIN.run.assert_not_called()  # pylint: disable=no-member
+        self.MOCK_PLUGINS["_controllable"].run.assert_called()
+
+    def test_max_qubits_are_respected(self):
+        """Test that the default handler gets used if the chosen plugin can't cope with a given
+        unitary."""
+        self.MOCK_PLUGINS["_controllable"].min_qubits = None
+        self.MOCK_PLUGINS["_controllable"].max_qubits = 0
+        qc = QuantumCircuit(2)
+        qc.unitary(np.eye(4, dtype=np.complex128), [0, 1])
+        pm = PassManager([UnitarySynthesis(basis_gates=["u", "cx"], method="_controllable")])
+        with self.mock_default_run_method():
+            pm.run(qc)
+            self.DEFAULT_PLUGIN.run.assert_called()  # pylint: disable=no-member
+        self.MOCK_PLUGINS["_controllable"].run.assert_not_called()
+
+    def test_min_qubits_are_respected(self):
+        """Test that the default handler gets used if the chosen plugin can't cope with a given
+        unitary."""
+        self.MOCK_PLUGINS["_controllable"].min_qubits = 3
+        self.MOCK_PLUGINS["_controllable"].max_qubits = None
+        qc = QuantumCircuit(2)
+        qc.unitary(np.eye(4, dtype=np.complex128), [0, 1])
+        pm = PassManager([UnitarySynthesis(basis_gates=["u", "cx"], method="_controllable")])
+        with self.mock_default_run_method():
+            pm.run(qc)
+            self.DEFAULT_PLUGIN.run.assert_called()  # pylint: disable=no-member
+        self.MOCK_PLUGINS["_controllable"].run.assert_not_called()
+
+    def test_all_keywords_passed_to_default_on_fallback(self):
+        """Test that all the keywords that the default synthesis plugin needs are passed to it, even
+        if the chosen method doesn't support them."""
+        # Set the mock plugin to reject all keyword arguments, but also be unable to handle
+        # operators of any numbers of qubits.  This will cause fallback to the default handler,
+        # which should receive a full set of keywords, still.
+        self.MOCK_PLUGINS["_controllable"].min_qubits = np.inf
+        self.MOCK_PLUGINS["_controllable"].max_qubits = 0
+        self.MOCK_PLUGINS["_controllable"].support([])
+        qc = QuantumCircuit(2)
+        qc.unitary(np.eye(4, dtype=np.complex128), [0, 1])
+        pm = PassManager([UnitarySynthesis(basis_gates=["u", "cx"], method="_controllable")])
+        with self.mock_default_run_method():
+            pm.run(qc)
+            self.DEFAULT_PLUGIN.run.assert_called()  # pylint: disable=no-member
+            # This access should be `run.call_args.kwargs`, but the namedtuple access wasn't added
+            # until Python 3.8.
+            call_kwargs = self.DEFAULT_PLUGIN.run.call_args[1]  # pylint: disable=no-member
+        expected_kwargs = [
+            "basis_gates",
+            "coupling_map",
+            "gate_errors",
+            "gate_lengths",
+            "natural_direction",
+            "pulse_optimize",
+        ]
+        for kwarg in expected_kwargs:
+            self.assertIn(kwarg, call_kwargs)
+        self.MOCK_PLUGINS["_controllable"].run.assert_not_called()
+
+
+if __name__ == "__main__":
+    unittest.main()