diff --git a/qcodes/instrument/base.py b/qcodes/instrument/base.py index 3cd013616a5..90d8856d2de 100644 --- a/qcodes/instrument/base.py +++ b/qcodes/instrument/base.py @@ -1,69 +1,79 @@ +"""Instrument base class.""" import weakref import time from qcodes.utils.metadata import Metadatable from qcodes.utils.helpers import DelegateAttributes, strip_attrs, full_class +from qcodes.utils.nested_attrs import NestedAttrAccess from qcodes.utils.validators import Anything from .parameter import StandardParameter from .function import Function from .remote import RemoteInstrument -import logging -class NoDefault: - ''' - empty class to provide a missing default to getattr - ''' - pass +class Instrument(Metadatable, DelegateAttributes, NestedAttrAccess): + """ + Base class for all QCodes instruments. -class Instrument(Metadatable, DelegateAttributes): - ''' - Base class for all QCodes instruments + Args: + name (str): an identifier for this instrument, particularly for + attaching it to a Station. - name: an identifier for this instrument, particularly for attaching it to - a Station. + server_name (Optional[str]): If not ``None``, this instrument starts a + separate server process (or connects to one, if one already exists + with the same name) and all hardware calls are made there. - server_name: this instrument starts a separate server process (or connects - to one, if one already exists with the same name) and all hardware - calls are made there. If '' (default), then we call classmethod - `default_server_name`, passing in all the constructor kwargs, to - determine the name. If not overridden, this is just 'Instruments'. + Default '', then we call classmethod ``default_server_name``, + passing in all the constructor kwargs, to determine the name. + If not overridden, this just gives 'Instruments'. - ** see notes below about `server_name` in SUBCLASS CONSTRUCTORS ** + ** see SUBCLASS CONSTRUCTORS below for more on ``server_name`` ** - Use None to operate without a server - but then this Instrument - will not work with qcodes Loops or other multiprocess procedures. + Use None to operate without a server - but then this Instrument + will not work with qcodes Loops or other multiprocess procedures. - If a server is used, the Instrument you asked for is instantiated - on the server, and the object you get in the main process is actually - a RemoteInstrument that proxies all method calls, Parameters, and - Functions to the server. + If a server is used, the ``Instrument`` you asked for is + instantiated on the server, and the object you get in the main + process is actually a ``RemoteInstrument`` that proxies all method + calls, ``Parameters``, and ``Functions`` to the server. - kwargs: any that make it all the way to this base class get stored as - metadata with this instrument + metadata (Optional[Dict]): additional static metadata to add to this + instrument's JSON snapshot. Any unpicklable objects that are inputs to the constructor must be set on server initialization, and must be shared between all instruments that reside on the same server. To make this happen, set the - `shared_kwargs` class attribute to a list of kwarg names that should + ``shared_kwargs`` class attribute to a tuple of kwarg names that should be treated this way. It is an error to initialize two instruments on the same server with - different keys or values for `shared_kwargs`, unless the later - instruments have NO shared_kwargs at all. + different keys or values for ``shared_kwargs``, unless the later + instruments have NO ``shared_kwargs`` at all. - SUBCLASS CONSTRUCTORS: `server_name` and any `shared_kwargs` must be + SUBCLASS CONSTRUCTORS: ``server_name`` and any ``shared_kwargs`` must be available as kwargs and kwargs ONLY (not positional) in all subclasses, and not modified in the inheritance chain. This is because we need to create the server before instantiating the actual instrument. The easiest - way to manage this is to accept **kwargs in your subclass and pass them - on to super().__init() - ''' - shared_kwargs = [] + way to manage this is to accept ``**kwargs`` in your subclass and pass them + on to ``super().__init()``. + + Attributes: + name (str): an identifier for this instrument, particularly for + attaching it to a Station. + + parameters (Dict[Parameter]): All the parameters supported by this + instrument. Usually populated via ``add_parameter`` + + functions (Dict[Function]): All the functions supported by this + instrument. Usually populated via ``add_function`` + """ + + shared_kwargs = () def __new__(cls, *args, server_name='', **kwargs): + """Figure out whether to create a base instrument or proxy.""" if server_name is None: return super().__new__(cls) else: @@ -89,18 +99,31 @@ def get_idn(self): """ Placeholder for instrument ID parameter getter. - Subclasses should override this and return dicts containing - at least these 4 fields: - vendor - model - serial - firmware + Subclasses should override this. + + Returns: + A dict containing (at least) these 4 fields: + vendor + model + serial + firmware """ return {'vendor': None, 'model': None, 'serial': None, 'firmware': None} @classmethod def default_server_name(cls, **kwargs): + """ + Generate a default name for the server to host this instrument. + + Args: + **kwargs: the constructor kwargs, used if necessary to choose a + name. + + Returns: + str: The default server name for the specific instrument instance + we are constructing. + """ return 'Instruments' def connect_message(self, idn_param='IDN', begin_time=None): @@ -110,7 +133,7 @@ def connect_message(self, idn_param='IDN', begin_time=None): Args: idn_param (str): name of parameter that returns ID dict. Default 'IDN'. - begin_time (number, optional): time.time() when init started. + begin_time (number): time.time() when init started. Default is self._t0, set at start of Instrument.__init__. """ # start with an empty dict, just in case an instrument doesn't @@ -126,139 +149,11 @@ def connect_message(self, idn_param='IDN', begin_time=None): print(con_msg) def __repr__(self): + """Simplified repr giving just the class and name.""" return '<{}: {}>'.format(type(self).__name__, self.name) - def getattr(self, attr, default=NoDefault): - ''' - Get an attribute of this Instrument. - Exact proxy for getattr if attr is a string, but can also - get parts from nested items if attr is a sequence. - - attr: a string or sequence - if a string, this behaves exactly as normal getattr - if a sequence, treats the parts as diving into a nested dictionary, - or attribute lookup for any part starting with '.' (the first - part is always an attribute and doesn't need a '.'). - if a default is provided, it will be returned if - the lookup fails at any level of the nesting, otherwise - an AttributeError or KeyError will be raised - NOTE: even with a default, if an intermediate nesting - encounters a non-container, a TypeError will be raised. - for example if obj.d = {'a': 1} and we call - obj.getattr(('d','a','b'), None) - - default: value to return if the lookup fails - ''' - try: - if isinstance(attr, str): - # simply attribute lookup - return getattr(self, attr) - - else: - # nested dictionary lookup - obj = getattr(self, attr[0]) - for key in attr[1:]: - if str(key).startswith('.'): - obj = getattr(obj, key[1:]) - else: - obj = obj[key] - return obj - - except (AttributeError, KeyError): - if default is NoDefault: - raise - else: - return default - - def setattr(self, attr, value): - ''' - Set an attribute of this Instrument - Exact proxy for setattr if attr is a string, but can also - set parts in nested items if attr is a sequence. - - attr: a string or sequence - if a string, this behaves exactly as normal setattr - if a sequence, treats the parts as diving into a nested dictionary, - or attribute lookup for any part starting with '.' (the first - part is always an attribute and doesn't need a '.'). - if any level is missing it will be created - NOTE: if an intermediate nesting encounters a non-container, - a TypeError will be raised. - for example if obj.d = {'a': 1} and we call - obj.setattr(('d','a','b'), 2) - - value: the value to store - ''' - if isinstance(attr, str): - setattr(self, attr, value) - elif len(attr) == 1: - setattr(self, attr[0], value) - else: - if not hasattr(self, attr[0]): - setattr(self, attr[0], {}) - obj = getattr(self, attr[0]) - - for key in attr[1: -1]: - if str(key).startswith('.'): - # we don't make intermediate attributes, only - # intermediate dicts. - obj = getattr(obj, key) - else: - if key not in obj: - obj[key] = {} - obj = obj[key] - - if str(attr[-1]).startswith('.'): - setattr(obj, attr[-1][1:], value) - else: - obj[attr[-1]] = value - - def delattr(self, attr, prune=True): - ''' - Delete an attribute from this Instrument - Exact proxy for __delattr__ if attr is a string, but can also - remove parts of nested items if attr is a sequence, in which case - it may prune empty containers of the final attribute - - attr: a string or sequence - if a string, this behaves exactly as normal __delattr__ - if a sequence, treats the parts as diving into a nested dictionary, - or attribute lookup for any part starting with '.' (the first - part is always an attribute and doesn't need a '.'). - prune: if True (default) and attr is a sequence, will try to remove - any containing levels which have become empty - ''' - if isinstance(attr, str): - delattr(self, attr) - elif len(attr) == 1: - delattr(self, attr[0]) - else: - obj = getattr(self, attr[0]) - # dive into the nesting, saving what we did - tree = [] - for key in attr[1:-1]: - if str(key).startswith('.'): - newobj = getattr(obj, key[1:]) - else: - newobj = obj[key] - tree.append((newobj, obj, key)) - obj = newobj - # delete the leaf - del obj[attr[-1]] - # work back out, deleting branches if we can - if prune: - for child, parent, key in reversed(tree): - if not child: - if str(key).startswith('.'): - delattr(parent, key[1:]) - else: - del parent[key] - else: - break - if not getattr(self, attr[0]): - delattr(self, attr[0]) - def __del__(self): + """Close the instrument and remove its instance record.""" try: wr = weakref.ref(self) if wr in getattr(self, '_instances', {}): @@ -268,11 +163,12 @@ def __del__(self): pass def close(self): - ''' - Irreversibly stop this instrument and free its resources - subclasses should override this if they have other specific + """ + Irreversibly stop this instrument and free its resources. + + Subclasses should override this if they have other specific resources to close. - ''' + """ if hasattr(self, 'connection') and hasattr(self.connection, 'close'): self.connection.close() @@ -281,13 +177,15 @@ def close(self): @classmethod def record_instance(cls, instance): - ''' - record (a weak ref to) an instance in a class's instance list + """ + Record (a weak ref to) an instance in a class's instance list. - note that we *do not* check that instance is actually an instance of - cls. This is important, because a RemoteInstrument should function as - an instance of the real Instrument it is connected to on the server. - ''' + Args: + instance (Union[Instrument, RemoteInstrument]): Note: we *do not* + check that instance is actually an instance of ``cls``. This is + important, because a ``RemoteInstrument`` should function as an + instance of the instrument it proxies. + """ if getattr(cls, '_type', None) is not cls: cls._type = cls cls._instances = [] @@ -295,11 +193,19 @@ def record_instance(cls, instance): @classmethod def instances(cls): - ''' - returns all currently defined instances of this instrument class - you can use this to get the objects back if you lose track of them, + """ + Get all currently defined instances of this instrument class. + + You can use this to get the objects back if you lose track of them, and it's also used by the test system to find objects to test against. - ''' + + Note: + Will also include ``RemoteInstrument`` instances that proxy + instruments of this class. + + Returns: + List[Union[Instrument, RemoteInstrument]] + """ if getattr(cls, '_type', None) is not cls: # only instances of a superclass - we want instances of this # exact class only @@ -308,31 +214,46 @@ def instances(cls): @classmethod def remove_instance(cls, instance): + """ + Remove a particular instance from the record. + + Args: + instance (Union[Instrument, RemoteInstrument]) + """ wr = weakref.ref(instance) if wr in cls._instances: cls._instances.remove(wr) def add_parameter(self, name, parameter_class=StandardParameter, **kwargs): - ''' - binds one Parameter to this instrument. + """ + Bind one Parameter to this instrument. - instrument subclasses can call this repeatedly in their __init__ + Instrument subclasses can call this repeatedly in their ``__init__`` for every real parameter of the instrument. In this sense, parameters are the state variables of the instrument, anything the user can set and/or get - `name` is how the Parameter will be stored within - instrument.parameters and also how you address it using the - shortcut methods: - instrument.set(param_name, value) etc. + Args: + name (str): How the parameter will be stored within + ``instrument.parameters`` and also how you address it using the + shortcut methods: ``instrument.set(param_name, value)`` etc. + + parameter_class (Optional[type]): You can construct the parameter + out of any class. Default ``StandardParameter``. + + **kwargs: constructor arguments for ``parameter_class``. - `parameter_class` can be used to construct the parameter out of - something other than StandardParameter + Returns: + dict: attribute information. Only used if you add parameters + from the ``RemoteInstrument`` rather than at construction, to + properly construct the proxy for this parameter. - kwargs: see StandardParameter (or `parameter_class`) - ''' + Raises: + KeyError: if this instrument already has a parameter with this + name. + """ if name in self.parameters: raise KeyError('Duplicate parameter name {}'.format(name)) param = parameter_class(name=name, instrument=self, **kwargs) @@ -343,21 +264,34 @@ def add_parameter(self, name, parameter_class=StandardParameter, return param.get_attrs() def add_function(self, name, **kwargs): - ''' - binds one Function to this instrument. + """ + Bind one Function to this instrument. - instrument subclasses can call this repeatedly in their __init__ + Instrument subclasses can call this repeatedly in their ``__init__`` for every real function of the instrument. - In this sense, functions are actions of the instrument, that typically - transcend any one parameter, such as reset, activate, or trigger. + This functionality is meant for simple cases, principally things that + map to simple commands like '*RST' (reset) or those with just a few + arguments. It requires a fixed argument count, and positional args + only. If your case is more complicated, you're probably better off + simply making a new method in your ``Instrument`` subclass definition. - `name` is how the Function will be stored within instrument.functions - and also how you address it using the shortcut methods: - instrument.call(func_name, *args) etc. + Args: + name (str): how the Function will be stored within + ``instrument.Functions`` and also how you address it using the + shortcut methods: ``instrument.call(func_name, *args)`` etc. - see Function for the list of kwargs and notes on its limitations. - ''' + **kwargs: constructor kwargs for ``Function`` + + Returns: + A dict of attribute information. Only used if you add functions + from the ``RemoteInstrument`` rather than at construction, to + properly construct the proxy for this function. + + Raises: + KeyError: if this instrument already has a function with this + name. + """ if name in self.functions: raise KeyError('Duplicate function name {}'.format(name)) func = Function(name=name, instrument=self, **kwargs) @@ -368,6 +302,18 @@ def add_function(self, name, **kwargs): return func.get_attrs() def snapshot_base(self, update=False): + """ + State of the instrument as a JSON-compatible dict. + + ``Metadatable`` adds metadata, if any, to this snapshot. + + Args: + update (bool): If True, update the state by querying the + instrument. If False, just use the latest values in memory. + + Returns: + dict: base snapshot + """ snap = {'parameters': dict((name, param.snapshot(update=update)) for name, param in self.parameters.items()), 'functions': dict((name, func.snapshot(update=update)) @@ -380,21 +326,81 @@ def snapshot_base(self, update=False): return snap ########################################################################## - # `write`, `read`, and `ask` are the interface to hardware # - # Override these in a subclass. # + # `write_raw` and `ask_raw` are the interface to hardware # + # `write` and `ask` are standard wrappers to help with error reporting # ########################################################################## def write(self, cmd): - raise NotImplementedError( - 'Instrument {} has not defined a write method'.format( - type(self).__name__)) + """ + Write a command string with NO response to the hardware. + + Subclasses that transform ``cmd`` should override this method, and in + it call ``super().write(new_cmd)``. Subclasses that define a new + hardware communication should instead override ``write_raw``. + + Args: + cmd (str): the string to send to the instrument + + Raises: + Exception: wraps any underlying exception with extra context, + including the command and the instrument. + """ + try: + self.write_raw(cmd) + except Exception as e: + e.args = e.args + ('writing ' + repr(cmd) + ' to ' + repr(self),) + raise e + + def write_raw(self, cmd): + """ + Low level method to write a command string to the hardware. + + Subclasses that define a new hardware communication should override + this method. Subclasses that transform ``cmd`` should instead + override ``write``. - def read(self): + Args: + cmd (str): the string to send to the instrument + """ raise NotImplementedError( - 'Instrument {} has not defined a read method'.format( + 'Instrument {} has not defined a write method'.format( type(self).__name__)) def ask(self, cmd): + """ + Write a command string to the hardware and return a response. + + Subclasses that transform ``cmd`` should override this method, and in + it call ``super().ask(new_cmd)``. Subclasses that define a new + hardware communication should instead override ``ask_raw``. + + Args: + cmd (str): the string to send to the instrument + + Returns: + response (str, normally) + + Raises: + Exception: wraps any underlying exception with extra context, + including the command and the instrument. + """ + try: + return self.ask_raw(cmd) + except Exception as e: + e.args = e.args + ('asking ' + repr(cmd) + ' to ' + repr(self),) + raise e + + def ask_raw(self, cmd): + """ + Low level method to write to the hardware and return a response. + + Subclasses that define a new hardware communication should override + this method. Subclasses that transform ``cmd`` should instead + override ``ask``. + + Args: + cmd (str): the string to send to the instrument + """ raise NotImplementedError( 'Instrument {} has not defined an ask method'.format( type(self).__name__)) @@ -411,32 +417,45 @@ def ask(self, cmd): delegate_attr_dicts = ['parameters', 'functions'] def __getitem__(self, key): + """Delegate instrument['name'] to parameter or function 'name'.""" try: return self.parameters[key] except KeyError: return self.functions[key] def set(self, param_name, value): + """ + Shortcut for setting a parameter from its name and new value. + + Args: + param_name (str): The name of a parameter of this instrument. + value (any): The new value to set. + """ self.parameters[param_name].set(value) def get(self, param_name): - return self.parameters[param_name].get() - - def param_getattr(self, param_name, attr, default=NoDefault): - if default is NoDefault: - return getattr(self.parameters[param_name], attr) - else: - return getattr(self.parameters[param_name], attr, default) + """ + Shortcut for getting a parameter from its name. - def param_setattr(self, param_name, attr, value): - setattr(self.parameters[param_name], attr, value) + Args: + param_name (str): The name of a parameter of this instrument. - def param_call(self, param_name, method_name, *args, **kwargs): - func = getattr(self.parameters[param_name], method_name) - return func(*args, **kwargs) + Returns: + any: The current value of the parameter. + """ + return self.parameters[param_name].get() - # and one lonely one for Functions def call(self, func_name, *args): + """ + Shortcut for calling a function from its name. + + Args: + func_name (str): The name of a function of this instrument. + *args: any arguments to the function. + + Returns: + any: The return value of the function. + """ return self.functions[func_name].call(*args) ########################################################################## @@ -444,10 +463,11 @@ def call(self, func_name, *args): ########################################################################## def _get_method_attrs(self): - ''' - grab all methods of the instrument, and return them - as a dictionary of attribute dictionaries - ''' + """ + Construct a dict of methods this instrument has. + + Each value is itself a dict of attribute dictionaries + """ out = {} for attr in dir(self): @@ -455,12 +475,8 @@ def _get_method_attrs(self): if ((not callable(value)) or value is self.parameters.get(attr) or value is self.functions.get(attr)): - # Functions are callable, and they show up in dir(), - # but we don't want them included in methods, they have - # their own listing. But we don't want to just exclude - # all Functions, in case a Function conflicts with a method, - # then we want the method to win because the function can still - # be called via instrument.call or instrument.functions + # Functions and Parameters are callable and they show up in + # dir(), but they have their own listing. continue attrs = out[attr] = {} diff --git a/qcodes/instrument/ip.py b/qcodes/instrument/ip.py index ea5dce7e9ce..e670dbb785c 100644 --- a/qcodes/instrument/ip.py +++ b/qcodes/instrument/ip.py @@ -1,24 +1,46 @@ +"""Ethernet instrument driver class based on sockets.""" import socket from .base import Instrument class IPInstrument(Instrument): - ''' - Bare socket ethernet instrument implementation - - name: what this instrument is called locally - address: the IP address or domain name, as a string - port: the IP port, as an integer - (address and port can be set later with set_address) - timeout: seconds to allow for responses (default 5) - (can be set later with set_timeout) - terminator: character(s) to terminate each send with (default '\n') - (can be set later with set_terminator) - persistent: do we leave the socket open between calls? (default True) - write_confirmation: does the instrument acknowledge writes with some - response we can read? (default True) - ''' + + r""" + Bare socket ethernet instrument implementation. + + Args: + name (str): What this instrument is called locally. + + address (Optional[str]): The IP address or name. If not given on + construction, must be provided before any communication. + + port (Optional[int]): The IP port. If not given on construction, must + be provided before any communication. + + timeout (number): Seconds to allow for responses. Default 5. + + terminator (str): Character(s) to terminate each send. Default '\n'. + + persistent (bool): Whether to leave the socket open between calls. + Default True. + + write_confirmation (bool): Whether the instrument acknowledges writes + with some response we should read. Default True. + + server_name (str): Name of the InstrumentServer to use. Defaults to + 'IPInstruments'. + + Use ``None`` to run locally - but then this instrument will not + work with qcodes Loops or other multiprocess procedures. + + metadata (Optional[Dict]): additional static metadata to add to this + instrument's JSON snapshot. + + See help for ``qcodes.Instrument`` for additional information on writing + instrument subclasses. + """ + def __init__(self, name, address=None, port=None, timeout=5, terminator='\n', persistent=True, write_confirmation=True, **kwargs): @@ -38,9 +60,25 @@ def __init__(self, name, address=None, port=None, timeout=5, @classmethod def default_server_name(cls, **kwargs): + """ + Get the default server name for this instrument. + + Args: + **kwargs: All the kwargs supplied in the constructor. + + Returns: + str: By default all IPInstruments go on the server 'IPInstruments'. + """ return 'IPInstruments' def set_address(self, address=None, port=None): + """ + Change the IP address and/or port of this instrument. + + Args: + address (Optional[str]): The IP address or name. + port (Optional[number]): The IP port. + """ if address is not None: self._address = address elif not hasattr(self, '_address'): @@ -56,6 +94,12 @@ def set_address(self, address=None, port=None): self.set_persistent(self._persistent) def set_persistent(self, persistent): + """ + Change whether this instrument keeps its socket open between calls. + + Args: + persistent (bool): Set True to keep the socket open all the time. + """ self._persistent = persistent if persistent: self._connect() @@ -79,13 +123,25 @@ def _disconnect(self): self._socket = None def set_timeout(self, timeout=None): - if timeout is not None: - self._timeout = timeout + """ + Change the read timeout for the socket. + + Args: + timeout (number): Seconds to allow for responses. + """ + self._timeout = timeout if self._socket is not None: - self.socket.settimeout(float(self.timeout)) + self._socket.settimeout(float(self._timeout)) def set_terminator(self, terminator): + r""" + Change the write terminator to use. + + Args: + terminator (str): Character(s) to terminate each send. + Default '\n'. + """ self._terminator = terminator def _send(self, cmd): @@ -96,29 +152,48 @@ def _recv(self): return self._socket.recv(512).decode() def close(self): + """Disconnect and irreversibly tear down the instrument.""" self._disconnect() super().close() - def write(self, cmd): - try: - with self._ensure_connection: - self._send(cmd) - if self._confirmation: - self._recv() - except Exception as e: - e.args = e.args + ('writing ' + repr(cmd) + ' to ' + repr(self),) - raise e - - def ask(self, cmd): - try: - with self._ensure_connection: - self._send(cmd) - return self._recv() - except Exception as e: - e.args = e.args + ('asking ' + repr(cmd) + ' to ' + repr(self),) - raise e + def write_raw(self, cmd): + """ + Low-level interface to send a command that gets no response. + + Args: + cmd (str): The command to send to the instrument. + """ + + with self._ensure_connection: + self._send(cmd) + if self._confirmation: + self._recv() + + def ask_raw(self, cmd): + """ + Low-level interface to send a command an read a response. + + Args: + cmd (str): The command to send to the instrument. + + Returns: + str: The instrument's response. + """ + with self._ensure_connection: + self._send(cmd) + return self._recv() def snapshot_base(self, update=False): + """ + State of the instrument as a JSON-compatible dict. + + Args: + update (bool): If True, update the state by querying the + instrument. If False, just use the latest values in memory. + + Returns: + dict: base snapshot + """ snap = super().snapshot_base(update=update) snap['port'] = self._port @@ -132,13 +207,26 @@ def snapshot_base(self, update=False): class EnsureConnection: + + """ + Context manager to ensure an instrument is connected when needed. + + Uses ``instrument._persistent`` to determine whether or not to close + the connection immediately on completion. + + Args: + instrument (IPInstrument): the instance to connect. + """ + def __init__(self, instrument): self._instrument = instrument def __enter__(self): + """Make sure we connect when entering the context.""" if not self.instrument._persistent or self.instrument._socket is None: self.instrument._connect() def __exit__(self): + """Possibly disconnect on exiting the context.""" if not self.instrument._persistent: self.instrument._disconnect() diff --git a/qcodes/instrument/mock.py b/qcodes/instrument/mock.py index b2da04ab59a..951b2aa6ccc 100644 --- a/qcodes/instrument/mock.py +++ b/qcodes/instrument/mock.py @@ -1,3 +1,4 @@ +"""Mock instruments for testing purposes.""" import time from datetime import datetime @@ -6,39 +7,60 @@ class MockInstrument(Instrument): - ''' - Creates a software instrument, for modeling or testing - - name: (string) the name of this instrument - delay: the time (in seconds) to wait after any operation - to simulate communication delay - model: a MockModel object to connect this MockInstrument to. - Subclasses MUST accept `model` as a constructor kwarg ONLY, even - though it is required. See notes in `Instrument` docstring. - A model should have one or two methods related directly to this - instrument: - _set(param, value) -> set a parameter on the model - _get(param) -> returns the value - keep_history: record (in self.history) every command sent to this - instrument (default True) - read_response: simple constant response to send to self.read(), - just for testing - server_name: leave default ('') to make a MockServer-####### - with the number matching the model server id, or set None - to not use a server. + + """ + Create a software instrument, mostly for testing purposes. + + Also works for simulatoins, but usually this will be simpler, easier to + use, and faster if made as a single ``Instrument`` subclass. + + ``MockInstrument``s have extra overhead as they serialize all commands + (to mimic a network communication channel) and use at least two processes + (instrument server and model server) both of which must be involved in any + given query. parameters to pass to model should be declared with: get_cmd = param_name + '?' set_cmd = param_name + ':{:.3f}' (specify the format & precision) - these will get self.name + ':' prepended to fit the syntax expected - by MockModel servers - alternatively independent functions may still be provided. - ''' - shared_kwargs = ['model'] + alternatively independent set/get functions may still be provided. + + Args: + name (str): The name of this instrument. + + delay (number): Time (in seconds) to wait after any operation + to simulate communication delay. Default 0. + + model (MockModel): A model to connect to. Subclasses MUST accept + ``model`` as a constructor kwarg ONLY, even though it is required. + See notes in ``Instrument`` docstring. + The model should have one or two methods related directly to this + instrument by ``name``: + ``_set(param, value)``: set a parameter on the model + ``_get(param)``: returns the value of a parameter + + keep_history (bool): Whether to record (in self.history) every command + sent to this instrument. Default True. - def __init__(self, name, delay=0, model=None, keep_history=True, - read_response=None, **kwargs): + server_name (Union[str, None]): leave default ('') to make a + MockInsts-####### server with the number matching the model server + id, or set None to not use a server. + Attributes: + shared_kwargs (List[str]): Class attribute, constructor kwargs to + provide via server init. For MockInstrument this should always be + ['model'] at least. + + keep_history (bool): Whether to record all commands and responses. Set + on init, but may be changed at any time. + + history (List[tuple]): All commands and responses while keep_history is + enabled, as tuples: + (timestamp, 'ask' or 'write', param_name[, value]) + """ + + shared_kwargs = ['model'] + + def __init__(self, name, delay=0, model=None, keep_history=True, **kwargs): super().__init__(name, **kwargs) if not isinstance(delay, (int, float)) or delay < 0: @@ -51,22 +73,37 @@ def __init__(self, name, delay=0, model=None, keep_history=True, self._model = model # keep a record of every command sent to this instrument - # for debugging purposes - if keep_history: - self.keep_history = True - self.history = [] - - # just for test purposes - self._read_response = read_response + # for debugging purposes? + self.keep_history = bool(keep_history) + self.history = [] @classmethod def default_server_name(cls, **kwargs): + """ + Get the default server name for this instrument. + + Args: + **kwargs: All the kwargs supplied in the constructor. + + Returns: + str: Default MockInstrument server name is MockInsts-#######, where + ####### is the first 7 characters of the MockModel's uuid. + """ model = kwargs.get('model', None) if model: return model.name.replace('Model', 'MockInsts') return 'MockInstruments' - def write(self, cmd): + def write_raw(self, cmd): + """ + Low-level interface to ``model.write``. + + Prepends self.name + ':' to the command, so the ``MockModel`` + will direct this query to its ``_set`` method + + Args: + cmd (str): The command to send to the instrument. + """ if self._delay: time.sleep(self._delay) @@ -81,7 +118,23 @@ def write(self, cmd): self._model.write('cmd', self.name + ':' + cmd) - def ask(self, cmd): + def ask_raw(self, cmd): + """ + Low-level interface to ``model.ask``. + + Prepends self.name + ':' to the command, so the ``MockModel`` + will direct this query to its ``_get`` method + + Args: + cmd (str): The command to send to the instrument. + + Returns: + str: The instrument's response. + + Raises: + ValueError: If ``cmd`` is malformed in that it contains text + after the '?' + """ if self._delay: time.sleep(self._delay) @@ -95,48 +148,65 @@ def ask(self, cmd): return self._model.ask('cmd', self.name + ':' + cmd) - def read(self): - if self._delay: - time.sleep(self._delay) - - return self._read_response - +# MockModel is purely in service of mock instruments which *are* tested +# so coverage testing this (by running it locally) would be a waste. class MockModel(ServerManager, BaseServer): # pragma: no cover - # this is purely in service of mock instruments which *are* tested - # so coverage testing this (by running it locally) would be a waste. - ''' - Base class for models to connect to various MockInstruments + + """ + Base class for models to connect to various MockInstruments. Creates a separate process that holds the model state, so that any process can interact with the model and get the same state. - write and ask support single string queries of the form: - :: (for setting) - :? (for getting) + Args: + name (str): The server name to create for the model. + Default 'Model-{:.7s}' uses the first 7 characters of + the server's uuid. + + for every instrument that connects to this model, create two methods: + ``_set(param, value)``: set a parameter on the model + ``_get(param)``: returns the value of a parameter + ``param`` and the set/return values should all be strings + + If ``param`` and/or ``value`` is not recognized, the method should raise + an error. - for every instrument the model understands, create two methods: - _set(param, value) - _get(param) -> returns the value - both param and the set/return values should be strings + Other uses of ServerManager use separate classes for the server and its + manager, but here I put the two together into a single class, to make it + easier to define models. The downside is you have a local object with + methods you shouldn't call: the extras (_(set|get)) should + only be called on the server copy. Normally this should only be called via + the attached instruments anyway. + """ - If anything is not recognized, raise an error, and the query will be - added to it - ''' def __init__(self, name='Model-{:.7s}'): - # Most of the other uses of ServerManager use a separate class - # for the server itself. But here I put the two together into - # a single class, just to make it easier to define models. - # The downside is you have a local object with methods you - # shouldn't call, the extras (_(set|get)) should - # only be called on the server copy. But I think that's OK because - # this will primarily be called via the attached instruments. super().__init__(name, server_class=None) def _run_server(self): self.run_event_loop() def handle_cmd(self, cmd): + """ + Handler for all model queries. + + Args: + cmd (str): Can take several forms: + ':?': + calls ``self._get()`` and forwards + the return value. + '::': + calls ``self._set(, )`` + ':'. + calls ``self._set(, None)`` + + Returns: + Union(str, None): The parameter value, if ``cmd`` has the form + ':?', otherwise no return. + + Raises: + ValueError: if cmd does not match one of the patterns above. + """ query = cmd.split(':') instrument = query[0] @@ -150,4 +220,4 @@ def handle_cmd(self, cmd): getattr(self, instrument + '_set')(param, value) else: - raise ValueError + raise ValueError() diff --git a/qcodes/instrument/remote.py b/qcodes/instrument/remote.py index d3a84281405..7f1c95990f8 100644 --- a/qcodes/instrument/remote.py +++ b/qcodes/instrument/remote.py @@ -1,3 +1,4 @@ +"""Proxies to interact with server-based instruments from another process.""" import multiprocessing as mp from qcodes.utils.deferred_operations import DeferredOperations @@ -8,9 +9,37 @@ class RemoteInstrument(DelegateAttributes): - ''' - A proxy for an instrument (of any class) running on a server process - ''' + + """ + A proxy for an instrument (of any class) running on a server process. + + Creates the server if necessary, then loads this instrument onto it, + then mirrors the API to that instrument. + + Args: + *args: Passed along to the real instrument constructor. + + instrument_class (type): The class of the real instrument to make. + + server_name (str): The name of the server to create or use for this + instrument. If not provided (''), gets a name from + ``instrument_class.default_server_name(**kwargs)`` using the + same kwargs passed to the instrument constructor. + + **kwargs: Passed along to the real instrument constructor, also + to ``default_server_name`` as mentioned. + + Attributes: + name (str): an identifier for this instrument, particularly for + attaching it to a Station. + + parameters (Dict[Parameter]): All the parameters supported by this + instrument. Usually populated via ``add_parameter`` + + functions (Dict[Function]): All the functions supported by this + instrument. Usually populated via ``add_function`` + """ + delegate_attr_dicts = ['_methods', 'parameters', 'functions'] def __init__(self, *args, instrument_class=None, server_name='', @@ -38,16 +67,16 @@ def __init__(self, *args, instrument_class=None, server_name='', self.connect() def connect(self): + """Create the instrument on the server and replicate its API here.""" connection_attrs = self._manager.connect(self, self._instrument_class, self._args, self._kwargs) - # bind all the different categories of actions we need - # to interface with the remote instrument - # TODO: anything else? - self.name = connection_attrs['name'] self._id = connection_attrs['id'] + # bind all the different categories of actions we need + # to interface with the remote instrument + self._methods = { name: RemoteMethod(name, self, attrs) for name, attrs in connection_attrs['methods'].items() @@ -64,30 +93,65 @@ def connect(self): } def _ask_server(self, func_name, *args, **kwargs): - """ - Query the server copy of this instrument, expecting a response - """ + """Query the server copy of this instrument, expecting a response.""" return self._manager.ask('cmd', self._id, func_name, *args, **kwargs) def _write_server(self, func_name, *args, **kwargs): - """ - Send a command to the server copy of this instrument, without - waiting for a response - """ + """Send a command to the server, without waiting for a response.""" self._manager.write('cmd', self._id, func_name, *args, **kwargs) def add_parameter(self, name, **kwargs): + """ + Proxy to add a new parameter to the server instrument. + + This is only for adding parameters remotely to the server copy. + Normally parameters are added in the instrument constructor, rather + than via this method. This method is limited in that you can generally + only use the string form of a command, not the callable form. + + Args: + name (str): How the parameter will be stored within + ``instrument.parameters`` and also how you address it using the + shortcut methods: ``instrument.set(param_name, value)`` etc. + + parameter_class (Optional[type]): You can construct the parameter + out of any class. Default ``StandardParameter``. + + **kwargs: constructor arguments for ``parameter_class``. + """ attrs = self._ask_server('add_parameter', name, **kwargs) self.parameters[name] = RemoteParameter(name, self, attrs) def add_function(self, name, **kwargs): + """ + Proxy to add a new Function to the server instrument. + + This is only for adding functions remotely to the server copy. + Normally functions are added in the instrument constructor, rather + than via this method. This method is limited in that you can generally + only use the string form of a command, not the callable form. + + Args: + name (str): how the function will be stored within + ``instrument.functions`` and also how you address it using the + shortcut methods: ``instrument.call(func_name, *args)`` etc. + + **kwargs: constructor kwargs for ``Function`` + """ attrs = self._ask_server('add_function', name, **kwargs) self.functions[name] = RemoteFunction(name, self, attrs) def instances(self): + """ + A RemoteInstrument shows as an instance of its proxied class. + + Returns: + List[Union[Instrument, RemoteInstrument]] + """ return self._instrument_class.instances() def close(self): + """Irreversibly close and tear down the server & remote instruments.""" if hasattr(self, '_manager'): if self._manager._server in mp.active_children(): self._manager.delete(self._id) @@ -95,24 +159,40 @@ def close(self): self._instrument_class.remove_instance(self) def restart(self): + """Remove and recreate the server copy of this instrument.""" + # TODO - this cannot work! _manager is gone after close! self.close() self._manager.restart() def __getitem__(self, key): + """Delegate instrument['name'] to parameter or function 'name'.""" try: return self.parameters[key] except KeyError: return self.functions[key] def __repr__(self): + """repr including the instrument name.""" return named_repr(self) class RemoteComponent: - ''' - One piece of a RemoteInstrument, that proxies all of its calls to the - corresponding object in the server instrument - ''' + + """ + An object that lives inside a RemoteInstrument. + + Proxies all of its calls to the corresponding object in the server + instrument. + + Args: + name (str): The name of this component. + + instrument (RemoteInstrument): the instrument this is part of. + + attrs (dict): instance attributes to set, to match the server + copy of this component. + """ + def __init__(self, name, instrument, attrs): self.name = name self._instrument = instrument @@ -123,27 +203,61 @@ def __init__(self, name, instrument, attrs): type(self).__name__, self.name, instrument.name, value) setattr(self, attribute, value) + def __repr__(self): + """repr including the component name.""" + return named_repr(self) + class RemoteMethod(RemoteComponent): + + """Proxy for a method of the server instrument.""" + def __call__(self, *args, **kwargs): + """Call the method on the server, passing on any args and kwargs.""" return self._instrument._ask_server(self.name, *args, **kwargs) class RemoteParameter(RemoteComponent, DeferredOperations): + + """Proxy for a Parameter of the server instrument.""" + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.get_latest = GetLatest(self) def __call__(self, *args): + """ + Shortcut to get (with no args) or set (with one arg) the parameter. + + Args: + *args: If empty, get the parameter. If one arg, set the parameter + to this value + + Returns: + any: The parameter value, if called with no args, + otherwise no return. + """ if len(args) == 0: return self.get() else: self.set(*args) def get(self): + """ + Read the value of this parameter. + + Returns: + any: the current value of the parameter. + """ return self._instrument._ask_server('get', self.name) def set(self, value): + """ + Set a new value of this parameter. + + Args: + value (any): the new value for the parameter. + """ # TODO: sometimes we want set to block (as here) and sometimes # we want it async... which would just be changing the '_ask_server' # to '_write_server' below. how do we decide, and how do we let the @@ -153,45 +267,106 @@ def set(self, value): # manually copy over validate and __getitem__ so they execute locally # no reason to send these to the server, unless the validators change... def validate(self, value): + """ + Raise an error if the given value is not allowed for this Parameter. + + Args: + value (any): the proposed new parameter value. + """ return Parameter.validate(self, value) def __getitem__(self, keys): + """Create a SweepValues from this parameter with slice notation.""" return Parameter.__getitem__(self, keys) def sweep(self, *args, **kwargs): + """Create a SweepValues from this parameter. See Parameter.sweep.""" return Parameter.sweep(self, *args, **kwargs) def _latest(self): - return self._instrument._ask_server('param_call', self.name, - '_latest') + return self._instrument._ask_server('callattr', self.name + '._latest') def snapshot(self, update=False): - return self._instrument._ask_server('param_call', self.name, - 'snapshot', update) + """ + State of the parameter as a JSON-compatible dict. + + Args: + update (bool): If True, update the state by querying the + instrument. If False, just use the latest value in memory. + + Returns: + dict: snapshot + """ + return self._instrument._ask_server('callattr', + self.name + '.snapshot', update) def setattr(self, attr, value): - self._instrument._ask_server('param_setattr', self.name, - attr, value) + """ + Set an attribute of the parameter on the server. + + Args: + attr (str): the attribute name. Can be nested as in + ``NestedAttrAccess``. + value: The new value to set. + """ + self._instrument._ask_server('setattr', self.name + '.' + attr, value) def getattr(self, attr): - return self._instrument._ask_server('param_getattr', self.name, - attr) + """ + Get an attribute of the parameter on the server. - def __repr__(self): - return named_repr(self) + Args: + attr (str): the attribute name. Can be nested as in + ``NestedAttrAccess``. - # TODO: need set_sweep if it exists, and any methods a subclass defines. + Returns: + any: The attribute value. + """ + return self._instrument._ask_server('getattr', self.name + '.' + attr) + + def callattr(self, attr, *args, **kwargs): + """ + Call arbitrary methods of the parameter on the server. + + Args: + attr (str): the method name. Can be nested as in + ``NestedAttrAccess``. + *args: positional args to the method + **kwargs: keyword args to the method + + Returns: + any: the return value of the called method. + """ + return self._instrument._ask_server( + 'callattr', self.name + '.' + attr, *args, **kwargs) class RemoteFunction(RemoteComponent): + + """Proxy for a Function of the server instrument.""" + def __call__(self, *args): + """ + Call the Function. + + Args: + *args: The positional args to this Function. Functions only take + positional args, not kwargs. + + Returns: + any: the return value of the function. + """ return self._instrument._ask_server('call', self.name, *args) def call(self, *args): + """An alias for __call__.""" return self.__call__(*args) def validate(self, *args): - return Function.validate(self, *args) + """ + Raise an error if the given args are not allowed for this Function. - def __repr__(self): - return named_repr(self) + Args: + *args: the proposed arguments with which to call the Function. + """ + return Function.validate(self, *args) diff --git a/qcodes/instrument/visa.py b/qcodes/instrument/visa.py index e9f1b8a8bf4..c636262f76c 100644 --- a/qcodes/instrument/visa.py +++ b/qcodes/instrument/visa.py @@ -1,3 +1,4 @@ +"""Visa instrument driver based on pyvisa.""" import visa import logging @@ -6,26 +7,37 @@ class VisaInstrument(Instrument): - ''' - Base class for all instruments using visa connections - - name: what this instrument is called locally - address: the visa resource name. see eg: - http://pyvisa.readthedocs.org/en/stable/names.html - (can be changed later with set_address) - server_name: name of the InstrumentServer to use. By default - uses 'VisaServer', ie all visa instruments go on the same - server, but you can provide any other string, or None to - not use a server (not recommended, then it cannot be used - with subprocesses like background Loop's) - timeout: seconds to allow for responses (default 5) - (can be changed later with set_timeout) - terminator: the read termination character(s) to expect - (can be changed later with set_terminator) - - See help for qcodes.Instrument for information on writing - instrument subclasses - ''' + + """ + Base class for all instruments using visa connections. + + Args: + name (str): What this instrument is called locally. + + address (str): The visa resource name. see eg: + http://pyvisa.readthedocs.org/en/stable/names.html + + timeout (number): seconds to allow for responses. Default 5. + + terminator: Read termination character(s) to look for. Default ''. + + server_name (str): Name of the InstrumentServer to use. By default + uses 'GPIBServer' for all GPIB instruments, 'SerialServer' for + serial port instruments, and 'VisaServer' for all others. + + Use ``None`` to run locally - but then this instrument will not + work with qcodes Loops or other multiprocess procedures. + + metadata (Optional[Dict]): additional static metadata to add to this + instrument's JSON snapshot. + + See help for ``qcodes.Instrument`` for additional information on writing + instrument subclasses. + + Attributes: + visa_handle (pyvisa.resources.Resource): The communication channel. + """ + def __init__(self, name, address=None, timeout=5, terminator='', **kwargs): super().__init__(name, **kwargs) @@ -42,18 +54,33 @@ def __init__(self, name, address=None, timeout=5, terminator='', **kwargs): @classmethod def default_server_name(cls, **kwargs): + """ + Get the default server name for this instrument. + + Args: + **kwargs: All the kwargs supplied in the constructor. + + Returns: + str: The default server name, either 'GPIBServer', 'SerialServer', + or 'VisaServer' depending on ``kwargs['address']``. + """ upper_address = kwargs.get('address', '').upper() if 'GPIB' in upper_address: return 'GPIBServer' elif 'ASRL' in upper_address: return 'SerialServer' - # TODO - any others to break out by default? - # break out separate GPIB or serial connections? return 'VisaServer' def get_idn(self): - """Parse a standard VISA '*IDN?' response into an ID dict.""" + """ + Parse a standard VISA '*IDN?' response into an ID dict. + + Override this if your instrument returns a nonstandard IDN string. + + Returns: + A dict containing vendor, model, serial, and firmware. + """ idstr = self.ask('*IDN?') try: # form is supposed to be comma-separated, but we've seen @@ -77,13 +104,14 @@ def get_idn(self): return dict(zip(('vendor', 'model', 'serial', 'firmware'), idparts)) def set_address(self, address): - ''' - change the address (visa resource name) for this instrument - see eg: http://pyvisa.readthedocs.org/en/stable/names.html - ''' + """ + Change the address for this instrument. + + Args: + address: The visa resource name to use to connect. + see eg: http://pyvisa.readthedocs.org/en/stable/names.html + """ # in case we're changing the address - close the old handle first - # but not by calling self.close() because that tears down the whole - # instrument! if getattr(self, 'visa_handle', None): self.visa_handle.close() @@ -94,9 +122,13 @@ def set_address(self, address): self._address = address def set_terminator(self, terminator): - ''' - change the read terminator (string, eg '\r\n') - ''' + r""" + Change the read terminator to use. + + Args: + terminator (str): Character(s) to look for at the end of a read. + eg. '\r\n'. + """ self.visa_handle.read_termination = terminator self._terminator = terminator @@ -116,36 +148,63 @@ def _get_visa_timeout(self): return timeout_ms / 1000 def close(self): + """Disconnect and irreversibly tear down the instrument.""" if getattr(self, 'visa_handle', None): self.visa_handle.close() super().close() def check_error(self, ret_code): - ''' - Default error checking, raises an error if return code !=0 - does not differentiate between warnings or specific error messages - overwrite this function in your driver if you want to add specific - error messages - ''' + """ + Default error checking, raises an error if return code !=0. + + Does not differentiate between warnings or specific error messages. + Override this function in your driver if you want to add specific + error messages. + + Args: + ret_code (int): A Visa error code. See eg: + https://github.com/hgrecco/pyvisa/blob/master/pyvisa/errors.py + + Raises: + visa.VisaIOError: if ``ret_code`` indicates a communication + problem. + """ if ret_code != 0: raise visa.VisaIOError(ret_code) - def write(self, cmd): - try: - nr_bytes_written, ret_code = self.visa_handle.write(cmd) - self.check_error(ret_code) - except Exception as e: - e.args = e.args + ('writing ' + repr(cmd) + ' to ' + repr(self),) - raise e + def write_raw(self, cmd): + """ + Low-level interface to ``visa_handle.write``. - def ask(self, cmd): - try: - return self.visa_handle.ask(cmd) - except Exception as e: - e.args = e.args + ('asking ' + repr(cmd) + ' to ' + repr(self),) - raise e + Args: + cmd (str): The command to send to the instrument. + """ + nr_bytes_written, ret_code = self.visa_handle.write(cmd) + self.check_error(ret_code) + + def ask_raw(self, cmd): + """ + Low-level interface to ``visa_handle.ask``. + + Args: + cmd (str): The command to send to the instrument. + + Returns: + str: The instrument's response. + """ + return self.visa_handle.ask(cmd) def snapshot_base(self, update=False): + """ + State of the instrument as a JSON-compatible dict. + + Args: + update (bool): If True, update the state by querying the + instrument. If False, just use the latest values in memory. + + Returns: + dict: base snapshot + """ snap = super().snapshot_base(update=update) snap['address'] = self._address diff --git a/qcodes/tests/test_instrument.py b/qcodes/tests/test_instrument.py index 8a93b8ccd91..bf21977f835 100644 --- a/qcodes/tests/test_instrument.py +++ b/qcodes/tests/test_instrument.py @@ -105,13 +105,10 @@ def __init__(self, *args, **kwargs): class TestParameters(TestCase): def setUp(self): self.model = AMockModel() - self.read_response = 'I am the walrus!' - self.gates = MockGates(model=self.model, - read_response=self.read_response) + self.gates = MockGates(model=self.model) self.source = MockSource(model=self.model) - self.meter = MockMeter(model=self.model, - read_response=self.read_response) + self.meter = MockMeter(model=self.model, keep_history=False) self.init_ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S') @@ -194,16 +191,16 @@ def test_mock_instrument(self): # initial state # short form of getter - self.assertEqual(meter.get('amplitude'), 0) + self.assertEqual(gates.get('chan0'), 0) # shortcut to the parameter, longer form of get - self.assertEqual(meter['amplitude'].get(), 0) + self.assertEqual(gates['chan0'].get(), 0) # explicit long form of getter - self.assertEqual(meter.parameters['amplitude'].get(), 0) - # both should produce the same history entry - hist = meter.getattr('history') + self.assertEqual(gates.parameters['chan0'].get(), 0) + # all 3 should produce the same history entry + hist = gates.getattr('history') self.assertEqual(len(hist), 3) - self.assertEqual(hist[0][1:], ('ask', 'ampl')) - self.assertEqual(hist[0][1:], ('ask', 'ampl')) + for item in hist: + self.assertEqual(item[1:], ('ask', 'c0')) # errors trying to set (or validate) invalid param values # put here so we ensure that these errors don't make it to @@ -236,9 +233,10 @@ def test_mock_instrument(self): # check just the size and timestamps of histories for entry in gatehist + sourcehist + meterhist: self.check_ts(entry[0]) - self.assertEqual(len(gatehist), 6) + self.assertEqual(len(gatehist), 9) self.assertEqual(len(sourcehist), 5) - self.assertEqual(len(meterhist), 7) + # meter does not keep history but should still have a history attr + self.assertEqual(len(meterhist), 0) # plus enough setters to check the parameter sweep # first source has to get the starting value @@ -555,16 +553,9 @@ def tests_get_latest(self): with self.assertRaises(AttributeError): noise.get_latest.set(50) - def test_mock_read(self): - gates, meter = self.gates, self.meter - self.assertEqual(meter.read(), self.read_response) - self.assertEqual(gates.read(), self.read_response) - def test_base_instrument_errors(self): b = Instrument('silent', server_name=None) - with self.assertRaises(NotImplementedError): - b.read() with self.assertRaises(NotImplementedError): b.write('hello!') with self.assertRaises(NotImplementedError): @@ -633,139 +624,31 @@ def tearDown(self): # do it twice - should not error, though the second is irrelevant self.instrument.close() - def test_simple_noserver(self): - instrument = Instrument(name='test_simple_local', server_name=None) - self.instrument = instrument - - # before setting attr1 - self.assertEqual(instrument.getattr('attr1', 99), 99) - with self.assertRaises(AttributeError): - instrument.getattr('attr1') - - with self.assertRaises(TypeError): - instrument.setattr('attr1') - - self.assertFalse(hasattr(instrument, 'attr1')) - - # set it to a value - instrument.setattr('attr1', 98) - self.assertTrue(hasattr(instrument, 'attr1')) - - self.assertEqual(instrument.getattr('attr1', 99), 98) - self.assertEqual(instrument.getattr('attr1'), 98) - - # then delete it - instrument.delattr('attr1') - - with self.assertRaises(AttributeError): - instrument.delattr('attr1') - - with self.assertRaises(AttributeError): - instrument.getattr('attr1') - - def test_nested_noserver(self): - instrument = Instrument(name='test_nested_local', server_name=None) - self.instrument = instrument - - self.assertFalse(hasattr(instrument, 'd1')) - - with self.assertRaises(TypeError): - instrument.setattr(('d1', 'a', 1)) - - # set one attribute that requires creating nested levels - instrument.setattr(('d1', 'a', 1), 2) - - # can't nest inside a non-container - with self.assertRaises(TypeError): - instrument.setattr(('d1', 'a', 1, 'secret'), 42) - - # get the whole dict with simple getattr style - self.assertEqual(instrument.getattr('d1'), {'a': {1: 2}}) - - # get the whole or parts with nested style - self.assertEqual(instrument.getattr(('d1',)), {'a': {1: 2}}) - self.assertEqual(instrument.getattr(('d1',), 55), {'a': {1: 2}}) - self.assertEqual(instrument.getattr(('d1', 'a')), {1: 2}) - self.assertEqual(instrument.getattr(('d1', 'a', 1)), 2) - self.assertEqual(instrument.getattr(('d1', 'a', 1), 3), 2) - - # add an attribute inside, then delete it again - instrument.setattr(('d1', 'a', 2, 3), 4) - self.assertEqual(instrument.getattr('d1'), {'a': {1: 2, 2: {3: 4}}}) - instrument.delattr(('d1', 'a', 2, 3)) - self.assertEqual(instrument.getattr('d1'), {'a': {1: 2}}) - - # deleting it without pruning should leave empty containers - instrument.delattr(('d1', 'a', 1), prune=False) - self.assertEqual(instrument.getattr('d1'), {'a': {}}) - - with self.assertRaises(KeyError): - instrument.delattr(('d1', 'a', 1)) - - # now prune - instrument.delattr(('d1', 'a')) - self.assertIsNone(instrument.getattr('d1', None)) - - # a little more with top-level attrs as tuples - instrument.setattr(('d2',), 'potato') - self.assertEqual(instrument.getattr('d2'), 'potato') - instrument.delattr(('d2',)) - self.assertIsNone(instrument.getattr('d2', None)) - def test_server(self): instrument = Instrument(name='test_server', server_name='attr_test') self.instrument = instrument - with self.assertRaises(TypeError): - instrument.setattr(('d1', 'a', 1)) - - # set one attribute that requires creating nested levels - instrument.setattr(('d1', 'a', 1), 2) + # set one attribute with nested levels + instrument.setattr('d1', {'a': {1: 2}}) - # can't nest inside a non-container - with self.assertRaises(TypeError): - instrument.setattr(('d1', 'a', 1, 'secret'), 42) - - # get the whole dict with simple getattr style - # TODO: twice (out of maybe 50 runs) I saw the below fail, - # it returned "test_server" which should have been the response - # above if it didn't raise an error. - # I guess this is catching the error before receiving the - # next response somehow. I've added a bit of a wait in there - # that may have fixed this but lets leave the comment for a - # while to see if it recurs. + # get the whole dict self.assertEqual(instrument.getattr('d1'), {'a': {1: 2}}) + self.assertEqual(instrument.getattr('d1', 55), {'a': {1: 2}}) - # get the whole or parts with nested style - self.assertEqual(instrument.getattr(('d1',)), {'a': {1: 2}}) - self.assertEqual(instrument.getattr(('d1',), 55), {'a': {1: 2}}) - self.assertEqual(instrument.getattr(('d1', 'a')), {1: 2}) - self.assertEqual(instrument.getattr(('d1', 'a', 1)), 2) - self.assertEqual(instrument.getattr(('d1', 'a', 1), 3), 2) + # get parts + self.assertEqual(instrument.getattr('d1["a"]'), {1: 2}) + self.assertEqual(instrument.getattr("d1['a'][1]"), 2) + self.assertEqual(instrument.getattr('d1["a"][1]', 3), 2) # add an attribute inside, then delete it again - instrument.setattr(('d1', 'a', 2), 23) + instrument.setattr('d1["a"][2]', 23) self.assertEqual(instrument.getattr('d1'), {'a': {1: 2, 2: 23}}) - instrument.delattr(('d1', 'a', 2)) + instrument.delattr('d1["a"][2]') self.assertEqual(instrument.getattr('d1'), {'a': {1: 2}}) - # deleting it without pruning should leave empty containers - instrument.delattr(('d1', 'a', 1), prune=False) - self.assertEqual(instrument.getattr('d1'), {'a': {}}) - - with self.assertRaises(KeyError): - instrument.delattr(('d1', 'a', 1)) - instrument.getattr('name') - - # now prune - instrument.delattr(('d1', 'a')) - self.assertIsNone(instrument.getattr('d1', None)) - # test restarting the InstrumentServer - this clears these attrs - instrument.setattr('answer', 42) - self.assertEqual(instrument.getattr('answer', None), 42) instrument._manager.restart() - self.assertIsNone(instrument.getattr('answer', None)) + self.assertIsNone(instrument.getattr('d1', None)) class TestLocalMock(TestCase): diff --git a/qcodes/tests/test_nested_attrs.py b/qcodes/tests/test_nested_attrs.py new file mode 100644 index 00000000000..0cf0dc19744 --- /dev/null +++ b/qcodes/tests/test_nested_attrs.py @@ -0,0 +1,100 @@ +from unittest import TestCase +from qcodes.utils.nested_attrs import NestedAttrAccess + + +class TestNestedAttrAccess(TestCase): + def test_simple(self): + obj = NestedAttrAccess() + + # before setting attr1 + self.assertEqual(obj.getattr('attr1', 99), 99) + with self.assertRaises(AttributeError): + obj.getattr('attr1') + + with self.assertRaises(TypeError): + obj.setattr('attr1') + + self.assertFalse(hasattr(obj, 'attr1')) + + # set it to a value + obj.setattr('attr1', 98) + self.assertTrue(hasattr(obj, 'attr1')) + + self.assertEqual(obj.getattr('attr1', 99), 98) + self.assertEqual(obj.getattr('attr1'), 98) + + # then delete it + obj.delattr('attr1') + + with self.assertRaises(AttributeError): + obj.delattr('attr1') + + with self.assertRaises(AttributeError): + obj.getattr('attr1') + + # make and call a method + def f(a, b=0): + return a + b + + obj.setattr('m1', f) + self.assertEqual(obj.callattr('m1', 4, 1), 5) + self.assertEqual(obj.callattr('m1', 21, b=42), 63) + + def test_nested(self): + obj = NestedAttrAccess() + + self.assertFalse(hasattr(obj, 'd1')) + + with self.assertRaises(TypeError): + obj.setattr('d1') + + # set one attribute that creates nesting + obj.setattr('d1', {'a': {1: 2, 'l': [5, 6]}}) + + # can't nest inside a non-container + with self.assertRaises(TypeError): + obj.setattr('d1["a"][1]["secret"]', 42) + + # get the whole dict + self.assertEqual(obj.getattr('d1'), {'a': {1: 2, 'l': [5, 6]}}) + self.assertEqual(obj.getattr('d1', 55), {'a': {1: 2, 'l': [5, 6]}}) + + # get parts + self.assertEqual(obj.getattr('d1["a"]'), {1: 2, 'l': [5, 6]}) + self.assertEqual(obj.getattr('d1["a"][1]'), 2) + self.assertEqual(obj.getattr('d1["a"][1]', 3), 2) + with self.assertRaises(KeyError): + obj.getattr('d1["b"]') + + # add an attribute inside, then delete it again + obj.setattr('d1["a"][2]', 4) + self.assertEqual(obj.getattr('d1'), {'a': {1: 2, 2: 4, 'l': [5, 6]}}) + obj.delattr('d1["a"][2]') + self.assertEqual(obj.getattr('d1'), {'a': {1: 2, 'l': [5, 6]}}) + self.assertEqual(obj.d1, {'a': {1: 2, 'l': [5, 6]}}) + + # list access + obj.setattr('d1["a"]["l"][0]', 7) + obj.callattr('d1["a"]["l"].extend', [5, 3]) + obj.delattr('d1["a"]["l"][1]') + # while we're at it test single quotes + self.assertEqual(obj.getattr("d1['a']['l'][1]"), 5) + self.assertEqual(obj.d1['a']['l'], [7, 5, 3]) + + def test_bad_attr(self): + obj = NestedAttrAccess() + obj.d = {} + # this one works + obj.setattr('d["x"]', 1) + + bad_attrs = [ + '', '.', '[', 'x.', '[]', # simply malformed + '.x' # don't put a dot at the start + '["hi"]', # can't set an item at the top level + 'd[x]', 'd["x]', 'd["x\']' # quoting errors + ] + + for attr in bad_attrs: + with self.subTest(attr=attr): + with self.assertRaises(ValueError): + obj.setattr(attr, 1) diff --git a/qcodes/utils/nested_attrs.py b/qcodes/utils/nested_attrs.py new file mode 100644 index 00000000000..9bc2e21b6bd --- /dev/null +++ b/qcodes/utils/nested_attrs.py @@ -0,0 +1,177 @@ +"""Nested attribute / item access for use by remote proxies.""" + +import re + + +class _NoDefault: + + """Empty class to provide a missing default to getattr.""" + + +class NestedAttrAccess: + + """ + A Mixin class to provide nested access to attributes and their items. + + Primarily for use by remote proxies, so we don't need to separately + proxy all the components, and all of their components, and worry about + which are picklable, etc. + """ + + def getattr(self, attr, default=_NoDefault): + """ + Get a (possibly nested) attribute of this object. + + If there is no ``.`` or ``[]`` in ``attr``, this exactly matches + the ``getattr`` function, but you can also access smaller pieces. + + Args: + attr (str): An attribute or accessor string, like: + ``'attr.subattr[item]'``. ``item`` can be an integer or a + string. If it's a string it must be quoted. + default (any): If the attribute does not exist (at any level of + nesting), we return this. If no default is provided, throws + an ``AttributeError``. + + Returns: + The value of this attribute. + + Raises: + ValueError: If ``attr`` could not be understood. + AttributeError: If the attribute is missing and no default is + provided. + KeyError: If the item cannot be found and no default is provided. + """ + parts = self._split_attr(attr) + + # import pdb; pdb.set_trace() + + try: + return self._follow_parts(parts) + + except (AttributeError, KeyError): + if default is _NoDefault: + raise + else: + return default + + def setattr(self, attr, value): + """ + Set a (possibly nested) attribute of this object. + + If there is no ``.`` or ``[]`` in ``attr``, this exactly matches + the ``setattr`` function, but you can also access smaller pieces. + + Args: + attr (str): An attribute or accessor string, like: + ``'attr.subattr[item]'``. ``item`` can be an integer or a + string; If it's a string it must be quoted as usual. + + value (any): The object to store in this attribute. + + Raises: + ValueError: If ``attr`` could not be understood + + TypeError: If an intermediate nesting level is not a container + and the next level is an item. + + AttributeError: If an attribute with this name cannot be set. + """ + parts = self._split_attr(attr) + obj = self._follow_parts(parts[:-1]) + leaf = parts[-1] + + if str(leaf).startswith('.'): + setattr(obj, leaf[1:], value) + else: + obj[leaf] = value + + def delattr(self, attr): + """ + Delete a (possibly nested) attribute of this object. + + If there is no ``.`` or ``[]`` in ``attr``, this exactly matches + the ``delattr`` function, but you can also access smaller pieces. + + Args: + attr (str): An attribute or accessor string, like: + ``'attr.subattr[item]'``. ``item`` can be an integer or a + string; If it's a string it must be quoted as usual. + + Raises: + ValueError: If ``attr`` could not be understood + """ + parts = self._split_attr(attr) + obj = self._follow_parts(parts[:-1]) + leaf = parts[-1] + + if str(leaf).startswith('.'): + delattr(obj, leaf[1:]) + else: + del obj[leaf] + + def callattr(self, attr, *args, **kwargs): + """ + Call a (possibly nested) method of this object. + + Args: + attr (str): An attribute or accessor string, like: + ``'attr.subattr[item]'``. ``item`` can be an integer or a + string; If it's a string it must be quoted as usual. + + *args: Passed on to the method. + + **kwargs: Passed on to the method. + + Returns: + any: Whatever the method returns. + + Raises: + ValueError: If ``attr`` could not be understood + """ + func = self.getattr(attr) + return func(*args, **kwargs) + + _PARTS_RE = re.compile(r'([\.\[])') + _ITEM_RE = re.compile(r'\[(?P[^\[\]]+)\]') + _QUOTED_RE = re.compile(r'(?P[\'"])(?P[^\'"]*)(?P=q)') + + def _split_attr(self, attr): + """ + Return attr as a list of parts. + + Items in the list are: + str '.attr' for attribute access, + str 'item' for string dict keys, + integers for integer dict/sequence keys. + Other key formats are not supported + """ + # the first item is implicitly an attribute + parts = ('.' + self._PARTS_RE.sub(r'~\1', attr)).split('~') + for i, part in enumerate(parts): + item_match = self._ITEM_RE.fullmatch(part) + if item_match: + item = item_match.group('item') + quoted_match = self._QUOTED_RE.fullmatch(item) + if quoted_match: + parts[i] = quoted_match.group('str') + else: + try: + parts[i] = int(item) + except ValueError: + raise ValueError('unrecognized item: ' + item) + elif part[0] != '.' or len(part) < 2: + raise ValueError('unrecognized attribute part: ' + part) + + return parts + + def _follow_parts(self, parts): + obj = self + + for key in parts: + if str(key).startswith('.'): + obj = getattr(obj, key[1:]) + else: + obj = obj[key] + + return obj