diff --git a/.travis.yml b/.travis.yml index 7cbb7d02b..ed6d2479f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,10 +10,21 @@ install: - "pip install -r dev-requirements.txt" - pip install python-coveralls - pip install coverage +before_script: + # We use before_script to report version and path information in a way + # that can be easily hidden by Travis' log folding. Moreover, a nonzero + # exit code from this block kills the entire job, meaning that if we can't + # even sensibly get version information, we correctly abort. + - which python + - python --version + - which nosetests + - nosetests --version + - which pylint + - pylint --version script: - nosetests --with-coverage -w instruments - - pylint --py3k instruments/ - - pylint instruments/ + - pylint --py3k instruments + - pylint --disable=I instruments after_success: - coveralls deploy: diff --git a/doc/source/intro.rst b/doc/source/intro.rst index bc8c18ac8..733a33c44 100644 --- a/doc/source/intro.rst +++ b/doc/source/intro.rst @@ -33,9 +33,9 @@ $ pip install -r requirements.txt - `enum34`_ - `future`_ - `python-vxi11`_ -- `PyUSB`_ +- `PyUSB`_ (version 1.0a or higher, required for raw USB support) - `python-usbtmc`_ -- `PyYAML`_ +- `ruamel.yaml`_ (required for configuration file support) Optional Dependencies ~~~~~~~~~~~~~~~~~~~~~ @@ -46,7 +46,7 @@ Optional Dependencies .. _quantities: http://pythonhosted.org/quantities/ .. _enum34: https://pypi.python.org/pypi/enum34 .. _future: https://pypi.python.org/pypi/future -.. _PyYAML: https://bitbucket.org/xi/pyyaml +.. _ruamel.yaml: http://yaml.readthedocs.io .. _PyUSB: http://sourceforge.net/apps/trac/pyusb/ .. _PyVISA: http://pyvisa.sourceforge.net/ .. _python-usbtmc: https://pypi.python.org/pypi/python-usbtmc diff --git a/instruments/abstract_instruments/instrument.py b/instruments/abstract_instruments/instrument.py index 0e791142f..7163f40f0 100644 --- a/instruments/abstract_instruments/instrument.py +++ b/instruments/abstract_instruments/instrument.py @@ -310,7 +310,8 @@ def binblockread(self, data_width, fmt=None): # CLASS METHODS # URI_SCHEMES = ["serial", "tcpip", "gpib+usb", - "gpib+serial", "visa", "file", "usbtmc", "vxi11"] + "gpib+serial", "visa", "file", "usbtmc", "vxi11", + "test"] @classmethod def open_from_uri(cls, uri): @@ -330,6 +331,7 @@ def open_from_uri(cls, uri): gpib+serial:///dev/ttyACM0/15 # Currently non-functional. visa://USB::0x0699::0x0401::C0000001::0::INSTR usbtmc://USB::0x0699::0x0401::C0000001::0::INSTR + test:// For the ``serial`` URI scheme, baud rates may be explicitly specified using the query parameter ``baud=``, as in the example @@ -415,6 +417,8 @@ def open_from_uri(cls, uri): # vxi11://192.168.1.104 # vxi11://TCPIP::192.168.1.105::gpib,5::INSTR return cls.open_vxi11(parsed_uri.netloc, **kwargs) + elif parsed_uri.scheme == "test": + return cls.open_test(**kwargs) else: raise NotImplementedError("Invalid scheme or not yet " "implemented.") diff --git a/instruments/config.py b/instruments/config.py index 74d09499b..107daa157 100644 --- a/instruments/config.py +++ b/instruments/config.py @@ -12,9 +12,22 @@ import warnings try: - import yaml + import ruamel.yaml as yaml except ImportError: - yaml = None + # Some versions of ruamel.yaml are named ruamel_yaml, so try that + # too. + # + # In either case, we've observed issues with pylint where it will raise + # a false positive from its import-error checker, so we locally disable + # it here. Once the cause for the false positive has been identified, + # the import-error check should be re-enabled. + import ruamel_yaml as yaml # pylint: disable=import-error + +import quantities as pq + +from future.builtins import str + +from instruments.util_fns import setattr_expression, split_unit_str # FUNCTIONS ################################################################### @@ -37,8 +50,9 @@ def walk_dict(d, path): # Treat as a base case that the path is empty. if not path: return d - if isinstance(path, str): + if not isinstance(path, list): path = path.split("/") + if not path[0]: # If the first part of the path is empty, do nothing. return walk_dict(d, path[1:]) @@ -46,6 +60,18 @@ def walk_dict(d, path): # Otherwise, resolve that segment and recurse. return walk_dict(d[path[0]], path[1:]) +def quantity_constructor(loader, node): + """ + Constructs a `pq.Quantity` instance from a PyYAML + node tagged as ``!Q``. + """ + # Follows the example of http://stackoverflow.com/a/43081967/267841. + value = loader.construct_scalar(node) + return pq.Quantity(*split_unit_str(value)) + +# We avoid having to register !Q every time by doing as soon as the +# relevant constructor is defined. +yaml.add_constructor(u'!Q', quantity_constructor) def load_instruments(conf_file_name, conf_path="/"): """ @@ -63,6 +89,28 @@ def load_instruments(conf_file_name, conf_path="/"): the form ``{'ddg': instruments.srs.SRSDG645.open_from_uri('gpib+usb://COM7/15')}``. + Each instrument configuration section can also specify one or more attributes + to set. These attributes are specified using a ``attrs`` section as well as the + required ``class`` and ``uri`` sections. For instance, the following + dictionary creates a ThorLabs APT motor controller instrument with a single motor + model configured:: + + rot_stage: + class: !!python/name:instruments.thorabsapt.APTMotorController + uri: serial:///dev/ttyUSB0?baud=115200 + attrs: + channel[0].motor_model: PRM1-Z8 + + Unitful attributes can be specified by using the ``!Q`` tag to quickly create + instances of `pq.Quantity`. In the example above, for instance, we can set a motion + timeout as a unitful quantity:: + + attrs: + motion_timeout: !Q 1 minute + + When using the ``!Q`` tag, any text before a space is taken to be the magnitude + of the quantity, and text following is taken to be the unit specification. + By specifying a path within the configuration file, one can load only a part of the given file. For instance, consider the configuration:: @@ -78,7 +126,7 @@ def load_instruments(conf_file_name, conf_path="/"): all other keys in the YAML file. :param str conf_file_name: Name of the configuration file to load - instruments from. + instruments from. Alternatively, a file-like object may be provided. :param str conf_path: ``"/"`` separated path to the section in the configuration file to load. @@ -95,23 +143,33 @@ def load_instruments(conf_file_name, conf_path="/"): """ if yaml is None: - raise ImportError("Could not import PyYAML, which is required " + raise ImportError("Could not import ruamel.yaml, which is required " "for this function.") - with open(conf_file_name, 'r') as f: - conf_dict = yaml.load(f) + if isinstance(conf_file_name, str): + with open(conf_file_name, 'r') as f: + conf_dict = yaml.load(f, Loader=yaml.Loader) + else: + conf_dict = yaml.load(conf_file_name, Loader=yaml.Loader) conf_dict = walk_dict(conf_dict, conf_path) inst_dict = {} - for name, value in conf_dict.iteritems(): + for name, value in conf_dict.items(): try: inst_dict[name] = value["class"].open_from_uri(value["uri"]) + + if 'attrs' in value: + # We have some attrs we can set on the newly created instrument. + for attr_name, attr_value in value['attrs'].items(): + setattr_expression(inst_dict[name], attr_name, attr_value) + except IOError as ex: # FIXME: need to subclass Warning so that repeated warnings # aren't ignored. - warnings.warn("Exception occured loading device URI " + warnings.warn("Exception occured loading device with URI " "{}:\n\t{}.".format(value["uri"], ex), RuntimeWarning) inst_dict[name] = None + return inst_dict diff --git a/instruments/tests/test_config.py b/instruments/tests/test_config.py new file mode 100644 index 000000000..6a2119fa5 --- /dev/null +++ b/instruments/tests/test_config.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Module containing tests for util_fns.py +""" + +# IMPORTS #################################################################### + +from __future__ import absolute_import, unicode_literals + +from io import StringIO + +import quantities as pq + +from instruments import Instrument +from instruments.config import ( + load_instruments, yaml +) + +# TEST CASES ################################################################# + +# pylint: disable=protected-access,missing-docstring + +def test_load_test_instrument(): + config_data = StringIO(u""" +test: + class: !!python/name:instruments.Instrument + uri: test:// +""") + insts = load_instruments(config_data) + assert isinstance(insts['test'], Instrument) + +def test_load_test_instrument_subtree(): + config_data = StringIO(u""" +instruments: + test: + class: !!python/name:instruments.Instrument + uri: test:// +""") + insts = load_instruments(config_data, conf_path="/instruments") + assert isinstance(insts['test'], Instrument) + +def test_yaml_quantity_tag(): + yaml_data = StringIO(u""" +a: + b: !Q 37 tesla + c: !Q 41.2 inches + d: !Q 98 +""") + data = yaml.load(yaml_data, Loader=yaml.Loader) + assert data['a']['b'] == pq.Quantity(37, 'tesla') + assert data['a']['c'] == pq.Quantity(41.2, 'inches') + assert data['a']['d'] == 98 + +def test_load_test_instrument_setattr(): + config_data = StringIO(u""" +test: + class: !!python/name:instruments.Instrument + uri: test:// + attrs: + foo: !Q 111 GHz +""") + insts = load_instruments(config_data) + assert insts['test'].foo == pq.Quantity(111, 'GHz') diff --git a/instruments/tests/test_util_fns.py b/instruments/tests/test_util_fns.py index 914372404..ecf8e6ec5 100644 --- a/instruments/tests/test_util_fns.py +++ b/instruments/tests/test_util_fns.py @@ -16,7 +16,8 @@ from instruments.util_fns import ( ProxyList, - assume_units, convert_temperature + assume_units, convert_temperature, + setattr_expression ) # TEST CASES ################################################################# @@ -169,3 +170,45 @@ def test_temperater_conversion_failure(): @raises(ValueError) def test_assume_units_failures(): assume_units(1, 'm').rescale('s') + +def test_setattr_expression_simple(): + class A(object): + x = 'x' + y = 'y' + z = 'z' + + a = A() + setattr_expression(a, 'x', 'foo') + assert a.x == 'foo' + +def test_setattr_expression_index(): + class A(object): + x = ['x', 'y', 'z'] + + a = A() + setattr_expression(a, 'x[1]', 'foo') + assert a.x[1] == 'foo' + +def test_setattr_expression_nested(): + class B(object): + x = 'x' + class A(object): + b = None + def __init__(self): + self.b = B() + + a = A() + setattr_expression(a, 'b.x', 'foo') + assert a.b.x == 'foo' + +def test_setattr_expression_both(): + class B(object): + x = 'x' + class A(object): + b = None + def __init__(self): + self.b = [B()] + + a = A() + setattr_expression(a, 'b[0].x', 'foo') + assert a.b[0].x == 'foo' diff --git a/instruments/util_fns.py b/instruments/util_fns.py index 316343fc9..977bfdcf3 100644 --- a/instruments/util_fns.py +++ b/instruments/util_fns.py @@ -14,11 +14,13 @@ from enum import Enum, IntEnum import quantities as pq -# FUNCTIONS ################################################################### +# CONSTANTS ################################################################### -# pylint: disable=too-many-arguments +_IDX_REGEX = re.compile(r'([a-zA-Z_][a-zA-Z0-9_]*)\[(-?[0-9]*)\]') +# FUNCTIONS ################################################################### +# pylint: disable=too-many-arguments def assume_units(value, units): """ If units are not provided for ``value`` (that is, if it is a raw @@ -37,6 +39,34 @@ def assume_units(value, units): value = pq.Quantity(value, units) return value +def setattr_expression(target, name_expr, value): + """ + Recursively calls getattr/setattr for attribute + names that are miniature expressions with subscripting. + For instance, of the form ``a[0].b``. + """ + # Allow "." in attribute names so that we can set attributes + # recursively. + if '.' in name_expr: + # Recursion: We have to strip off a level of getattr. + head, name_expr = name_expr.split('.', 1) + match = _IDX_REGEX.match(head) + if match: + head_name, head_idx = match.groups() + target = getattr(target, head_name)[int(head_idx)] + else: + target = getattr(target, head) + + setattr_expression(target, name_expr, value) + else: + # Base case: We're in the last part of a dot-expression. + match = _IDX_REGEX.match(name_expr) + if match: + name, idx = match.groups() + getattr(target, name)[int(idx)] = value + else: + setattr(target, name_expr, value) + def convert_temperature(temperature, base): """ diff --git a/requirements.txt b/requirements.txt index 113c7a6a3..622bb14d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,4 @@ enum34 python-vxi11>=0.8 pyusb python-usbtmc -pyyaml +ruamel.yaml diff --git a/setup.py b/setup.py index c928c9608..b422efd4b 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,7 @@ "python-vxi11", "python-usbtmc", "pyusb", - "pyyaml" + "ruamel.yaml" ] EXTRAS_REQUIRE = { 'VISA': ["pyvisa"]