diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d3eb7752..bea58daa 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,14 @@ Versioning `_. Unreleased_ ----------- +Added: + +- Add support for EPICS record aliases: an ``alias`` keyword argument on + record creation functions, a standalone ``Alias`` function, and an + ``AddDeviceAlias`` function for aliasing every record under a device. +- Add the ``dbla`` command (list record aliases) to the ``interactive_ioc`` + shell. + Fixed: - `Correctly set alarms during blocking processing <../../pull/209>`_ diff --git a/docs/reference/api.rst b/docs/reference/api.rst index 6bbee443..6a53c93b 100644 --- a/docs/reference/api.rst +++ b/docs/reference/api.rst @@ -99,6 +99,7 @@ Test Facilities`_ documentation for more details of each function. .. autofunction:: dba .. autofunction:: dbl +.. autofunction:: dbla .. autofunction:: dbnr .. autofunction:: dbgrep .. autofunction:: dbgf @@ -361,6 +362,23 @@ and stderr streams, is sent directly to the terminal. :class:`~softioc.autosave.Autosave` for how to track PVs with autosave inside a context manager. + .. _alias: + + `alias` + ~~~~~~~ + + Available on all record types. If given, registers an additional alias + name for the record just created, equivalent to calling :func:`Alias` + with the name of the record just created and this value immediately + afterwards. As with :func:`Alias`, this may be given relative to the + current device name, or as an absolute name if it contains a colon. + + .. seealso:: + `Alias` for creating an alias for an arbitrary existing record. + + `AddDeviceAlias` for automatically aliasing every record under the + current device. + For all of these functions any EPICS database field can be assigned a value by passing it as a keyword argument for the corresponding field name (in upper @@ -508,6 +526,38 @@ record creation function. prevent the accidential creation of records with the currently set device name. +.. function:: Alias(name, alias_name) + + Adds ``alias_name`` as an alias for the record called ``name``. Both + ``name`` and ``alias_name`` may be given relative to the current device + name (see `SetDeviceName`), or as absolute names if they contain a + colon. + + The target record must already have been created earlier in this + session; aliasing a record that will only be created later, or one + that only exists in some other already-running IOC, is not supported. + + .. seealso:: + `alias` for adding an alias at the point a record is created. + +.. function:: AddDeviceAlias(prefix) + + Registers ``prefix`` as an alias for the current device name: every + record subsequently created under the current device will also be + given an alias with ``prefix`` in place of the device name -- including + any relative alias names given via the `alias` parameter or `Alias` + function, which are aliased again under ``prefix`` rather than chained + onto the original alias. For example, if the device name is ``foo`` + and, after calling ``AddDeviceAlias("test")``, a PV called ``bar`` is + created with ``alias="baz"``, the result is a record ``foo:bar`` with + aliases ``foo:baz``, ``test:bar`` and ``test:baz`` -- all four names + refer to the same record. + + Like `SetDeviceName`, this only affects records (and their aliases) + created *after* the call, not ones already created. Must be called + after `SetDeviceName`. Alias prefix registrations are cleared whenever + the device name changes. + .. function:: SetBlocking(blocking) This can be used to globally set the default of the `blocking` flag, which diff --git a/softioc/alias.py b/softioc/alias.py new file mode 100644 index 00000000..857a6867 --- /dev/null +++ b/softioc/alias.py @@ -0,0 +1,172 @@ +'''Support for adding aliases to EPICS records. + +An alias is an alternative name by which an existing record can be reached. +This module resolves alias and target names relative to the current device +name (see `softioc.builder.SetDeviceName`), validates names eagerly so that +mistakes are reported when they are made rather than when the generated +database is loaded, and glues together the alias support already provided +by `epicsdbbuilder`. +''' + +from epicsdbbuilder import GetRecordNames, LookupRecord + + +def _validate_length(full_name): + # GetRecordNames() already length-checks relative record names as they + # are resolved, but that's not the only way a name reaches us: absolute + # names bypass it, and so does device-alias-prefix substitution (which + # builds a name by string surgery, not by resolving it). Applied + # uniformly here so every alias name is checked no matter how it was + # constructed, catching e.g. a pathological AddDeviceAlias prefix that + # would otherwise generate a name long enough to crash the EPICS + # database parser instead of failing cleanly in Python. + max_length = GetRecordNames().maxLength + shown = full_name if len(full_name) <= 80 else full_name[:80] + '...' + assert 0 < len(full_name) <= max_length, \ + 'Record name %r too long' % shown + + +def resolve_name(name): + '''Resolves name to an absolute record name. If name contains a colon + it is treated as already absolute, otherwise it is resolved relative to + the current device name.''' + if ':' in name: + full_name = name + else: + full_name = GetRecordNames()(name) + _validate_length(full_name) + return full_name + + +# Every alias name declared so far this session, mapping the full alias +# name to the record it aliases. Real record names are already tracked by +# epicsdbbuilder (LookupRecord); this supplements that with alias names, so +# collisions between the two are caught immediately rather than only when +# the generated database is loaded. +_alias_names = {} + + +def _lookup_record(full_name): + '''Returns the record called full_name, or None if no such record has + been created.''' + try: + return LookupRecord(full_name) + except KeyError: + return None + + +def check_record_name(name): + '''Called before creating a new record: raises if name is already in + use as an alias. (A collision with an existing record is already + caught by epicsdbbuilder itself.)''' + full_name = resolve_name(name) + assert full_name not in _alias_names, \ + 'Alias %r already defined' % full_name + + +def add_alias(record, alias_name): + '''Adds alias_name as an alias for record. If alias_name is relative + (no colon), it is resolved under the current device name and also + propagated to every registered device alias prefix, exactly like the + record's own name. If alias_name is absolute (contains a colon), it + is added as a single alias with no propagation: an absolute name has + no device-relative part to substitute a device alias prefix into.''' + full_alias = resolve_name(alias_name) + assert full_alias not in _alias_names, \ + 'Alias %r already defined' % full_alias + assert _lookup_record(full_alias) is None, \ + 'Record %r already defined' % full_alias + record.add_alias(full_alias) + _alias_names[full_alias] = record + if ':' not in alias_name: + _propagate_device_alias(record, full_alias) + + +def create_alias(name, alias_name): + '''Creates alias_name as an alias for the record called name. Both + name and alias_name may be relative to the current device name or + absolute. The target record must already have been created earlier in + this session.''' + full_name = resolve_name(name) + record = _lookup_record(full_name) + assert record is not None, \ + 'Cannot alias unknown record %r' % full_name + add_alias(record, alias_name) + + +# ----------------------------------------------------------------------- +# Support for AddDeviceAlias. Like SetDeviceName, this only affects +# records (and their own aliases) created after it is called. + +# Alias prefixes registered for the current device. +_device_aliases = [] + + +def _device_prefix(): + '''Returns (prefix, separator) for the current device, or (None, None) + if no device name is currently set.''' + names = GetRecordNames() + if names.prefix: + return names.prefix[-1], names.separator + else: + return None, None + + +def _propagate_device_alias(record, full_name): + '''Called for a record's own primary name (from register_record) or a + relative alias (from add_alias): if any device alias prefixes are + registered, adds a copy of full_name under each prefix, pointing + directly at record. + + The names generated here (e.g. "A:t") always contain a colon, so + add_alias treats them as absolute and never propagates them again -- + there is no alias-of-alias chaining. + + If device alias prefixes are registered but full_name isn't actually + part of the current device (e.g. a nested epicsdbbuilder PushPrefix is + in play), there is no sensible substitution to make. Silently + skipping it would mean AddDeviceAlias quietly failing to do its job + for that name, so this raises instead.''' + if not _device_aliases: + return + device_prefix, separator = _device_prefix() + assert device_prefix is not None, \ + 'No device name set while device alias prefixes are registered' + assert full_name.startswith(device_prefix + separator), \ + 'Cannot apply device alias prefixes to %r: not part of the ' \ + 'current device %r' % (full_name, device_prefix) + short_name = full_name[len(device_prefix) + len(separator):] + for prefix in _device_aliases: + add_alias(record, separator.join((prefix, short_name))) + + +def register_record(record): + '''Called for every record created. Applies any device alias prefixes + already registered with AddDeviceAlias to the record's primary name.''' + _propagate_device_alias(record, record.name) + + +def add_device_alias(prefix): + '''Registers prefix as an alias prefix for the current device. Only + affects records (and their aliases) created after this call, exactly + as SetDeviceName only affects subsequently created records.''' + assert isinstance(prefix, str) and prefix, \ + 'AddDeviceAlias prefix must be a non-empty string' + assert prefix not in _device_aliases, \ + 'Device alias prefix %r already registered' % prefix + _device_aliases.append(prefix) + + +def clear_device_aliases(): + '''Called whenever the device name changes (SetDeviceName/UnsetDevice): + forgets alias prefix registrations, as they are only meaningful for the + device they were registered against.''' + _device_aliases.clear() + + +def forget_all(): + '''Called when all created records are discarded (ClearRecords): + forgets every alias name declared so far, since the records behind + them are gone. Alias prefix registrations for the (unchanged) device + survive, matching SetDeviceName's scoping.''' + _alias_names.clear() diff --git a/softioc/builder.py b/softioc/builder.py index 71f522cd..ab337e2d 100644 --- a/softioc/builder.py +++ b/softioc/builder.py @@ -1,6 +1,7 @@ import os import numpy +from . import alias from .device_core import RecordLookup from .softioc import dbLoadDatabase from .autosave import load_autosave @@ -317,6 +318,33 @@ def ClearRecords(): 'Record database has already been loaded' RecordLookup._RecordDirectory.clear() ResetRecords() + alias.forget_all() + + +# ---------------------------------------------------------------------------- +# Support for record aliases. + +def Alias(name, alias_name): + '''Adds alias_name as an alias for the record called name. Both name + and alias_name may be given relative to the current device name (see + SetDeviceName), or as absolute names if they contain a colon. + + The target record must already have been created earlier in this + session.''' + alias.create_alias(name, alias_name) + + +def AddDeviceAlias(prefix): + '''Registers prefix as an alias for the current device name: every + record subsequently created under the current device will also be + given an alias under prefix instead of the device name, including any + of that record's own aliases. Like SetDeviceName, this only affects + records created after the call, not ones already created. Must be + called after SetDeviceName. Alias prefix registrations are cleared + whenever the device name changes.''' + assert GetRecordNames().prefix, \ + 'Must call SetDeviceName() before AddDeviceAlias()' + alias.add_device_alias(prefix) # ---------------------------------------------------------------------------- @@ -325,9 +353,11 @@ def ClearRecords(): SetSimpleRecordNames(None, ':') def SetDeviceName(name): + alias.clear_device_aliases() SetPrefix(name) def UnsetDevice(): + alias.clear_device_aliases() SetPrefix(None) @@ -347,6 +377,7 @@ def UnsetDevice(): # Other builder support functions 'LoadDatabase', 'ClearRecords', 'SetDeviceName', 'UnsetDevice', + 'Alias', 'AddDeviceAlias', # Device support functions 'SetBlocking' ] diff --git a/softioc/pythonSoftIoc.py b/softioc/pythonSoftIoc.py index 8d04606f..e9aebc7b 100644 --- a/softioc/pythonSoftIoc.py +++ b/softioc/pythonSoftIoc.py @@ -5,7 +5,7 @@ import epicsdbbuilder -from . import device +from . import alias, device class RecordWrapper(object): @@ -32,8 +32,14 @@ def __init__(self, builder, device, name, **fields): if keyword in fields: device_kargs[keyword] = fields.pop(keyword) + record_alias = fields.pop('alias', None) + + alias.check_record_name(name) record = builder(name, **fields) record.address = '@' + record.name + if record_alias is not None: + alias.add_alias(record, record_alias) + alias.register_record(record) self.__set('__builder', record) self.__set('__device', device(record.name, **device_kargs)) self.__Instances.append(self) diff --git a/softioc/softioc.py b/softioc/softioc.py index 8a439a2f..ceedb94f 100644 --- a/softioc/softioc.py +++ b/softioc/softioc.py @@ -110,6 +110,12 @@ def call_f(*args): separated) list of fields is also given then the values of the fields are also printed.''') +ExportTest('dbla', (auto_encode,), ('',), '''\ +dbla(pattern='') + +Prints "alias -> record" for each alias in the database whose name matches +pattern (or all aliases if pattern is empty).''') + ExportTest('dbnr', (c_int,), (0,), '''\ dbnr(all=0) diff --git a/tests/expected_records.db b/tests/expected_records.db index 4b93fe50..854e17bf 100644 --- a/tests/expected_records.db +++ b/tests/expected_records.db @@ -145,6 +145,7 @@ record(stringout, "TS-DI-TEST-01:STRINGOUT") field(DTYP, "Python") field(OMSL, "supervisory") field(OUT, "@TS-DI-TEST-01:STRINGOUT") + alias("TS-DI-TEST-01:STRINGOUT_ALIAS") } record(waveform, "TS-DI-TEST-01:WAVEFORM") diff --git a/tests/sim_records.py b/tests/sim_records.py index 7d9daec4..2b8a4abd 100644 --- a/tests/sim_records.py +++ b/tests/sim_records.py @@ -42,7 +42,9 @@ def create_records(): boolOut('BOOLOUT', 'Zero', 'One', initial_value=True, on_update=on_update) longOut('LONGOUT', initial_value=2008, on_update=on_update) - stringOut('STRINGOUT', initial_value='watevah', on_update=on_update) + stringOut( + 'STRINGOUT', initial_value='watevah', on_update=on_update, + alias='STRINGOUT_ALIAS') mbbOut('MBBO', 'Ein', 'Zwei', 'Drei', initial_value=1) def update_sin_wf(value): diff --git a/tests/test_alias.py b/tests/test_alias.py new file mode 100644 index 00000000..74b43fc2 --- /dev/null +++ b/tests/test_alias.py @@ -0,0 +1,307 @@ +import pytest + +from conftest import create_random_prefix + +from softioc import builder +from epicsdbbuilder import WriteRecords + + +def write_and_read(tmp_path): + """Write the current set of records and return the generated .db text, + excluding the timestamped disclaimer header.""" + path = str(tmp_path / "records.db") + WriteRecords(path) + return open(path).readlines()[5:] + + +def record_block(lines, header): + """Returns the lines of the record() block starting with header (e.g. + 'record(ai, "PREFIX:NAME")'), for checking what's nested inside it.""" + text = ''.join(lines) + start = text.index(header) + end = text.index('}', start) + return text[start:end] + + +# ---------------------------------------------------------------------------- +# The three ways to declare an alias: all positive paths. + +def test_alias_relative(tmp_path): + prefix = create_random_prefix() + builder.SetDeviceName(prefix) + builder.aIn('AI') + builder.Alias('AI', 'AI_ALIAS') + + lines = write_and_read(tmp_path) + assert ' alias("%s:AI_ALIAS")\n' % prefix in lines + + +def test_alias_absolute(tmp_path): + prefix = create_random_prefix() + builder.SetDeviceName(prefix) + builder.aIn('AI') + builder.Alias(prefix + ':AI', 'SOME:OTHER:NAME') + + lines = write_and_read(tmp_path) + assert ' alias("SOME:OTHER:NAME")\n' in lines + + +def test_alias_kwarg(tmp_path): + prefix = create_random_prefix() + builder.SetDeviceName(prefix) + builder.aOut('AO', alias='AO_ALIAS') + + lines = write_and_read(tmp_path) + assert ' alias("%s:AO_ALIAS")\n' % prefix in lines + + +def test_add_device_alias(tmp_path): + prefix = create_random_prefix() + builder.SetDeviceName(prefix) + builder.AddDeviceAlias('test') + builder.aIn('AI') + + lines = write_and_read(tmp_path) + assert ' alias("test:AI")\n' in lines + + +# ---------------------------------------------------------------------------- +# AddDeviceAlias is forward-only, exactly like SetDeviceName: it only +# affects records (and their aliases) created after it is called. + +def test_add_device_alias_only_affects_subsequent_records(tmp_path): + prefix = create_random_prefix() + builder.SetDeviceName(prefix) + + builder.aIn('EARLY') + builder.AddDeviceAlias('after') + builder.longIn('LATE') + + lines = write_and_read(tmp_path) + assert not any('after:EARLY' in line for line in lines) + assert ' alias("after:LATE")\n' in lines + + +def test_add_device_alias_requires_device_name(): + with pytest.raises(AssertionError): + builder.AddDeviceAlias('prefix') + + +def test_add_device_alias_cleared_on_device_change(tmp_path): + prefix1 = create_random_prefix() + builder.SetDeviceName(prefix1) + builder.AddDeviceAlias('carried') + + prefix2 = create_random_prefix() + builder.SetDeviceName(prefix2) + builder.aIn('AI') + + lines = write_and_read(tmp_path) + assert not any('carried:' in line for line in lines) + + +def test_add_device_alias_duplicate_prefix_raises(): + prefix = create_random_prefix() + builder.SetDeviceName(prefix) + builder.AddDeviceAlias('A') + with pytest.raises(AssertionError): + builder.AddDeviceAlias('A') + + +# ---------------------------------------------------------------------------- +# A record's own aliases (from `alias=` or Alias()) are themselves given +# device-prefixed aliases, pointing directly at the target record -- never +# chained as an alias of another alias. + +def test_add_device_alias_propagates_to_own_alias(tmp_path): + prefix = create_random_prefix() + builder.SetDeviceName(prefix) + builder.AddDeviceAlias('A') + builder.aOut('t', alias='a') + + lines = write_and_read(tmp_path) + block = record_block(lines, 'record(ao, "%s:t")' % prefix) + # All three aliases are in-body clauses on the real record "t" itself. + assert ' alias("%s:a")\n' % prefix in block + assert ' alias("A:t")\n' in block + assert ' alias("A:a")\n' in block + + +def test_alias_call_after_add_device_alias_also_propagates(tmp_path): + prefix = create_random_prefix() + builder.SetDeviceName(prefix) + builder.AddDeviceAlias('A') + builder.aIn('t') + builder.Alias('t', 'a2') + + lines = write_and_read(tmp_path) + block = record_block(lines, 'record(ai, "%s:t")' % prefix) + assert ' alias("%s:a2")\n' % prefix in block + assert ' alias("A:t")\n' in block + assert ' alias("A:a2")\n' in block + + +# ---------------------------------------------------------------------------- +# ClearRecords() frees up previously used alias names for reuse. + +def test_clear_records_frees_alias_names(): + prefix = create_random_prefix() + builder.SetDeviceName(prefix) + builder.aIn('AI') + builder.Alias('AI', 'REUSED') + + builder.ClearRecords() + + # Should not raise: the record and alias from before are gone. + builder.aIn('AI') + builder.Alias('AI', 'REUSED') + + +def test_clear_records_keeps_device_alias_prefixes(tmp_path): + # Unlike alias names, device alias prefix registrations are scoped to + # the device, not to the set of created records, so they must survive + # ClearRecords() -- matching how they already survive across records + # created under the same SetDeviceName call. + prefix = create_random_prefix() + builder.SetDeviceName(prefix) + builder.AddDeviceAlias('kept') + builder.aIn('AI') + + builder.ClearRecords() + builder.aIn('AI') + + lines = write_and_read(tmp_path) + assert ' alias("kept:AI")\n' in lines + + +# ---------------------------------------------------------------------------- +# Error cases: all PV-name mistakes must raise immediately, not be deferred +# to builder.LoadDatabase()/dbLoadDatabase() time. + +def test_alias_target_not_found(): + prefix = create_random_prefix() + builder.SetDeviceName(prefix) + with pytest.raises(AssertionError): + builder.Alias('DOES_NOT_EXIST', 'ALIAS') + + +def test_alias_name_taken_by_existing_alias(): + prefix = create_random_prefix() + builder.SetDeviceName(prefix) + builder.aIn('AI') + builder.aOut('AO') + builder.Alias('AI', 'DUP') + with pytest.raises(AssertionError): + builder.Alias('AO', 'DUP') + + +def test_alias_name_taken_by_existing_record(): + prefix = create_random_prefix() + builder.SetDeviceName(prefix) + builder.aIn('AI') + builder.aOut('AO') + with pytest.raises(AssertionError): + builder.Alias('AI', 'AO') + + +def test_alias_kwarg_name_collision(): + prefix = create_random_prefix() + builder.SetDeviceName(prefix) + builder.aIn('AI', alias='DUP') + with pytest.raises(AssertionError): + builder.aOut('AO', alias='DUP') + + +def test_record_name_taken_by_existing_alias(): + prefix = create_random_prefix() + builder.SetDeviceName(prefix) + builder.aIn('AI') + builder.Alias('AI', 'TAKEN') + with pytest.raises(AssertionError): + builder.aOut('TAKEN') + + +def test_device_alias_collision_with_existing_record(): + # Device U already owns a real record whose name exactly matches what + # device T's AddDeviceAlias(U) would generate for a same-named PV. + prefix_u = create_random_prefix() + builder.SetDeviceName(prefix_u) + builder.aIn('thing') + + prefix_t = create_random_prefix() + builder.SetDeviceName(prefix_t) + builder.AddDeviceAlias(prefix_u) + with pytest.raises(AssertionError): + builder.aIn('thing') + + +def test_absolute_alias_name_too_long(): + # Absolute (colon-containing) names bypass epicsdbbuilder's own + # relative-name length check, so alias.resolve_name must apply it + # directly instead of silently accepting an over-long name. + prefix = create_random_prefix() + builder.SetDeviceName(prefix) + builder.aIn('AI') + too_long = 'A:' + 'X' * 70 + with pytest.raises(AssertionError): + builder.Alias('AI', too_long) + + +def test_device_alias_prefix_too_long_is_caught_eagerly(): + # A pathological AddDeviceAlias prefix generates a device-prefixed + # alias name via raw string substitution rather than resolve_name's + # normal relative-name path -- but since the generated name contains a + # colon, add_alias's call to resolve_name still catches it via the + # absolute-name length check, rather than a long enough generated name + # crashing the EPICS database parser's scanner at LoadDatabase() time. + prefix = create_random_prefix() + builder.SetDeviceName(prefix) + builder.AddDeviceAlias('X' * 20000) + with pytest.raises(AssertionError): + builder.aIn('AI') + + +def test_add_device_alias_requires_string_prefix(): + prefix = create_random_prefix() + builder.SetDeviceName(prefix) + with pytest.raises(AssertionError): + builder.AddDeviceAlias('') + with pytest.raises(AssertionError): + builder.AddDeviceAlias(None) + + +def test_absolute_alias_skips_device_alias_propagation(tmp_path): + # An absolute alias has no device-relative part to substitute a device + # alias prefix into, so unlike a relative alias it's added as a single + # alias with no propagation, and no "A:..." counterpart is generated + # for it -- but this must not raise, unlike an absolute alias failing + # to substitute for a record's own (always device-relative) name. + prefix = create_random_prefix() + builder.SetDeviceName(prefix) + builder.AddDeviceAlias('A') + builder.aIn('t') + builder.Alias('t', 'SOME:ABSOLUTE:NAME') + + lines = write_and_read(tmp_path) + block = record_block(lines, 'record(ai, "%s:t")' % prefix) + assert ' alias("A:t")\n' in block + assert ' alias("SOME:ABSOLUTE:NAME")\n' in block + assert block.count('alias(') == 2 + + +def test_add_device_alias_cannot_apply_under_nested_prefix(): + # epicsdbbuilder's PushPrefix/PopPrefix (reachable via builder even + # though not part of softioc's documented API) can put a record under a + # name that doesn't start with the device prefix _device_prefix() reads + # off the top of the stack -- this must raise rather than silently + # produce no device alias for that record. + from epicsdbbuilder import PushPrefix, PopPrefix + prefix = create_random_prefix() + builder.SetDeviceName(prefix) + builder.AddDeviceAlias('A') + PushPrefix('SUB') + try: + with pytest.raises(AssertionError): + builder.aIn('rec') + finally: + PopPrefix() diff --git a/tests/test_cothread.py b/tests/test_cothread.py index 3586c9ca..d0d66aee 100644 --- a/tests/test_cothread.py +++ b/tests/test_cothread.py @@ -27,6 +27,10 @@ def test_cothread_ioc(cothread_ioc): assert caget(pre + ":STRINGOUT") == "watevah" caput(pre + ":STRINGOUT", "something", wait=True) assert caget(pre + ":STRINGOUT") == "something" + # STRINGOUT alias: same underlying record, reached by a different name + assert caget(pre + ":STRINGOUT_ALIAS") == "something" + caput(pre + ":STRINGOUT_ALIAS", "watevah", wait=True) + assert caget(pre + ":STRINGOUT") == "watevah" # Check pvaccess works from p4p.client.cothread import Context with Context("pva") as ctx: