diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2422aaf6..aba990e5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,6 +11,7 @@ Unreleased_ ----------- - `Allow "status" and "severity" on In record init <../../pull/111>`_ +- `Allow users to reset the list of created records <../../pull/114>`_ 4.1.0_ - 2022-08-05 ------------------- diff --git a/docs/reference/api.rst b/docs/reference/api.rst index 062200f3..5bc3f777 100644 --- a/docs/reference/api.rst +++ b/docs/reference/api.rst @@ -426,6 +426,12 @@ The following attributes allow more direct access to record creation. See `../how-to/use-soft-records` for a full example of its usage. +.. function:: ClearRecords() + This can be used to remove all created records. This means the same record + names can be re-used for different record types. This cannot be used once the + record database has been loaded, and is only designed to be used to help with + testing. + Finally, the following function is used to load record definitions before starting the IOC. diff --git a/softioc/builder.py b/softioc/builder.py index cc53b913..036e6141 100644 --- a/softioc/builder.py +++ b/softioc/builder.py @@ -1,6 +1,7 @@ import os import numpy +from .device_core import RecordLookup from .softioc import dbLoadDatabase from epicsdbbuilder import * @@ -273,9 +274,6 @@ def longStringOut(name, **fields): # ---------------------------------------------------------------------------- # Support routines for builder - -_DatabaseWritten = False - def LoadDatabase(): '''This should be called after all the builder records have been created, but before calling iocInit(). The database is loaded into EPICS memory, @@ -290,6 +288,14 @@ def LoadDatabase(): pythonSoftIoc.RecordWrapper.reset_builder() +def ClearRecords(): + """Delete all created record information, allowing new record creation""" + assert not pythonSoftIoc.RecordWrapper.is_builder_reset(), \ + 'Record database has already been loaded' + RecordLookup._RecordDirectory.clear() + ResetRecords() + + # ---------------------------------------------------------------------------- # Record name configuration. A device name prefix must be specified. @@ -317,7 +323,7 @@ def UnsetDevice(): 'longStringIn', 'longStringOut', 'Action', # Other builder support functions - 'LoadDatabase', + 'LoadDatabase', 'ClearRecords', 'SetDeviceName', 'UnsetDevice', # Device support functions 'SetBlocking' diff --git a/softioc/pythonSoftIoc.py b/softioc/pythonSoftIoc.py index 47b9d00e..0aa36e05 100644 --- a/softioc/pythonSoftIoc.py +++ b/softioc/pythonSoftIoc.py @@ -17,7 +17,7 @@ class RecordWrapper(object): __builder_reset = False def __init__(self, builder, device, name, **fields): - assert not self.__builder_reset, \ + assert not self.is_builder_reset(), \ 'It\'s too late to create records' # List of keyword arguments expected by the device constructor. The # remaining arguments are passed to the builder. It's a shame we @@ -47,6 +47,12 @@ def reset_builder(cls): for instance in cls.__Instances: instance.__set('__builder', None) + @classmethod + def is_builder_reset(cls): + '''Returns True if it is too late to create records''' + return cls.__builder_reset + + # Most attributes delegate directly to the builder instance until the # database has been written. At this point the builder instance has been diff --git a/softioc/softioc.py b/softioc/softioc.py index 24d291a1..a21747a2 100644 --- a/softioc/softioc.py +++ b/softioc/softioc.py @@ -4,8 +4,6 @@ from ctypes import * from tempfile import NamedTemporaryFile -from epicsdbbuilder.recordset import recordset - from . import imports, device from . import cothread_dispatcher diff --git a/tests/conftest.py b/tests/conftest.py index fb09fd50..3051123f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,8 +10,7 @@ import pytest # Must import softioc before epicsdbbuilder -from softioc.device_core import RecordLookup -from epicsdbbuilder import ResetRecords +from softioc.builder import ClearRecords requires_cothread = pytest.mark.skipif( sys.platform.startswith("win"), reason="Cothread doesn't work on windows" @@ -88,20 +87,12 @@ def asyncio_ioc_override(): ioc.kill() aioca_cleanup() -def _clear_records(): - # Remove any records created at epicsdbbuilder layer - ResetRecords() - # And at pythonSoftIoc level - # TODO: Remove this hack and use use whatever comes out of - # https://github.com/dls-controls/pythonSoftIOC/issues/56 - RecordLookup._RecordDirectory.clear() - @pytest.fixture(autouse=True) def clear_records(): """Deletes all records before and after every test""" - _clear_records() + ClearRecords() yield - _clear_records() + ClearRecords() @pytest.fixture(autouse=True) def enable_code_coverage(): diff --git a/tests/test_records.py b/tests/test_records.py index 5f2a5ecc..768f7809 100644 --- a/tests/test_records.py +++ b/tests/test_records.py @@ -9,7 +9,6 @@ log, create_random_prefix, requires_cothread, - _clear_records, WAVEFORM_LENGTH, TIMEOUT, select_and_recv, @@ -19,6 +18,7 @@ from softioc import asyncio_dispatcher, builder, softioc from softioc import alarm from softioc.device import SetBlocking +from softioc.device_core import LookupRecord, LookupRecordList # Test file for miscellaneous tests related to records @@ -38,7 +38,6 @@ def test_records(tmp_path): # Ensure we definitely unload all records that may be hanging over from # previous tests, then create exactly one instance of expected records. from sim_records import create_records - _clear_records() create_records() path = str(tmp_path / "records.db") @@ -228,6 +227,68 @@ def test_setting_alarm_invalid_keywords(creation_func): assert e.value.args[0] == 'Can\'t specify both status and STAT' +def test_clear_records(): + """Test that clearing the known records allows creation of a record of the + same name afterwards""" + + device_name = create_random_prefix() + builder.SetDeviceName(device_name) + + record_name = "NEW-RECORD" + + builder.aOut(record_name) + + # First check that we cannot create two of the same name + with pytest.raises(AssertionError): + builder.aOut(record_name) + + builder.ClearRecords() + + # Then confirm we can create one of this name + builder.aOut(record_name) + + # Finally prove that the underlying record holder contains only one record + # Records are stored as full device:record name + full_name = device_name + ":" + record_name + assert LookupRecord(full_name) + assert len(LookupRecordList()) == 1 + for key, val in LookupRecordList(): + assert key == full_name + +def clear_records_runner(child_conn): + """Tests ClearRecords after loading the database""" + builder.aOut("Some-Record") + builder.LoadDatabase() + + try: + builder.ClearRecords() + except AssertionError: + # Expected error + child_conn.send("D") # "Done" + child_conn.send("F") # "Fail" + +def test_clear_records_too_late(): + """Test that calling ClearRecords after a database has been loaded raises + an exception""" + ctx = get_multiprocessing_context() + + parent_conn, child_conn = ctx.Pipe() + + process = ctx.Process( + target=clear_records_runner, + args=(child_conn,), + ) + + process.start() + + try: + select_and_recv(parent_conn, "D") + finally: + process.join(timeout=TIMEOUT) + if process.exitcode is None: + pytest.fail("Process did not terminate") + + def validate_fixture_names(params): """Provide nice names for the out_records fixture in TestValidate class"""