Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prepare ad_creator() to handle plugin class names as text #1042

Merged
merged 5 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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])
prjemian marked this conversation as resolved.
Show resolved Hide resolved

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