Repository: microsoft/Qcodes

Issue:
Improve implementation of instrument instance registry
This issue flags the importance of improving how `Instrument._all_instruments` and `Instrument._instances` are implemented. 

There are cases where closing instrument is important not to forget, otherwise some warning like the ones mentioned in this PR https://github.com/QCoDeS/Qcodes/pull/1875 can start showing up for no good reason.

Anyway, `Insrtument._all_instruments` is intended to hold weak references to instrument instances in order to prevent multiple connections to the same hardware instrument. It is now implemented via simple `dict` which requires methods like `Instrument.exists` and `Instrument.find_instance` to account for the fact that some of those weak references can be `None` because instruments may have gotten closed in the meantime. And this seems to be causing weird unnecessary bugs and dirty code.

My suggestion is to consider using the right tools for the job - use `weakref.WeakValueDictionary` for `_all_instruments` which will automatically take care of the case where a weakref is "dead". For a usage example see `DllWrapper` for `Alazar` driver https://github.com/QCoDeS/Qcodes/blob/master/qcodes/instrument_drivers/AlazarTech/dll_wrapper.py#L95.


Discussion:
This sounds good. For me the main userfaceing bug to solve here would the fact that an instrument is registered even when the setup fails e.g.

```python
inst = MyInstrument(name='foo', totally_the_wrong_args)
# now the name foo is reserved even if the instrument never got constructed
# and this line below fails
inst = MyInstrument(name='foo', the_right_args)
```



this is an important bug, let's see if this weakdict stuff fixes it. Otherwise I'll make a separate issue for this bug.

Resolving patch:
diff --git a/qcodes/instrument/base.py b/qcodes/instrument/base.py
--- a/qcodes/instrument/base.py
+++ b/qcodes/instrument/base.py
@@ -3,7 +3,7 @@
 import time
 import warnings
 import weakref
-from abc import ABC, abstractmethod
+from abc import ABC, ABCMeta, abstractmethod
 from typing import (
     TYPE_CHECKING,
     Any,
@@ -257,9 +257,8 @@ def snapshot_base(self, update: Optional[bool] = False,
             except:
                 # really log this twice. Once verbose for the UI and once
                 # at lower level with more info for file based loggers
-                self.log.warning(f"Snapshot: Could not update "
-                                 f"parameter: {name}")
-                self.log.info(f"Details for Snapshot:", exc_info=True)
+                self.log.warning("Snapshot: Could not update parameter: %s", name)
+                self.log.info("Details for Snapshot:", exc_info=True)
                 snap['parameters'][name] = param.snapshot(update=False)
 
         for attr in set(self._meta_attrs):
@@ -434,7 +433,37 @@ def validate_status(self, verbose: bool = False) -> None:
                 p.validate(value)
 
 
-class AbstractInstrument(ABC):
+class AbstractInstrumentMeta(ABCMeta):
+    """
+    Metaclass used to customize Instrument creation. We want to register the
+    instance iff __init__ successfully runs, however we can only do this if
+    we customize the instance initialization process, otherwise there is no
+    way to run `register_instance` after `__init__` but before the created
+    instance is returned to the caller.
+
+    Instead we use the fact that `__new__` and `__init__` are called inside
+    `type.__call__`
+    (https://github.com/python/cpython/blob/main/Objects/typeobject.c#L1077,
+    https://github.com/python/typeshed/blob/master/stdlib/builtins.pyi#L156)
+    which we will overload to insert our own custom code AFTER `__init__` is
+    complete. Note this is part of the spec and will work in alternate python
+    implementations like pypy too.
+
+    Note: Because we want AbstractInstrument to subclass ABC, we subclass
+    `ABCMeta` instead of `type`.
+    """
+
+    def __call__(cls, *args: Any, **kwargs: Any) -> Any:
+        """
+        Overloads `type.__call__` to add code that runs only if __init__ completes
+        successfully.
+        """
+        new_inst = super().__call__(*args, **kwargs)
+        new_inst.record_instance(new_inst)
+        return new_inst
+
+
+class AbstractInstrument(ABC, metaclass=AbstractInstrumentMeta):
     """ABC that is useful for defining mixin classes for Instrument class"""
     log: 'InstrumentLoggerAdapter'  # instrument logging
 
@@ -477,7 +506,6 @@ def __init__(
 
         self.add_parameter('IDN', get_cmd=self.get_idn,
                            vals=Anything())
-        self.record_instance(self)
 
     def get_idn(self) -> Dict[str, Optional[str]]:
         """
@@ -583,12 +611,11 @@ def close_all(cls) -> None:
         log.info("Closing all registered instruments")
         for inststr in list(cls._all_instruments):
             try:
-                inst: Instrument = cls.find_instrument(inststr)
-                log.info(f"Closing {inststr}")
+                inst: "Instrument" = cls.find_instrument(inststr)
+                log.info("Closing %s", inststr)
                 inst.close()
             except:
-                log.exception(f"Failed to close {inststr}, ignored")
-                pass
+                log.exception("Failed to close %s, ignored", inststr)
 
     @classmethod
     def record_instance(cls, instance: 'Instrument') -> None:
@@ -598,6 +625,8 @@ def record_instance(cls, instance: 'Instrument') -> None:
         Also records the instance in list of *all* instruments, and verifies
         that there are no other instruments with the same name.
 
+        This method is called after initialization of the instrument is completed.
+
         Args:
             instance: Instance to record.
 
@@ -688,10 +717,12 @@ def find_instrument(
 
         if not isinstance(ins, internal_instrument_class):
             raise TypeError(
-                f"Instrument {name} is {type(ins)} but {internal_instrument_class} was requested"
+                f"Instrument {name} is {type(ins)} but "
+                f"{internal_instrument_class} was requested"
             )
-        # at this stage we have checked that the instrument is either of type instrument_class
-        # or Instrument if that is None. It is therefor safe to cast here.
+        # at this stage we have checked that the instrument is either of
+        # type instrument_class or Instrument if that is None. It is
+        # therefore safe to cast here.
         ins = cast(T, ins)
         return ins
 
@@ -779,8 +810,8 @@ def write_raw(self, cmd: str) -> None:
             cmd: The string to send to the instrument.
         """
         raise NotImplementedError(
-            'Instrument {} has not defined a write method'.format(
-                type(self).__name__))
+            f"Instrument {type(self).__name__} has not defined a write method"
+        )
 
     def ask(self, cmd: str) -> str:
         """
@@ -822,8 +853,8 @@ def ask_raw(self, cmd: str) -> str:
             cmd: The string to send to the instrument.
         """
         raise NotImplementedError(
-            'Instrument {} has not defined an ask method'.format(
-                type(self).__name__))
+            f"Instrument {type(self).__name__} has not defined an ask method"
+        )
 
 
 def find_or_create_instrument(


Test patch:
diff --git a/qcodes/tests/instrument_mocks.py b/qcodes/tests/instrument_mocks.py
--- a/qcodes/tests/instrument_mocks.py
+++ b/qcodes/tests/instrument_mocks.py
@@ -136,6 +136,23 @@ def __init__(self, name: str = 'dummy',
             )
 
 
+class DummyFailingInstrument(Instrument):
+    def __init__(self, name: str = "dummy", fail: bool = True, **kwargs):
+
+        """
+        Create a dummy instrument that fails on initialization
+        that can be used for testing
+
+        Args:
+            name: name for the instrument
+            fail: if true, instrument will throw a runtime error on creation.
+        """
+        super().__init__(name, **kwargs)
+
+        if fail:
+            raise RuntimeError("Failed to create instrument")
+
+
 class DummyAttrInstrument(Instrument):
     def __init__(self, name: str = "dummy", **kwargs: Any):
 
diff --git a/qcodes/tests/test_instrument.py b/qcodes/tests/test_instrument.py
--- a/qcodes/tests/test_instrument.py
+++ b/qcodes/tests/test_instrument.py
@@ -12,7 +12,12 @@
 from qcodes.instrument.function import Function
 from qcodes.instrument.parameter import Parameter
 
-from .instrument_mocks import DummyInstrument, MockMetaParabola, MockParabola
+from .instrument_mocks import (
+    DummyFailingInstrument,
+    DummyInstrument,
+    MockMetaParabola,
+    MockParabola,
+)
 
 
 @pytest.fixture(name='testdummy', scope='function')
@@ -61,6 +66,26 @@ def test_check_instances(testdummy):
     assert testdummy.instances() == [testdummy]
 
 
+def test_instrument_fail(close_before_and_after):
+    with pytest.raises(RuntimeError, match="Failed to create instrument"):
+        DummyFailingInstrument(name="failinginstrument")
+
+    assert Instrument.instances() == []
+    assert DummyFailingInstrument.instances() == []
+    assert Instrument._all_instruments == {}
+
+
+def test_instrument_retry_with_same_name(close_before_and_after):
+    with pytest.raises(RuntimeError, match="Failed to create instrument"):
+        DummyFailingInstrument(name="failinginstrument")
+    instr = DummyFailingInstrument(name="failinginstrument", fail=False)
+
+    # Check that the instrument is successfully registered after failing first
+    assert Instrument.instances() == []
+    assert DummyFailingInstrument.instances() == [instr]
+    assert Instrument._all_instruments == {"failinginstrument": weakref.ref(instr)}
+
+
 def test_attr_access(testdummy):
 
     # test the instrument works