diff --git a/.vscode/launch.json b/.vscode/launch.json index 21c0b227..39175105 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,8 @@ "type": "python", "request": "launch", "program": "${file}", - "console": "integratedTerminal" + "console": "integratedTerminal", + "justMyCode": false, }, { "name": "Debug Unit Test", diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 552b4ef4..34d0bc3c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,19 @@ Versioning `_. Unreleased_ ----------- +4.3.0_ - 2023-04-04 +------------------- + +Added: + +- `Add Channel Access Report functions <../../pull/115>`_ +- Add ``WaveformIn`` to default exports from ``softioc.builder`` + +Fixed: + +- `Allow arrays of strings to be used with Waveforms <../../pull/102>`_ +- `Fix DeprecationWarning from numpy when using "+" <../../pull/123>`_ + 4.2.0_ - 2022-11-08 ------------------- @@ -165,7 +178,9 @@ Added: Last release as an EPICS module rather than a Python package -.. _Unreleased: https://github.com/dls-controls/pythonIoc/compare/4.1.0...HEAD +.. _Unreleased: https://github.com/dls-controls/pythonIoc/compare/4.3.0...HEAD +.. _4.3.0: https://github.com/dls-controls/pythonIoc/compare/4.2.0...4.3.0 +.. _4.2.0: https://github.com/dls-controls/pythonIoc/compare/4.1.0...4.2.0 .. _4.1.0: https://github.com/dls-controls/pythonIoc/compare/4.0.2...4.1.0 .. _4.0.2: https://github.com/dls-controls/pythonIoc/compare/4.0.1...4.0.2 .. _4.0.1: https://github.com/dls-controls/pythonIoc/compare/3.2.1...4.0.1 diff --git a/docs/reference/api.rst b/docs/reference/api.rst index 5bc3f777..8b7a23bc 100644 --- a/docs/reference/api.rst +++ b/docs/reference/api.rst @@ -322,12 +322,14 @@ All functions return a wrapped `ProcessDeviceSupportIn` or .. function:: Waveform(name, [value,] **fields) + WaveformIn(name, [value,] **fields) WaveformOut(name, [value,] **fields) - Create ``waveform`` records. Depending on whether `Waveform` or + Create ``waveform`` records. Depending on whether `WaveformIn` or `WaveformOut` is called the record is configured to behave as an IN or an OUT record, in particular `on_update` can only be specified when calling - `WaveformOut`. + `WaveformOut`. The name `Waveform` is an alias for `WaveformIn` and exists + for largely historical reasons. If ``value`` is specified or if an `initial_value` is specified (only one of these can be used) the value is used to initialise the waveform and to @@ -340,6 +342,11 @@ All functions return a wrapped `ProcessDeviceSupportIn` or field type name. Otherwise the field type is taken from the initial value if given, or defaults to ``'FLOAT'``. + .. note:: + Storing arrays of strings differs from other values. String arrays will always + be assumed to be encoded as Unicode strings, and will be returned to the user + as a Python list rather than a Numpy array. + The following functions generates specialised records. @@ -519,7 +526,7 @@ direction is confusing) using the following `softioc.builder` methods: :func:`~softioc.builder.aIn`, :func:`~softioc.builder.boolIn`, :func:`~softioc.builder.longIn`, :func:`~softioc.builder.stringIn`, - :func:`~softioc.builder.mbbIn`, :func:`~softioc.builder.Waveform`. + :func:`~softioc.builder.mbbIn`, :func:`~softioc.builder.WaveformIn`. Create OUT records for receiving control information into the IOC using the following methods: diff --git a/docs/tutorials/creating-an-ioc.rst b/docs/tutorials/creating-an-ioc.rst index fd7c773e..7c92d02b 100644 --- a/docs/tutorials/creating-an-ioc.rst +++ b/docs/tutorials/creating-an-ioc.rst @@ -119,7 +119,7 @@ by calling any of the following PV creation functions: :func:`~softioc.builder.longIn`, :func:`~softioc.builder.longOut`, :func:`~softioc.builder.stringIn`, :func:`~softioc.builder.stringOut`, :func:`~softioc.builder.mbbIn`, :func:`~softioc.builder.mbbOut`, - :func:`~softioc.builder.Waveform`, :func:`~softioc.builder.WaveformOut`. + :func:`~softioc.builder.WaveformIn`, :func:`~softioc.builder.WaveformOut`. These functions create, respectively, ``Python`` device bound records of the following types: diff --git a/softioc/builder.py b/softioc/builder.py index 036e6141..70ae8a1c 100644 --- a/softioc/builder.py +++ b/softioc/builder.py @@ -11,7 +11,7 @@ from . import device, pythonSoftIoc # noqa # Re-export this so users only have to import the builder -from .device import SetBlocking # noqa +from .device import SetBlocking, to_epics_str_array # noqa PythonDevice = pythonSoftIoc.PythonDevice() @@ -148,6 +148,7 @@ def Action(name, **fields): 'uint32': 'ULONG', 'float32': 'FLOAT', 'float64': 'DOUBLE', + 'bytes320': 'STRING', # Numpy term for 40-character byte str (40*8 bits) } # Coverts FTVL string to numpy type @@ -160,6 +161,7 @@ def Action(name, **fields): 'ULONG': 'uint32', 'FLOAT': 'float32', 'DOUBLE': 'float64', + 'STRING': 'S40', } @@ -211,11 +213,15 @@ def _waveform(value, fields): # Special case for [u]int64: if the initial value comes in as 64 bit # integers we cannot represent that, so recast it as [u]int32 + # Special case for array of strings to mark each element as conforming + # to EPICS 40-character string limit if datatype is None: if initial_value.dtype == numpy.int64: initial_value = numpy.require(initial_value, numpy.int32) elif initial_value.dtype == numpy.uint64: initial_value = numpy.require(initial_value, numpy.uint32) + elif initial_value.dtype.char in ('S', 'U'): + initial_value = to_epics_str_array(initial_value) else: initial_value = numpy.array([], dtype = datatype) length = _get_length(fields) @@ -231,12 +237,13 @@ def _waveform(value, fields): fields['FTVL'] = NumpyDtypeToDbf[datatype.name] -def Waveform(name, *value, **fields): +def WaveformIn(name, *value, **fields): _waveform(value, fields) _set_in_defaults(fields) return PythonDevice.waveform(name, **fields) -WaveformIn = Waveform +# Legacy name +Waveform = WaveformIn def WaveformOut(name, *value, **fields): _waveform(value, fields) @@ -319,7 +326,7 @@ def UnsetDevice(): 'longIn', 'longOut', 'stringIn', 'stringOut', 'mbbIn', 'mbbOut', - 'Waveform', 'WaveformOut', + 'Waveform', 'WaveformIn', 'WaveformOut', 'longStringIn', 'longStringOut', 'Action', # Other builder support functions diff --git a/softioc/device.py b/softioc/device.py index 0b5dad81..4fcb5add 100644 --- a/softioc/device.py +++ b/softioc/device.py @@ -351,16 +351,34 @@ class ao(ProcessDeviceSupportOut): _ctype_ = c_double _dbf_type_ = fields.DBF_DOUBLE +def to_epics_str_array(value): + """Convert the given array of Python strings to an array of EPICS + nul-terminated strings""" + result = numpy.empty(len(value), 'S40') + + for n, s in enumerate(value): + if isinstance(s, str): + val = EpicsString._ctype_() + val.value = s.encode() + b'\0' + result[n] = val.value + else: + result[n] = s + return result + def _require_waveform(value, dtype): - if isinstance(value, bytes): - # Special case hack for byte arrays. Surprisingly tricky: - value = numpy.frombuffer(value, dtype = numpy.uint8) - value = numpy.require(value, dtype = dtype) - if value.shape == (): - value.shape = (1,) - assert value.ndim == 1, 'Can\'t write multidimensional arrays' - return value + if dtype and dtype.char == 'S': + return to_epics_str_array(value) + else: + if isinstance(value, bytes): + # Special case hack for byte arrays. Surprisingly tricky: + value = numpy.frombuffer(value, dtype = numpy.uint8) + + value = numpy.require(value, dtype = dtype) + if value.shape == (): + value.shape = (1,) + assert value.ndim == 1, 'Can\'t write multidimensional arrays' + return value class WaveformBase(ProcessDeviceSupportCore): @@ -409,10 +427,13 @@ def _value_to_epics(self, value): # common class of bug, at the cost of duplicated code and data, here we # ensure a copy is taken of the value. assert len(value) <= self._nelm, 'Value too long for waveform' - return +value + return numpy.copy(value) def _epics_to_value(self, value): - return value + if self._dtype.char == 'S': + return [_string_at(s, 40) for s in value] + else: + return value def _value_to_dbr(self, value): return self._dbf_type_, len(value), value.ctypes.data, value diff --git a/softioc/fields.py b/softioc/fields.py index 63fa476e..a3bbb523 100644 --- a/softioc/fields.py +++ b/softioc/fields.py @@ -147,4 +147,4 @@ def __set_time(self, address, new_time): -__all__ = ['RecordFactory', 'DbfCodeToNumpy', 'ca_timestamp'] +__all__ = ['RecordFactory', 'ca_timestamp'] diff --git a/softioc/softioc.py b/softioc/softioc.py index a21747a2..884b72da 100644 --- a/softioc/softioc.py +++ b/softioc/softioc.py @@ -221,6 +221,42 @@ def call_f(*args): Prints all records in the I/O event scan lists.''') + +ExportTest('casr', (c_int,), (0,), '''\ +casr(level=0) + +Channel Access Server Report - print information regarding all connected CA +clients. Information printed determined by level: + += ================================================================== +0 Show a one line summary of each connected client +1 Show additional information for each client +2 Also show additional information about the server's free resources += ================================================================== +''') + +ExportTest('dbel', (auto_encode,), (), '''\ +dbel(record_name) + +This routine prints the Channel Access event list for the specified record.''') + +ExportTest('dbcar', (auto_encode, c_int), ("*", 0,), '''\ +dbcar(record_name, level) + +Database to channel access report. This command generates a report showing +database channel access links. If record_name is “*” then information +about all records is shown otherwise only information about the specified +record. + +level can have the following values: + += ================================================================== +0 Show summary information only. +1 Show summary and each CA link that is not connected. +2 Show summary and status of each CA link += ================================================================== +''') + ExportTest('generalTimeReport', (c_int,), (0,), '''\ generalTimeReport(int level) diff --git a/tests/conftest.py b/tests/conftest.py index 3051123f..b8a0a136 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,7 +21,7 @@ WAVEFORM_LENGTH = 40 # Default timeout for many operations across testing -TIMEOUT = 10 # Seconds +TIMEOUT = 20 # Seconds # Address for multiprocessing Listener/Client pair ADDRESS = ("localhost", 2345) diff --git a/tests/test_record_values.py b/tests/test_record_values.py index ad9ce836..b3c882ed 100644 --- a/tests/test_record_values.py +++ b/tests/test_record_values.py @@ -189,6 +189,76 @@ def record_values_names(fixture_value): ), numpy.ndarray, ), + ( + "wIn_byte_string_array", + builder.WaveformIn, + [b"AB123", b"CD456", b"EF789"], + ["AB123", "CD456", "EF789"], + list, + ), + ( + "wOut_byte_string_array", + builder.WaveformOut, + [b"12AB", b"34CD", b"56EF"], + ["12AB", "34CD", "56EF"], + list, + ), + ( + "wIn_unicode_string_array", + builder.WaveformIn, + ["12€½", "34¾²", "56¹³"], + ["12€½", "34¾²", "56¹³"], + list, + ), + ( + "wOut_unicode_string_array", + builder.WaveformOut, + ["12€½", "34¾²", "56¹³"], + ["12€½", "34¾²", "56¹³"], + list, + ), + ( + "wIn_string_array", + builder.WaveformIn, + ["123abc", "456def", "7890ghi"], + ["123abc", "456def", "7890ghi"], + list, + ), + ( + "wOut_string_array", + builder.WaveformOut, + ["123abc", "456def", "7890ghi"], + ["123abc", "456def", "7890ghi"], + list, + ), + ( + "wIn_mixed_array_1", + builder.WaveformIn, + ["123abc", 456, "7890ghi"], + ["123abc", "456", "7890ghi"], + list, + ), + ( + "wOut_mixed_array_1", + builder.WaveformOut, + ["123abc", 456, "7890ghi"], + ["123abc", "456", "7890ghi"], + list, + ), + ( + "wIn_mixed_array_2", + builder.WaveformIn, + [123, 456, "7890ghi"], + ["123", "456", "7890ghi"], + list, + ), + ( + "wOut_mixed_array_2", + builder.WaveformOut, + [123, 456, "7890ghi"], + ["123", "456", "7890ghi"], + list, + ), ( "longStringIn_str", builder.longStringIn, @@ -239,6 +309,7 @@ def record_values(request): """A list of parameters for record value setting/getting tests. Fields are: + - Record name - Record builder function - Input value passed to .set()/initial_value/caput - Expected output value after doing .get()/caget @@ -376,8 +447,31 @@ def run_test_function( expected value. set_enum and get_enum determine when the record's value is set and how the value is retrieved, respectively.""" - ctx = get_multiprocessing_context() + def is_valid(configuration): + """Remove some cases that cannot succeed. + Waveforms of Strings must have the value specified as initial value.""" + ( + record_name, + creation_func, + initial_value, + expected_value, + expected_type, + ) = configuration + + if creation_func in (builder.WaveformIn, builder.WaveformOut): + if isinstance(initial_value, list) and \ + any(isinstance(val, (str, bytes)) for val in initial_value): + if set_enum is not SetValueEnum.INITIAL_VALUE: + print(f"Removing {configuration}") + return False + + return True + record_configurations = [ + x for x in record_configurations if is_valid(x) + ] + + ctx = get_multiprocessing_context() parent_conn, child_conn = ctx.Pipe() ioc_process = ctx.Process( @@ -462,6 +556,7 @@ def run_test_function( if ( creation_func in [builder.WaveformOut, builder.WaveformIn] + and hasattr(expected_value, 'dtype') and expected_value.dtype in [numpy.float64, numpy.int32] ): log( @@ -470,6 +565,11 @@ def run_test_function( "scalar. Therefore we skip this check.") continue + # caget on a waveform of strings will return a numpy array. + # Must extract it out to a list to match .get() + if isinstance(rec_val, numpy.ndarray) and len(rec_val) > 1 \ + and rec_val.dtype.char in ["S", "U"]: + rec_val = [s for s in rec_val] record_value_asserts( creation_func, rec_val, expected_value, expected_type @@ -485,13 +585,6 @@ def run_test_function( pytest.fail("Process did not terminate") -def skip_long_strings(record_values): - if ( - record_values[0] in [builder.stringIn, builder.stringOut] - and len(record_values[1]) > 40 - ): - pytest.skip("CAPut blocks strings > 40 characters.") - class TestGetValue: """Tests that use .get() to check whether values applied with .set(), @@ -509,6 +602,14 @@ def test_value_pre_init_set(self, record_values): expected_type, ) = record_values + if ( + creation_func in [builder.WaveformIn, builder.WaveformOut] and + isinstance(initial_value, list) and + any(isinstance(s, (str, bytes)) for s in initial_value) + ): + pytest.skip("Cannot .set() a list of strings to a waveform, must" + "initially specify using initial_value or FTVL") + kwarg = {} if creation_func in [builder.WaveformIn, builder.WaveformOut]: kwarg = {"length": WAVEFORM_LENGTH} # Must specify when no value @@ -753,6 +854,9 @@ def test_value_none_rejected_set_before_init( def none_value_test_func(self, record_func, queue): """Start the IOC and catch the expected exception""" + + log("CHILD: Child started") + kwarg = {} if record_func in [builder.WaveformIn, builder.WaveformOut]: kwarg = {"length": WAVEFORM_LENGTH} # Must specify when no value @@ -881,7 +985,48 @@ def test_waveform_rejects_overlong_values(self): w_in = builder.WaveformIn("W_IN", [1, 2, 3]) w_out = builder.WaveformOut("W_OUT", [1, 2, 3]) + w_in_str = builder.WaveformIn("W_IN_STR", ["ABC", "DEF"]) + w_out_str = builder.WaveformOut("W_OUT_STR", ["ABC", "DEF"]) + with pytest.raises(AssertionError): w_in.set([1, 2, 3, 4]) with pytest.raises(AssertionError): w_out.set([1, 2, 3, 4]) + with pytest.raises(AssertionError): + w_in_str.set(["ABC", "DEF", "GHI"]) + with pytest.raises(AssertionError): + w_out_str.set(["ABC", "DEF", "GHI"]) + + def test_waveform_rejects_late_strings(self): + """Test that a waveform won't allow a list of strings to be assigned + if no string list was given in initial waveform construction""" + w_in = builder.WaveformIn("W_IN", length=10) + w_out = builder.WaveformOut("W_OUT", length=10) + + with pytest.raises(ValueError): + w_in.set(["ABC", "DEF"]) + with pytest.raises(ValueError): + w_out.set(["ABC", "DEF"]) + + def test_waveform_rejects_long_array_of_strings(self): + """Test that a waveform of strings won't accept too long strings""" + w_in = builder.WaveformIn( + "W_IN", + initial_value=["123abc", "456def", "7890ghi"] + ) + w_out = builder.WaveformIn( + "W_OUT", + initial_value=["123abc", "456def", "7890ghi"] + ) + + # Test putting too many elements + with pytest.raises(AssertionError): + w_in.set(["1", "2", "3", "4"]) + with pytest.raises(AssertionError): + w_out.set(["1", "2", "3", "4"]) + + # Test putting too long a string + with pytest.raises(ValueError): + w_in.set([VERY_LONG_STRING]) + with pytest.raises(ValueError): + w_out.set([VERY_LONG_STRING]) diff --git a/tests/test_records.py b/tests/test_records.py index 768f7809..8e347307 100644 --- a/tests/test_records.py +++ b/tests/test_records.py @@ -459,6 +459,8 @@ def on_update_test_func( self, device_name, record_func, conn, always_update ): + log("CHILD: Child started") + builder.SetDeviceName(device_name) li = builder.longIn("ON-UPDATE-COUNTER-RECORD", initial_value=0)