diff --git a/CHANGES.rst b/CHANGES.rst index 1b726452..fbc4fd89 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -26,12 +26,22 @@ describe future plans. Release expected by 2024-12-31. + Enhancements + ------------ + + - Add 'dynamic_import()' (support 'ad_creator()' from device file). + Fixes ----- - - PVPositionerSoftDone used an invalid subscription event type + - 'PVPositionerSoftDone' used an invalid subscription event type in unusual cases (with fake ophyd simulated devices). + Maintenance + ----------- + + - In 'ad_creator()', convert text class name to class object. + 1.7.1 ****** diff --git a/apstools/devices/area_detector_factory.py b/apstools/devices/area_detector_factory.py index 9d8c3d1c..951cbf9c 100644 --- a/apstools/devices/area_detector_factory.py +++ b/apstools/devices/area_detector_factory.py @@ -85,6 +85,7 @@ import ophyd.areadetector.plugins from ophyd import ADComponent +from ..utils import dynamic_import from .area_detector_support import AD_EpicsFileNameJPEGPlugin from .area_detector_support import AD_EpicsFileNameTIFFPlugin from .area_detector_support import HDF5FileWriterPlugin @@ -324,6 +325,10 @@ def ad_class_factory(name, bases=None, plugins=None, plugin_defaults=None): if "suffix" not in kwargs: raise KeyError(f"Must define 'suffix': {kwargs}") component_class = kwargs.pop("class") + if isinstance(component_class, str): + # Convert text class into object, such as: + # "apstools.devices.area_detector_support.SimDetectorCam_V34" + component_class = dynamic_import(component_class) suffix = kwargs.pop("suffix") # if "write_path_template" in defaults @@ -374,7 +379,7 @@ def ad_creator( *object*: Plugin configuration dictionary. (default: ``None``, PLUGIN_DEFAULTS will be used.) - kwargs + kwargs *dict*: Any additional keyword arguments for the new class definition. (default: ``{}``) diff --git a/apstools/utils/__init__.py b/apstools/utils/__init__.py index f33864c6..134249f9 100644 --- a/apstools/utils/__init__.py +++ b/apstools/utils/__init__.py @@ -59,6 +59,7 @@ from .misc import count_child_devices_and_signals from .misc import count_common_subdirs from .misc import dictionary_table +from .misc import dynamic_import from .misc import full_dotted_name from .misc import itemizer from .misc import listobjects diff --git a/apstools/utils/aps_data_management.py b/apstools/utils/aps_data_management.py index 92681a47..6904fbdf 100644 --- a/apstools/utils/aps_data_management.py +++ b/apstools/utils/aps_data_management.py @@ -124,7 +124,7 @@ def dm_setup(setup_file): """ Name the APS Data Management bash script that activates its conda environment. - The return result defines the ``BDP_WORKFLOW_OWNER`` symbol. + The return result is the 'owner' of the DM workflows. """ global DM_SETUP_FILE @@ -134,10 +134,10 @@ def dm_setup(setup_file): DM_SETUP_FILE = setup_file dm_source_environ() - bdp_workflow_owner = environ["DM_STATION_NAME"].lower() + workflow_owner = environ["DM_STATION_NAME"].lower() - logger.info("APS DM workflow owner: %s", bdp_workflow_owner) - return bdp_workflow_owner + logger.info("APS DM workflow owner: %s", workflow_owner) + return workflow_owner def build_run_metadata_dict(user_md: dict, **dm_kwargs) -> dict: diff --git a/apstools/utils/misc.py b/apstools/utils/misc.py index 0c920621..f6d73cd9 100644 --- a/apstools/utils/misc.py +++ b/apstools/utils/misc.py @@ -10,6 +10,7 @@ ~count_child_devices_and_signals ~count_common_subdirs ~dictionary_table + ~dynamic_import ~full_dotted_name ~itemizer ~listobjects @@ -92,20 +93,22 @@ def wrapper(*a, **kw): return wrapper -def cleanupText(text): +def cleanupText(text, replace="_"): """ - convert text so it can be used as a dictionary key + Convert text so it can be used as a dictionary key. Given some input text string, return a clean version remove troublesome characters, perhaps other cleanup as well. This is best done with regular expression pattern matching. """ pattern = "[a-zA-Z0-9_]" + if replace is None: + replace = "_" def mapper(c): if re.match(pattern, c) is not None: return c - return "_" + return replace return "".join([mapper(c) for c in text]) @@ -192,6 +195,48 @@ def dictionary_table(dictionary, **kwargs): return t +def dynamic_import(full_path: str) -> type: + """ + Import the object given its import path as text. + + Motivated by specification of class names for plugins + when using ``apstools.devices.ad_creator()``. + + EXAMPLES:: + + obj = dynamic_import("ophyd.EpicsMotor") + m1 = obj("gp:m1", name="m1") + + IocStats = dynamic_import("instrument.devices.ioc_stats.IocInfoDevice") + gp_stats = IocStats("gp:", name="gp_stats") + """ + from importlib import import_module + + import_object = None + + if "." not in full_path: + # fmt: off + raise ValueError( + "Must use a dotted path, no local imports." + f" Received: {full_path!r}" + ) + # fmt: on + + if full_path.startswith("."): + # fmt: off + raise ValueError( + "Must use absolute path, no relative imports." + f" Received: {full_path!r}" + ) + # fmt: on + + module_name, object_name = full_path.rsplit(".", 1) + module_object = import_module(module_name) + import_object = getattr(module_object, object_name) + + return import_object + + def full_dotted_name(obj): """ Return the full dotted name diff --git a/apstools/utils/tests/test_misc.py b/apstools/utils/tests/test_misc.py new file mode 100644 index 00000000..254303b6 --- /dev/null +++ b/apstools/utils/tests/test_misc.py @@ -0,0 +1,54 @@ +"""Test parts of the utils.misc module.""" + +import ophyd +import pytest + +from .._core import MAX_EPICS_STRINGOUT_LENGTH +from ..misc import cleanupText +from ..misc import dynamic_import + + +class CustomClass: + """some local class""" + + +@pytest.mark.parametrize( + "original, expected, replacement", + [ + ["abcd12345", "abcd12345", None], + ["aBcd12345", "aBcd12345", None], + ["abcd 12345", "abcd_12345", None], + ["abcd-12345", "abcd_12345", None], + [" abc ", "__abc__", None], + [" abc ", "__abc__", None], + [" abc ", "__abc__", "_"], + [" abc ", "..abc..", "."], + ], +) +def test_cleaupText(original, expected, replacement): + result = cleanupText(original, replace=replacement) + assert result == expected, f"{original=!r} {result=!r} {expected=!r}" + + +@pytest.mark.parametrize( + "specified, expected, error", + [ + ["ophyd.EpicsMotor", ophyd.EpicsMotor, None], + ["apstools.utils.dynamic_import", dynamic_import, None], + ["apstools.utils.misc.cleanupText", cleanupText, None], + [ + "apstools.utils._core.MAX_EPICS_STRINGOUT_LENGTH", + MAX_EPICS_STRINGOUT_LENGTH, + None, + ], + ["CustomClass", None, ValueError], + [".test_utils.CATALOG", None, ValueError], + ], +) +def test_dynamic_import(specified, expected, error): + if error is None: + obj = dynamic_import(specified) + assert obj == expected, f"{specified=!r} {obj=} {expected=}" + else: + with pytest.raises(error): + obj = dynamic_import(specified) diff --git a/docs/source/api/_utils.rst b/docs/source/api/_utils.rst index 8e8196b3..d4063b4e 100644 --- a/docs/source/api/_utils.rst +++ b/docs/source/api/_utils.rst @@ -80,6 +80,7 @@ Other Utilities ~apstools.utils.apsu_controls_subnet.warn_if_not_aps_controls_subnet ~apstools.utils.misc.cleanupText ~apstools.utils.misc.connect_pvlist + ~apstools.utils.misc.dynamic_import ~apstools.utils.email.EmailNotifications ~apstools.utils.plot.select_live_plot ~apstools.utils.plot.select_mpl_figure @@ -103,6 +104,7 @@ General ~apstools.utils.catalog.copy_filtered_catalog ~apstools.utils.query.db_query ~apstools.utils.misc.dictionary_table + ~apstools.utils.misc.dynamic_import ~apstools.utils.email.EmailNotifications ~apstools.utils.spreadsheet.ExcelDatabaseFileBase ~apstools.utils.spreadsheet.ExcelDatabaseFileGeneric