From 8365468487582b55a7f2bfb9314f17a19bc6965f Mon Sep 17 00:00:00 2001
From: Philipp Klenze
Date: Wed, 22 Jul 2026 13:25:19 +0200
Subject: [PATCH] Add EPICS record alias support to softioc
Adds three ways to create EPICS record aliases: an `alias` keyword
argument on record creation functions (aIn, aOut, etc.), a standalone
Alias(name, alias_name) function for aliasing an existing record by
name, and AddDeviceAlias(prefix) for automatically aliasing every
record subsequently created under the current device with an
alternate prefix. Device-alias-prefixed names are generated for a
record's own aliases too, always pointing directly at the real record
rather than chaining through another alias.
Built entirely on epicsdbbuilder's existing alias primitives, with no
changes needed to epicsdbbuilder or epicscorelibs. PV-naming mistakes
(unknown alias targets, name collisions between records and aliases in
either direction, oversized generated names, and cases where a device
alias prefix can't sensibly apply to a name) are validated eagerly and
raise immediately, rather than being deferred to LoadDatabase().
Also adds the `dbla` command to the interactive_ioc shell for listing
record aliases, matching the existing dbl/dbgf/etc. commands.
Co-authored-by: Philipp Klenze
Co-authored-by: Claude
---
CHANGELOG.rst | 8 +
docs/reference/api.rst | 50 +++++++
softioc/alias.py | 172 +++++++++++++++++++++
softioc/builder.py | 31 ++++
softioc/pythonSoftIoc.py | 8 +-
softioc/softioc.py | 6 +
tests/expected_records.db | 1 +
tests/sim_records.py | 4 +-
tests/test_alias.py | 307 ++++++++++++++++++++++++++++++++++++++
tests/test_cothread.py | 4 +
10 files changed, 589 insertions(+), 2 deletions(-)
create mode 100644 softioc/alias.py
create mode 100644 tests/test_alias.py
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: