Skip to content

Commit

Permalink
Merge branch 'main' into 1022-subscription-with-fake
Browse files Browse the repository at this point in the history
  • Loading branch information
prjemian authored Nov 20, 2024
2 parents 17cd75b + 105416f commit 4062a42
Show file tree
Hide file tree
Showing 7 changed files with 126 additions and 9 deletions.
12 changes: 11 additions & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
******

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
8 changes: 4 additions & 4 deletions apstools/utils/aps_data_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
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 4062a42

Please sign in to comment.