Skip to content

Commit

Permalink
Merge pull request #1042 from BCDA-APS/1039-ad_creator-plugin-class-n…
Browse files Browse the repository at this point in the history
…ames

Prepare ad_creator() to handle plugin class names as text
  • Loading branch information
prjemian authored Nov 20, 2024
2 parents 2013e3b + 8208215 commit 105416f
Show file tree
Hide file tree
Showing 6 changed files with 121 additions and 4 deletions.
10 changes: 10 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ describe future plans.
Release expected by 2024-12-31.

Enhancements
------------

- Add 'dynamic_import()' (support 'ad_creator()' from device file).

Maintenance
-----------

- In 'ad_creator()', convert text class name to class object.

1.7.1
******

Expand Down
7 changes: 6 additions & 1 deletion apstools/devices/area_detector_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: ``{}``)
Expand Down
1 change: 1 addition & 0 deletions apstools/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 48 additions & 3 deletions apstools/utils/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
~count_child_devices_and_signals
~count_common_subdirs
~dictionary_table
~dynamic_import
~full_dotted_name
~itemizer
~listobjects
Expand Down Expand Up @@ -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])

Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions apstools/utils/tests/test_misc.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions docs/source/api/_utils.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down

0 comments on commit 105416f

Please sign in to comment.