softioc
Advanced tools
+18
-2
| Metadata-Version: 2.1 | ||
| Name: softioc | ||
| Version: 4.4.0 | ||
| Version: 4.5.0 | ||
| Summary: Embed an EPICS IOC in a Python process | ||
@@ -18,5 +18,21 @@ Home-page: https://github.com/dls-controls/pythonSoftIOC | ||
| Description-Content-Type: text/x-rst | ||
| License-File: LICENSE | ||
| Requires-Dist: epicscorelibs<7.0.7.99.1,>=7.0.7.99.0.2 | ||
| Requires-Dist: pvxslibs>=1.2.2 | ||
| Requires-Dist: numpy | ||
| Requires-Dist: epicsdbbuilder>=1.4 | ||
| Provides-Extra: useful | ||
| Requires-Dist: cothread; extra == "useful" | ||
| Requires-Dist: scipy; extra == "useful" | ||
| Requires-Dist: aioca>=1.6; extra == "useful" | ||
| Provides-Extra: dev | ||
| License-File: LICENSE | ||
| Requires-Dist: pytest-cov; extra == "dev" | ||
| Requires-Dist: pytest-flake8; extra == "dev" | ||
| Requires-Dist: flake8<5.0.0; extra == "dev" | ||
| Requires-Dist: sphinx==4.3.2; extra == "dev" | ||
| Requires-Dist: sphinx-rtd-theme-github-versions; extra == "dev" | ||
| Requires-Dist: pytest-asyncio; extra == "dev" | ||
| Requires-Dist: aioca>=1.6; extra == "dev" | ||
| Requires-Dist: cothread; sys_platform != "win32" and extra == "dev" | ||
| Requires-Dist: p4p; extra == "dev" | ||
@@ -23,0 +39,0 @@ pythonSoftIOC |
| Metadata-Version: 2.1 | ||
| Name: softioc | ||
| Version: 4.4.0 | ||
| Version: 4.5.0 | ||
| Summary: Embed an EPICS IOC in a Python process | ||
@@ -18,5 +18,21 @@ Home-page: https://github.com/dls-controls/pythonSoftIOC | ||
| Description-Content-Type: text/x-rst | ||
| License-File: LICENSE | ||
| Requires-Dist: epicscorelibs<7.0.7.99.1,>=7.0.7.99.0.2 | ||
| Requires-Dist: pvxslibs>=1.2.2 | ||
| Requires-Dist: numpy | ||
| Requires-Dist: epicsdbbuilder>=1.4 | ||
| Provides-Extra: useful | ||
| Requires-Dist: cothread; extra == "useful" | ||
| Requires-Dist: scipy; extra == "useful" | ||
| Requires-Dist: aioca>=1.6; extra == "useful" | ||
| Provides-Extra: dev | ||
| License-File: LICENSE | ||
| Requires-Dist: pytest-cov; extra == "dev" | ||
| Requires-Dist: pytest-flake8; extra == "dev" | ||
| Requires-Dist: flake8<5.0.0; extra == "dev" | ||
| Requires-Dist: sphinx==4.3.2; extra == "dev" | ||
| Requires-Dist: sphinx-rtd-theme-github-versions; extra == "dev" | ||
| Requires-Dist: pytest-asyncio; extra == "dev" | ||
| Requires-Dist: aioca>=1.6; extra == "dev" | ||
| Requires-Dist: cothread; sys_platform != "win32" and extra == "dev" | ||
| Requires-Dist: p4p; extra == "dev" | ||
@@ -23,0 +39,0 @@ pythonSoftIOC |
@@ -11,4 +11,4 @@ # Compute a version number from a git repo or archive | ||
| # These will be filled in if git archive is run or by setup.py cmdclasses | ||
| GIT_REFS = 'tag: 4.4.0' | ||
| GIT_SHA1 = '07b1168' | ||
| GIT_REFS = 'tag: 4.5.0' | ||
| GIT_SHA1 = 'eea8794' | ||
@@ -15,0 +15,0 @@ # Git describe gives us sha1, last version-like tag, and commits since then |
+21
-0
@@ -15,2 +15,3 @@ import os | ||
| db_put_field, | ||
| db_get_field, | ||
| ) | ||
@@ -87,3 +88,23 @@ from .device_core import DeviceSupportCore, RecordLookup | ||
| def get_field(self, field): | ||
| ''' Returns the given field value as a string.''' | ||
| assert hasattr(self, "_record"), \ | ||
| 'get_field may only be called after iocInit' | ||
| data = (c_char * 40)() | ||
| name = self._name + '.' + field | ||
| db_get_field(name, fields.DBF_STRING, addressof(data), 1) | ||
| return _string_at(data, 40) | ||
| def set_field(self, field, value): | ||
| '''Sets the given field to the given value. Value will be transported as | ||
| a DBF_STRING.''' | ||
| assert hasattr(self, "_record"), \ | ||
| 'set_field may only be called after iocInit' | ||
| data = (c_char * 40)() | ||
| data.value = str(value).encode() + b'\0' | ||
| name = self._name + '.' + field | ||
| db_put_field(name, fields.DBF_STRING, addressof(data), 1) | ||
| class ProcessDeviceSupportIn(ProcessDeviceSupportCore): | ||
@@ -90,0 +111,0 @@ _link_ = 'INP' |
+46
-2
@@ -110,9 +110,51 @@ /* Provide EPICS functions in Python format */ | ||
| PyExc_RuntimeError, "dbNameToAddr failed for %s", name); | ||
| if (dbPutField(&dbAddr, dbrType, pbuffer, length)) | ||
| long put_result; | ||
| /* There are two important locks to consider at this point: The Global | ||
| * Interpreter Lock (GIL) and the EPICS record lock. A deadlock is possible if | ||
| * this thread holds the GIL and wants the record lock (which happens inside | ||
| * dbPutField), and there exists another EPICS thread that has the record lock | ||
| * and wants to call Python (which requires the GIL). | ||
| * This can occur if this code is called as part of an asynchronous on_update | ||
| * callback. | ||
| * Therefore, we must ensure we relinquish the GIL while we perform this | ||
| * EPICS call, to avoid potential deadlocks. | ||
| * See https://github.com/dls-controls/pythonSoftIOC/issues/119. */ | ||
| Py_BEGIN_ALLOW_THREADS | ||
| put_result = dbPutField(&dbAddr, dbrType, pbuffer, length); | ||
| Py_END_ALLOW_THREADS | ||
| if (put_result) | ||
| return PyErr_Format( | ||
| PyExc_RuntimeError, "dbPutField failed for %s", name); | ||
| Py_RETURN_NONE; | ||
| else | ||
| Py_RETURN_NONE; | ||
| } | ||
| static PyObject *db_get_field(PyObject *self, PyObject *args) | ||
| { | ||
| const char *name; | ||
| short dbrType; | ||
| void *pbuffer; | ||
| long length; | ||
| if (!PyArg_ParseTuple(args, "shnl", &name, &dbrType, &pbuffer, &length)) | ||
| return NULL; | ||
| struct dbAddr dbAddr; | ||
| if (dbNameToAddr(name, &dbAddr)) | ||
| return PyErr_Format( | ||
| PyExc_RuntimeError, "dbNameToAddr failed for %s", name); | ||
| long get_result; | ||
| long options = 0; | ||
| /* See reasoning for Python macros in long comment in db_put_field. */ | ||
| Py_BEGIN_ALLOW_THREADS | ||
| get_result = dbGetField(&dbAddr, dbrType, pbuffer, &options, &length, NULL); | ||
| Py_END_ALLOW_THREADS | ||
| if (get_result) | ||
| return PyErr_Format( | ||
| PyExc_RuntimeError, "dbGetField failed for %s", name); | ||
| else | ||
| Py_RETURN_NONE; | ||
| } | ||
| /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ | ||
@@ -270,2 +312,4 @@ /* IOC PV put logging */ | ||
| "Put a database field to a value"}, | ||
| {"db_get_field", db_get_field, METH_VARARGS, | ||
| "Get a database field's value"}, | ||
| {"install_pv_logging", install_pv_logging, METH_VARARGS, | ||
@@ -272,0 +316,0 @@ "Install caput logging to stdout"}, |
@@ -23,5 +23,9 @@ '''External DLL imports used for implementing Python EPICS device support. | ||
| def db_put_field(name, dbr_type, pbuffer, length): | ||
| '''Put field where pbuffer is void* pointer. Returns RC''' | ||
| '''Put field where pbuffer is void* pointer. Returns None.''' | ||
| return _extension.db_put_field(name, dbr_type, pbuffer, length) | ||
| def db_get_field(name, dbr_type, pbuffer, length): | ||
| '''Get field where pbuffer is void* pointer. Returns None.''' | ||
| return _extension.db_get_field(name, dbr_type, pbuffer, length) | ||
| def install_pv_logging(acf_file): | ||
@@ -28,0 +32,0 @@ '''Install pv logging''' |
+313
-2
| import asyncio | ||
| import multiprocessing | ||
| import subprocess | ||
| import sys | ||
| import numpy | ||
@@ -350,3 +351,2 @@ import os | ||
| def validate_fixture_names(params): | ||
@@ -908,1 +908,312 @@ """Provide nice names for the out_records fixture in TestValidate class""" | ||
| pytest.fail("Process did not terminate") | ||
| class TestGetSetField: | ||
| """Tests related to get_field and set_field on records""" | ||
| test_result_rec = "TestResult" | ||
| def test_set_field_before_init_fails(self): | ||
| """Test that calling set_field before iocInit() raises an exception""" | ||
| ao = builder.aOut("testAOut") | ||
| with pytest.raises(AssertionError) as e: | ||
| ao.set_field("EGU", "Deg") | ||
| assert "set_field may only be called after iocInit" in str(e.value) | ||
| def test_get_field_before_init_fails(self): | ||
| """Test that calling get_field before iocInit() raises an exception""" | ||
| ao = builder.aOut("testAOut") | ||
| with pytest.raises(AssertionError) as e: | ||
| ao.get_field("EGU") | ||
| assert "get_field may only be called after iocInit" in str(e.value) | ||
| def get_set_test_func(self, device_name, conn): | ||
| """Run an IOC and do simple get_field/set_field calls""" | ||
| builder.SetDeviceName(device_name) | ||
| lo = builder.longOut("TestLongOut", EGU="unset", DRVH=12) | ||
| # Record to indicate success/failure | ||
| bi = builder.boolIn(self.test_result_rec, ZNAM="FAILED", ONAM="SUCCESS") | ||
| dispatcher = asyncio_dispatcher.AsyncioDispatcher() | ||
| builder.LoadDatabase() | ||
| softioc.iocInit(dispatcher) | ||
| conn.send("R") # "Ready" | ||
| log("CHILD: Sent R over Connection to Parent") | ||
| # Set and then get the EGU field | ||
| egu = "TEST" | ||
| lo.set_field("EGU", egu) | ||
| log("CHILD: set_field successful") | ||
| readback_egu = lo.get_field("EGU") | ||
| log(f"CHILD: get_field returned {readback_egu}") | ||
| assert readback_egu == egu, \ | ||
| f"EGU field was not {egu}, was {readback_egu}" | ||
| log("CHILD: assert passed") | ||
| # Test completed, report to listening camonitor | ||
| bi.set(True) | ||
| # Keep process alive while main thread works. | ||
| while (True): | ||
| if conn.poll(TIMEOUT): | ||
| val = conn.recv() | ||
| if val == "D": # "Done" | ||
| break | ||
| log("CHILD: Received exit command, child exiting") | ||
| @pytest.mark.asyncio | ||
| async def test_get_set(self): | ||
| """Test a simple set_field/get_field is successful""" | ||
| ctx = get_multiprocessing_context() | ||
| parent_conn, child_conn = ctx.Pipe() | ||
| device_name = create_random_prefix() | ||
| process = ctx.Process( | ||
| target=self.get_set_test_func, | ||
| args=(device_name, child_conn), | ||
| ) | ||
| process.start() | ||
| log("PARENT: Child started, waiting for R command") | ||
| from aioca import camonitor | ||
| try: | ||
| # Wait for message that IOC has started | ||
| select_and_recv(parent_conn, "R") | ||
| log("PARENT: received R command") | ||
| queue = asyncio.Queue() | ||
| record = device_name + ":" + self.test_result_rec | ||
| monitor = camonitor(record, queue.put) | ||
| log(f"PARENT: monitoring {record}") | ||
| new_val = await asyncio.wait_for(queue.get(), TIMEOUT) | ||
| log(f"PARENT: new_val is {new_val}") | ||
| assert new_val == 1, \ | ||
| f"Test failed, value was not 1(True), was {new_val}" | ||
| finally: | ||
| monitor.close() | ||
| # Clear the cache before stopping the IOC stops | ||
| # "channel disconnected" error messages | ||
| aioca_cleanup() | ||
| log("PARENT: Sending Done command to child") | ||
| parent_conn.send("D") # "Done" | ||
| process.join(timeout=TIMEOUT) | ||
| log(f"PARENT: Join completed with exitcode {process.exitcode}") | ||
| if process.exitcode is None: | ||
| pytest.fail("Process did not terminate") | ||
| def get_set_too_long_value(self, device_name, conn): | ||
| """Run an IOC and deliberately call set_field with a too-long value""" | ||
| builder.SetDeviceName(device_name) | ||
| lo = builder.longOut("TestLongOut", EGU="unset", DRVH=12) | ||
| # Record to indicate success/failure | ||
| bi = builder.boolIn(self.test_result_rec, ZNAM="FAILED", ONAM="SUCCESS") | ||
| dispatcher = asyncio_dispatcher.AsyncioDispatcher() | ||
| builder.LoadDatabase() | ||
| softioc.iocInit(dispatcher) | ||
| conn.send("R") # "Ready" | ||
| log("CHILD: Sent R over Connection to Parent") | ||
| # Set a too-long value and confirm it reports an error | ||
| try: | ||
| lo.set_field("EGU", "ThisStringIsFarTooLongToFitIntoTheEguField") | ||
| except ValueError as e: | ||
| # Expected error, report success to listening camonitor | ||
| assert "byte string too long" in e.args[0] | ||
| bi.set(True) | ||
| # Keep process alive while main thread works. | ||
| while (True): | ||
| if conn.poll(TIMEOUT): | ||
| val = conn.recv() | ||
| if val == "D": # "Done" | ||
| break | ||
| log("CHILD: Received exit command, child exiting") | ||
| @pytest.mark.asyncio | ||
| async def test_set_too_long_value(self): | ||
| """Test that set_field with a too-long value raises the expected | ||
| error""" | ||
| ctx = get_multiprocessing_context() | ||
| parent_conn, child_conn = ctx.Pipe() | ||
| device_name = create_random_prefix() | ||
| process = ctx.Process( | ||
| target=self.get_set_too_long_value, | ||
| args=(device_name, child_conn), | ||
| ) | ||
| process.start() | ||
| log("PARENT: Child started, waiting for R command") | ||
| from aioca import camonitor | ||
| try: | ||
| # Wait for message that IOC has started | ||
| select_and_recv(parent_conn, "R") | ||
| log("PARENT: received R command") | ||
| queue = asyncio.Queue() | ||
| record = device_name + ":" + self.test_result_rec | ||
| monitor = camonitor(record, queue.put) | ||
| log(f"PARENT: monitoring {record}") | ||
| new_val = await asyncio.wait_for(queue.get(), TIMEOUT) | ||
| log(f"PARENT: new_val is {new_val}") | ||
| assert new_val == 1, \ | ||
| f"Test failed, value was not 1(True), was {new_val}" | ||
| finally: | ||
| monitor.close() | ||
| # Clear the cache before stopping the IOC stops | ||
| # "channel disconnected" error messages | ||
| aioca_cleanup() | ||
| log("PARENT: Sending Done command to child") | ||
| parent_conn.send("D") # "Done" | ||
| process.join(timeout=TIMEOUT) | ||
| log(f"PARENT: Join completed with exitcode {process.exitcode}") | ||
| if process.exitcode is None: | ||
| pytest.fail("Process did not terminate") | ||
| class TestRecursiveSet: | ||
| """Tests related to recursive set() calls. See original issue here: | ||
| https://github.com/dls-controls/pythonSoftIOC/issues/119""" | ||
| recursive_record_name = "RecursiveLongOut" | ||
| def recursive_set_func(self, device_name, conn): | ||
| from cothread import Event | ||
| def useless_callback(value): | ||
| log("CHILD: In callback ", value) | ||
| useless_pv.set(0) | ||
| log("CHILD: Exiting callback") | ||
| def go_away(*args): | ||
| log("CHILD: received exit signal ", args) | ||
| event.Signal() | ||
| builder.SetDeviceName(device_name) | ||
| useless_pv = builder.aOut( | ||
| self.recursive_record_name, | ||
| initial_value=0, | ||
| on_update=useless_callback | ||
| ) | ||
| event = Event() | ||
| builder.Action("GO_AWAY", on_update = go_away) | ||
| builder.LoadDatabase() | ||
| softioc.iocInit() | ||
| conn.send("R") # "Ready" | ||
| log("CHILD: Sent R over Connection to Parent") | ||
| log("CHILD: About to wait") | ||
| event.Wait() | ||
| log("CHILD: Exiting") | ||
| @requires_cothread | ||
| @pytest.mark.asyncio | ||
| async def test_recursive_set(self): | ||
| """Test that recursive sets do not cause a deadlock""" | ||
| ctx = get_multiprocessing_context() | ||
| parent_conn, child_conn = ctx.Pipe() | ||
| device_name = create_random_prefix() | ||
| process = ctx.Process( | ||
| target=self.recursive_set_func, | ||
| args=(device_name, child_conn), | ||
| ) | ||
| process.start() | ||
| log("PARENT: Child started, waiting for R command") | ||
| from aioca import caput, camonitor | ||
| try: | ||
| # Wait for message that IOC has started | ||
| select_and_recv(parent_conn, "R") | ||
| log("PARENT: received R command") | ||
| record = device_name + ":" + self.recursive_record_name | ||
| log(f"PARENT: monitoring {record}") | ||
| queue = asyncio.Queue() | ||
| monitor = camonitor(record, queue.put, all_updates=True) | ||
| log("PARENT: Beginning first wait") | ||
| # Expected initial state | ||
| new_val = await asyncio.wait_for(queue.get(), TIMEOUT) | ||
| log(f"PARENT: initial new_val: {new_val}") | ||
| assert new_val == 0 | ||
| # Try a series of caput calls, to maximise chance to trigger | ||
| # the deadlock | ||
| i = 1 | ||
| while i < 500: | ||
| log(f"PARENT: begin loop with i={i}") | ||
| await caput(record, i) | ||
| new_val = await asyncio.wait_for(queue.get(), 1) | ||
| assert new_val == i | ||
| new_val = await asyncio.wait_for(queue.get(), 1) | ||
| assert new_val == 0 # .set() should reset value | ||
| i += 1 | ||
| # Signal the IOC to cleanly shut down | ||
| await caput(device_name + ":" + "GO_AWAY", 1) | ||
| except asyncio.TimeoutError as e: | ||
| raise asyncio.TimeoutError( | ||
| f"IOC did not send data back - loop froze on iteration {i} " | ||
| "- it has probably hung/deadlocked." | ||
| ) from e | ||
| finally: | ||
| monitor.close() | ||
| # Clear the cache before stopping the IOC stops | ||
| # "channel disconnected" error messages | ||
| aioca_cleanup() | ||
| process.join(timeout=TIMEOUT) | ||
| log(f"PARENT: Join completed with exitcode {process.exitcode}") | ||
| if process.exitcode is None: | ||
| process.terminate() | ||
| pytest.fail("Process did not finish cleanly, terminating") |
Alert delta unavailable
Currently unable to show alert delta for PyPI packages.
354081
4.16%3757
7.04%