apsbits 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apsbits/__init__.py +18 -0
- apsbits/_version.py +21 -0
- apsbits/core/__init__.py +11 -0
- apsbits/core/best_effort_init.py +51 -0
- apsbits/core/catalog_init.py +36 -0
- apsbits/core/run_engine_init.py +118 -0
- apsbits/demo_instrument/README.md +1 -0
- apsbits/demo_instrument/__init__.py +22 -0
- apsbits/demo_instrument/callbacks/__init__.py +1 -0
- apsbits/demo_instrument/callbacks/nexus_data_file_writer.py +58 -0
- apsbits/demo_instrument/callbacks/spec_data_file_writer.py +97 -0
- apsbits/demo_instrument/configs/__init__.py +1 -0
- apsbits/demo_instrument/configs/devices.yml +52 -0
- apsbits/demo_instrument/configs/devices_aps_only.yml +6 -0
- apsbits/demo_instrument/configs/iconfig.yml +82 -0
- apsbits/demo_instrument/configs/logging.yml +41 -0
- apsbits/demo_instrument/devices/__init__.py +1 -0
- apsbits/demo_instrument/plans/__init__.py +8 -0
- apsbits/demo_instrument/plans/dm_plans.py +111 -0
- apsbits/demo_instrument/plans/sim_plans.py +69 -0
- apsbits/demo_instrument/startup.py +76 -0
- apsbits/demo_qserver/qs-config.yml +51 -0
- apsbits/demo_qserver/qs_host.sh +231 -0
- apsbits/demo_qserver/user_group_permissions.yaml +46 -0
- apsbits/tests/__init__.py +1 -0
- apsbits/tests/conftest.py +39 -0
- apsbits/tests/test_config.py +113 -0
- apsbits/tests/test_device_factories.py +44 -0
- apsbits/tests/test_general.py +98 -0
- apsbits/tests/test_stored_dict.py +139 -0
- apsbits/utils/__init__.py +1 -0
- apsbits/utils/aps_functions.py +67 -0
- apsbits/utils/config_loaders.py +169 -0
- apsbits/utils/controls_setup.py +107 -0
- apsbits/utils/create_new_instrument.py +129 -0
- apsbits/utils/helper_functions.py +123 -0
- apsbits/utils/logging_setup.py +211 -0
- apsbits/utils/make_devices.py +162 -0
- apsbits/utils/metadata.py +96 -0
- apsbits/utils/sim_creator.py +202 -0
- apsbits/utils/stored_dict.py +174 -0
- apsbits-1.0.0.dist-info/METADATA +195 -0
- apsbits-1.0.0.dist-info/RECORD +47 -0
- apsbits-1.0.0.dist-info/WHEEL +5 -0
- apsbits-1.0.0.dist-info/entry_points.txt +2 -0
- apsbits-1.0.0.dist-info/licenses/LICENSE +48 -0
- apsbits-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EPICS & ophyd related setup
|
|
3
|
+
===========================
|
|
4
|
+
|
|
5
|
+
.. autosummary::
|
|
6
|
+
~oregistry
|
|
7
|
+
~set_control_layer
|
|
8
|
+
~set_timeouts
|
|
9
|
+
~epics_scan_id_source
|
|
10
|
+
~connect_scan_id_pv
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
import ophyd
|
|
16
|
+
from ophyd.signal import EpicsSignalBase
|
|
17
|
+
from ophydregistry import Registry
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
logger.bsdev(__file__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
DEFAULT_CONTROL_LAYER = "PyEpics"
|
|
24
|
+
DEFAULT_TIMEOUT = 60 # default used next...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def epics_scan_id_source(_md):
|
|
28
|
+
"""
|
|
29
|
+
Callback function for RunEngine. Returns *next* scan_id to be used.
|
|
30
|
+
|
|
31
|
+
* Ignore metadata dictionary passed as argument.
|
|
32
|
+
* Get current scan_id from PV.
|
|
33
|
+
* Apply lower limit of zero.
|
|
34
|
+
* Increment (so that scan_id numbering starts from 1).
|
|
35
|
+
* Set PV with new value.
|
|
36
|
+
* Return new value.
|
|
37
|
+
|
|
38
|
+
Exception will be raised if PV is not connected when next
|
|
39
|
+
``bps.open_run()`` is called.
|
|
40
|
+
"""
|
|
41
|
+
scan_id_epics = oregistry.find(name="scan_id_epics")
|
|
42
|
+
new_scan_id = max(scan_id_epics.get(), 0) + 1
|
|
43
|
+
scan_id_epics.put(new_scan_id)
|
|
44
|
+
return new_scan_id
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def connect_scan_id_pv(RE, pv: str = None, scan_id_pv: str = None):
|
|
48
|
+
"""
|
|
49
|
+
Define a PV to use for the RunEngine's `scan_id`.
|
|
50
|
+
"""
|
|
51
|
+
from ophyd import EpicsSignal
|
|
52
|
+
|
|
53
|
+
pv = pv or scan_id_pv
|
|
54
|
+
if pv is None:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
scan_id_epics = EpicsSignal(pv, name="scan_id_epics")
|
|
59
|
+
except TypeError: # when Sphinx substitutes EpicsSignal with _MockModule
|
|
60
|
+
return
|
|
61
|
+
logger.info("Using EPICS PV %r for RunEngine 'scan_id'", pv)
|
|
62
|
+
|
|
63
|
+
# Setup the RunEngine to call epics_scan_id_source()
|
|
64
|
+
# which uses the EPICS PV to provide the scan_id.
|
|
65
|
+
RE.scan_id_source = epics_scan_id_source
|
|
66
|
+
|
|
67
|
+
scan_id_epics.wait_for_connection()
|
|
68
|
+
try:
|
|
69
|
+
RE.md["scan_id_pv"] = scan_id_epics.pvname
|
|
70
|
+
RE.md["scan_id"] = scan_id_epics.get() # set scan_id from EPICS
|
|
71
|
+
except TypeError:
|
|
72
|
+
pass # Ignore PersistentDict errors that only raise when making the docs
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def set_control_layer(control_layer: str = DEFAULT_CONTROL_LAYER):
|
|
76
|
+
"""
|
|
77
|
+
Communications library between ophyd and EPICS Channel Access.
|
|
78
|
+
|
|
79
|
+
Choices are: PyEpics (default) or caproto.
|
|
80
|
+
|
|
81
|
+
OPHYD_CONTROL_LAYER is an application of "lessons learned."
|
|
82
|
+
|
|
83
|
+
Only used in a couple rare cases where PyEpics code was failing.
|
|
84
|
+
It's defined here since it was difficult to find how to do this
|
|
85
|
+
in the ophyd documentation.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
ophyd.set_cl(control_layer.lower())
|
|
89
|
+
|
|
90
|
+
logger.info("using ophyd control layer: %r", ophyd.cl.name)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def set_timeouts(timeouts):
|
|
94
|
+
"""Set default timeout for all EpicsSignal connections & communications."""
|
|
95
|
+
if not EpicsSignalBase._EpicsSignalBase__any_instantiated:
|
|
96
|
+
# Only BEFORE any EpicsSignalBase (or subclass) are created!
|
|
97
|
+
EpicsSignalBase.set_defaults(
|
|
98
|
+
auto_monitor=True,
|
|
99
|
+
timeout=timeouts.get("PV_READ", DEFAULT_TIMEOUT),
|
|
100
|
+
write_timeout=timeouts.get("PV_WRITE", DEFAULT_TIMEOUT),
|
|
101
|
+
connection_timeout=timeouts.get("PV_CONNECTION", DEFAULT_TIMEOUT),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
oregistry = Registry(auto_register=True)
|
|
106
|
+
"""Registry of all ophyd-style Devices and Signals."""
|
|
107
|
+
oregistry.warn_duplicates = False
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Create a new instrument from a fixed template.
|
|
4
|
+
|
|
5
|
+
Copies the template directory and updates pyproject.toml and .templatesyncignore.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "1.0.0"
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def copy_instrument(destination_dir: Path) -> None:
|
|
19
|
+
"""
|
|
20
|
+
Copy template directory to the destination.
|
|
21
|
+
|
|
22
|
+
:param template_dir: Path to the template directory.
|
|
23
|
+
:param destination_dir: Path to the new instrument directory.
|
|
24
|
+
:return: None
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
demo_template_path: Path = (
|
|
28
|
+
Path(__file__).resolve().parent.parent / "demo_instrument"
|
|
29
|
+
).resolve()
|
|
30
|
+
|
|
31
|
+
shutil.copytree(str(demo_template_path), str(destination_dir))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def create_qserver(qserver_dir: Path, name: str) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Create a qserver config file in the destination directory.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
demo_qserver_path: Path = (
|
|
40
|
+
Path(__file__).resolve().parent.parent / "demo_qserver"
|
|
41
|
+
).resolve()
|
|
42
|
+
|
|
43
|
+
os.makedirs(qserver_dir, exist_ok=True)
|
|
44
|
+
# Copy all yml files from demo_qserver to destination qserver dir
|
|
45
|
+
for yml_file in demo_qserver_path.glob("*"):
|
|
46
|
+
shutil.copy2(yml_file, qserver_dir)
|
|
47
|
+
|
|
48
|
+
# Update startup module in qs-config.yml
|
|
49
|
+
qs_config_path = qserver_dir / "qs-config.yml"
|
|
50
|
+
|
|
51
|
+
with open(qs_config_path, "r") as f:
|
|
52
|
+
config_contents = f.read()
|
|
53
|
+
# Replace demo_instrument with new name in startup module path
|
|
54
|
+
updated_contents = config_contents.replace(
|
|
55
|
+
"startup_module: demo_instrument.startup", f"startup_module: {name}.startup"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
with open(qs_config_path, "w") as f:
|
|
59
|
+
f.write(updated_contents)
|
|
60
|
+
|
|
61
|
+
new_script_path = qserver_dir / "qs_host.sh"
|
|
62
|
+
|
|
63
|
+
# Read script contents
|
|
64
|
+
with open(new_script_path, "r") as src:
|
|
65
|
+
script_contents = src.read()
|
|
66
|
+
|
|
67
|
+
# Replace demo package name with new instrument name
|
|
68
|
+
updated_contents = script_contents.replace("demo_instrument", name)
|
|
69
|
+
|
|
70
|
+
# Write updated script
|
|
71
|
+
with open(new_script_path, "w") as dest:
|
|
72
|
+
dest.write(updated_contents)
|
|
73
|
+
|
|
74
|
+
# Make script executable
|
|
75
|
+
os.chmod(new_script_path, new_script_path.stat().st_mode | 0o755)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main() -> None:
|
|
79
|
+
"""
|
|
80
|
+
Parse arguments and create the instrument.
|
|
81
|
+
|
|
82
|
+
:return: None
|
|
83
|
+
"""
|
|
84
|
+
parser = argparse.ArgumentParser(
|
|
85
|
+
description="Create an instrument from a fixed template."
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"name", type=str, help="New instrument name; must be a valid package name."
|
|
89
|
+
)
|
|
90
|
+
args = parser.parse_args()
|
|
91
|
+
|
|
92
|
+
if re.fullmatch(r"[a-z][_a-z0-9]*", args.name) is None:
|
|
93
|
+
print(f"Error: Invalid instrument name '{args.name}'.", file=sys.stderr)
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
|
|
96
|
+
main_path: Path = Path(os.getcwd()).resolve()
|
|
97
|
+
|
|
98
|
+
new_instrument_dir: Path = main_path / "src" / args.name
|
|
99
|
+
|
|
100
|
+
new_qserver_dir: Path = main_path / "src" / f"{args.name}_qserver"
|
|
101
|
+
|
|
102
|
+
print(
|
|
103
|
+
f"Creating instrument '{args.name}' from demo_instrument into \
|
|
104
|
+
'{new_instrument_dir}'."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if new_instrument_dir.exists():
|
|
108
|
+
print(f"Error: Destination '{new_instrument_dir}' exists.", file=sys.stderr)
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
copy_instrument(new_instrument_dir)
|
|
113
|
+
print(f"Template copied to '{new_instrument_dir}'.")
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
print(f"Error copying instrument: {exc}", file=sys.stderr)
|
|
116
|
+
sys.exit(1)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
create_qserver(new_qserver_dir, args.name)
|
|
120
|
+
print(f"Qserver config created in '{new_qserver_dir}'.")
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
print(f"Error creating qserver config: {exc}", file=sys.stderr)
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
|
|
125
|
+
print(f"Instrument '{args.name}' created.")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
main()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generic utility helper functions
|
|
3
|
+
================================
|
|
4
|
+
|
|
5
|
+
.. autosummary::
|
|
6
|
+
~register_bluesky_magics
|
|
7
|
+
~running_in_queueserver
|
|
8
|
+
~debug_python
|
|
9
|
+
~mpl_setup
|
|
10
|
+
~is_notebook
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
import matplotlib as mpl
|
|
17
|
+
import matplotlib.pyplot as plt
|
|
18
|
+
from bluesky.magics import BlueskyMagics
|
|
19
|
+
from IPython import get_ipython
|
|
20
|
+
|
|
21
|
+
from apsbits.utils.config_loaders import get_config
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
logger.bsdev(__file__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def register_bluesky_magics() -> None:
|
|
28
|
+
"""Register Bluesky IPython magics."""
|
|
29
|
+
try:
|
|
30
|
+
ip = get_ipython()
|
|
31
|
+
if ip is not None:
|
|
32
|
+
ip.register_magics(BlueskyMagics)
|
|
33
|
+
logger.info("Registered Bluesky IPython magics")
|
|
34
|
+
except Exception as e:
|
|
35
|
+
logger.warning("Could not register Bluesky IPython magics: %s", e)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def running_in_queueserver() -> bool:
|
|
39
|
+
"""
|
|
40
|
+
Check if we are running in a Bluesky queueserver.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
True if running in a queueserver, False otherwise.
|
|
44
|
+
"""
|
|
45
|
+
return os.environ.get("QSERVER_URI") is not None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_xmode_level() -> str:
|
|
49
|
+
"""
|
|
50
|
+
Get the current XMode debug level.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The current XMode debug level.
|
|
54
|
+
"""
|
|
55
|
+
iconfig = get_config()
|
|
56
|
+
xmode_level: str = iconfig.get("XMODE_DEBUG_LEVEL", "Plain")
|
|
57
|
+
return xmode_level
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def debug_python(xmode_level: str = "Plain") -> None:
|
|
61
|
+
"""
|
|
62
|
+
Enable detailed debugging for Python exceptions in the IPython environment.
|
|
63
|
+
|
|
64
|
+
This function adjusts the xmode settings for exception tracebacks based on
|
|
65
|
+
the provided xmode_level argument.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
xmode_level (str): The level of detail for exception tracebacks.
|
|
69
|
+
Defaults to "Minimal".
|
|
70
|
+
"""
|
|
71
|
+
ipython = get_ipython()
|
|
72
|
+
if ipython is not None:
|
|
73
|
+
xmode_level: str = get_xmode_level()
|
|
74
|
+
ipython.run_line_magic("xmode", xmode_level)
|
|
75
|
+
print("\nEnd of IPython settings\n")
|
|
76
|
+
logger.bsdev("xmode exception level: '%s'", xmode_level)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_notebook() -> bool:
|
|
80
|
+
"""
|
|
81
|
+
Detect if the current environment is a Jupyter Notebook.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
bool: True if running in a notebook (Jupyter notebook or qtconsole),
|
|
85
|
+
False otherwise.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
shell: str = get_ipython().__class__.__name__
|
|
89
|
+
if shell == "ZMQInteractiveShell":
|
|
90
|
+
return True # Jupyter notebook or qtconsole
|
|
91
|
+
elif shell == "TerminalInteractiveShell":
|
|
92
|
+
return False # Terminal running IPython
|
|
93
|
+
else:
|
|
94
|
+
return False # Other type
|
|
95
|
+
except NameError:
|
|
96
|
+
return False # Standard Python interpreter
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def mpl_setup() -> None:
|
|
100
|
+
"""
|
|
101
|
+
Configure the Matplotlib backend based on the current environment.
|
|
102
|
+
|
|
103
|
+
For non-queueserver and non-notebook environments, attempts to use the 'qtAgg'
|
|
104
|
+
backend.
|
|
105
|
+
If 'qtAgg' is not available due to missing dependencies, falls back to the 'Agg'
|
|
106
|
+
backend.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
None
|
|
110
|
+
"""
|
|
111
|
+
if not running_in_queueserver():
|
|
112
|
+
if not is_notebook():
|
|
113
|
+
try:
|
|
114
|
+
mpl.use("qtAgg")
|
|
115
|
+
plt.ion()
|
|
116
|
+
logger.bsdev("Using qtAgg backend for matplotlib.")
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
logger.error(
|
|
119
|
+
"qtAgg backend is not available, falling back to Agg backend. \
|
|
120
|
+
Error: %s",
|
|
121
|
+
exc,
|
|
122
|
+
)
|
|
123
|
+
mpl.use("Agg")
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configure logging for this session.
|
|
3
|
+
|
|
4
|
+
.. rubric:: Public
|
|
5
|
+
.. autosummary::
|
|
6
|
+
~configure_logging
|
|
7
|
+
|
|
8
|
+
.. rubric:: Internal
|
|
9
|
+
.. autosummary::
|
|
10
|
+
~_setup_console_logger
|
|
11
|
+
~_setup_file_logger
|
|
12
|
+
~_setup_ipython_logger
|
|
13
|
+
~_setup_module_logging
|
|
14
|
+
|
|
15
|
+
.. seealso:: https://blueskyproject.io/bluesky/main/debugging.html
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import logging.handlers
|
|
20
|
+
import os
|
|
21
|
+
import pathlib
|
|
22
|
+
|
|
23
|
+
BYTE = 1
|
|
24
|
+
kB = 1024 * BYTE
|
|
25
|
+
MB = 1024 * kB
|
|
26
|
+
|
|
27
|
+
BRIEF_DATE = "%a-%H:%M:%S"
|
|
28
|
+
BRIEF_FORMAT = "%(levelname)-.1s %(asctime)s.%(msecs)03d: %(message)s"
|
|
29
|
+
DEFAULT_CONFIG_FILE = (
|
|
30
|
+
pathlib.Path(__file__).parent.parent / "demo_instrument" / "configs" / "logging.yml"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Add your custom logging level at the top-level, before configure_logging()
|
|
35
|
+
def addLoggingLevel(levelName, levelNum, methodName=None):
|
|
36
|
+
"""
|
|
37
|
+
Comprehensively adds a new logging level to the `logging` module and the
|
|
38
|
+
currently configured logging class.
|
|
39
|
+
|
|
40
|
+
`levelName` becomes an attribute of the `logging` module with the value
|
|
41
|
+
`levelNum`. `methodName` becomes a convenience method for both `logging`
|
|
42
|
+
itself and the class returned by `logging.getLoggerClass()` (usually just
|
|
43
|
+
`logging.Logger`). If `methodName` is not specified, `levelName.lower()` is
|
|
44
|
+
used.
|
|
45
|
+
|
|
46
|
+
To avoid accidental clobberings of existing attributes, this method will
|
|
47
|
+
raise an `AttributeError` if the level name is already an attribute of the
|
|
48
|
+
`logging` module or if the method name is already present
|
|
49
|
+
|
|
50
|
+
Example
|
|
51
|
+
-------
|
|
52
|
+
>>> addLoggingLevel('TRACE', logging.INFO - 5)
|
|
53
|
+
>>> logging.getLogger(__name__).setLevel("TEST")
|
|
54
|
+
>>> logging.getLogger(__name__).test('that worked')
|
|
55
|
+
>>> logging.test('so did this')
|
|
56
|
+
>>> logging.TEST
|
|
57
|
+
5
|
|
58
|
+
|
|
59
|
+
"""
|
|
60
|
+
if not methodName:
|
|
61
|
+
methodName = levelName.lower()
|
|
62
|
+
|
|
63
|
+
if hasattr(logging, levelName):
|
|
64
|
+
raise AttributeError("{} already defined in logging module".format(levelName))
|
|
65
|
+
if hasattr(logging, methodName):
|
|
66
|
+
raise AttributeError("{} already defined in logging module".format(methodName))
|
|
67
|
+
if hasattr(logging.getLoggerClass(), methodName):
|
|
68
|
+
raise AttributeError("{} already defined in logger class".format(methodName))
|
|
69
|
+
|
|
70
|
+
# This method was inspired by the answers to Stack Overflow post
|
|
71
|
+
# http://stackoverflow.com/q/2183233/2988730, especially
|
|
72
|
+
# http://stackoverflow.com/a/13638084/2988730
|
|
73
|
+
def logForLevel(self, message, *args, **kwargs):
|
|
74
|
+
if self.isEnabledFor(levelNum):
|
|
75
|
+
self._log(levelNum, message, args, **kwargs)
|
|
76
|
+
|
|
77
|
+
def logToRoot(message, *args, **kwargs):
|
|
78
|
+
logging.log(levelNum, message, *args, **kwargs)
|
|
79
|
+
|
|
80
|
+
logging.addLevelName(levelNum, levelName)
|
|
81
|
+
setattr(logging, levelName, levelNum)
|
|
82
|
+
setattr(logging.getLoggerClass(), methodName, logForLevel)
|
|
83
|
+
setattr(logging, methodName, logToRoot)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
addLoggingLevel("BSDEV", logging.INFO - 5)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def configure_logging():
|
|
90
|
+
"""Configure logging as described in file."""
|
|
91
|
+
from apsbits.utils.config_loaders import load_config_yaml
|
|
92
|
+
|
|
93
|
+
# (Re)configure the root logger.
|
|
94
|
+
logger = logging.getLogger(__name__).root
|
|
95
|
+
logger.debug("logger=%r", logger)
|
|
96
|
+
|
|
97
|
+
config_file = os.environ.get("BLUESKY_INSTRUMENT_CONFIG_FILE")
|
|
98
|
+
if config_file is None:
|
|
99
|
+
config_file = DEFAULT_CONFIG_FILE
|
|
100
|
+
else:
|
|
101
|
+
config_file = pathlib.Path(config_file)
|
|
102
|
+
|
|
103
|
+
logging_configuration = load_config_yaml(config_file)
|
|
104
|
+
for part, cfg in logging_configuration.items():
|
|
105
|
+
logging.debug("%r - %s", part, cfg)
|
|
106
|
+
|
|
107
|
+
if part == "console_logs":
|
|
108
|
+
_setup_console_logger(logger, cfg)
|
|
109
|
+
|
|
110
|
+
elif part == "file_logs":
|
|
111
|
+
_setup_file_logger(logger, cfg)
|
|
112
|
+
|
|
113
|
+
elif part == "ipython_logs":
|
|
114
|
+
_setup_ipython_logger(logger, cfg)
|
|
115
|
+
|
|
116
|
+
elif part == "modules":
|
|
117
|
+
_setup_module_logging(cfg)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _setup_console_logger(logger, cfg):
|
|
121
|
+
"""
|
|
122
|
+
Reconfigure the root logger as configured by the user.
|
|
123
|
+
|
|
124
|
+
We can't apply user configurations in ``configure_logging()`` above
|
|
125
|
+
because the code to read the config file triggers initialization of
|
|
126
|
+
the logging system.
|
|
127
|
+
|
|
128
|
+
.. seealso:: https://docs.python.org/3/library/logging.html#logging.basicConfig
|
|
129
|
+
"""
|
|
130
|
+
logging.basicConfig(
|
|
131
|
+
encoding="utf-8",
|
|
132
|
+
level=cfg["root_level"].upper(),
|
|
133
|
+
format=cfg["log_format"],
|
|
134
|
+
datefmt=cfg["date_format"],
|
|
135
|
+
force=True, # replace any previous setup
|
|
136
|
+
)
|
|
137
|
+
h = logger.handlers[0]
|
|
138
|
+
h.setLevel(cfg["level"].upper())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _setup_file_logger(logger, cfg):
|
|
142
|
+
"""Record log messages in file(s)."""
|
|
143
|
+
formatter = logging.Formatter(
|
|
144
|
+
fmt=cfg["log_format"],
|
|
145
|
+
datefmt=cfg["date_format"],
|
|
146
|
+
style="%",
|
|
147
|
+
validate=True,
|
|
148
|
+
)
|
|
149
|
+
formatter.default_msec_format = "%s.%03d"
|
|
150
|
+
|
|
151
|
+
backupCount = cfg.get("backupCount", 9)
|
|
152
|
+
maxBytes = cfg.get("maxBytes", 1 * MB)
|
|
153
|
+
log_path = pathlib.Path(cfg.get("log_directory", ".logs")).resolve()
|
|
154
|
+
if not log_path.exists():
|
|
155
|
+
os.makedirs(str(log_path))
|
|
156
|
+
|
|
157
|
+
file_name = log_path / cfg.get("log_filename_base", "logging.log")
|
|
158
|
+
if maxBytes > 0 or backupCount > 0:
|
|
159
|
+
backupCount = max(backupCount, 1) # impose minimum standards
|
|
160
|
+
maxBytes = max(maxBytes, 100 * kB)
|
|
161
|
+
handler = logging.handlers.RotatingFileHandler(
|
|
162
|
+
file_name,
|
|
163
|
+
maxBytes=maxBytes,
|
|
164
|
+
backupCount=backupCount,
|
|
165
|
+
)
|
|
166
|
+
else:
|
|
167
|
+
handler = logging.FileHandler(file_name)
|
|
168
|
+
handler.setFormatter(formatter)
|
|
169
|
+
if cfg.get("rotate_on_startup", False):
|
|
170
|
+
handler.doRollover()
|
|
171
|
+
logger.addHandler(handler)
|
|
172
|
+
logger.info("%s Bluesky Startup Initialized", "*" * 40)
|
|
173
|
+
logger.bsdev(__file__)
|
|
174
|
+
logger.bsdev("Log file: %s", file_name)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _setup_ipython_logger(logger, cfg):
|
|
178
|
+
"""
|
|
179
|
+
Internal: Log IPython console session In and Out to a file.
|
|
180
|
+
|
|
181
|
+
See ``logrotate?`` int he IPython console for more information.
|
|
182
|
+
"""
|
|
183
|
+
log_path = pathlib.Path(cfg.get("log_directory", ".logs")).resolve()
|
|
184
|
+
try:
|
|
185
|
+
from IPython import get_ipython
|
|
186
|
+
|
|
187
|
+
# start logging console to file
|
|
188
|
+
# https://ipython.org/ipython-doc/3/interactive/magics.html#magic-logstart
|
|
189
|
+
_ipython = get_ipython()
|
|
190
|
+
log_file = log_path / cfg.get("log_filename_base", "ipython_log.py")
|
|
191
|
+
log_mode = cfg.get("log_mode", "rotate")
|
|
192
|
+
options = cfg.get("options", "-o -t")
|
|
193
|
+
if _ipython is not None:
|
|
194
|
+
print(
|
|
195
|
+
"\nBelow are the IPython logging settings for your session."
|
|
196
|
+
"\nThese settings have no impact on your experiment.\n"
|
|
197
|
+
)
|
|
198
|
+
_ipython.run_line_magic("logstart", f"{options} {log_file} {log_mode}")
|
|
199
|
+
if logger is not None:
|
|
200
|
+
logger.bsdev("Console logging: %s", log_file)
|
|
201
|
+
except Exception as exc:
|
|
202
|
+
if logger is None:
|
|
203
|
+
print(f"Could not setup console logging: {exc}")
|
|
204
|
+
else:
|
|
205
|
+
logger.exception("Could not setup console logging.")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _setup_module_logging(cfg):
|
|
209
|
+
"""Internal: Set logging level for each named module."""
|
|
210
|
+
for module, level in cfg.items():
|
|
211
|
+
logging.getLogger(module).setLevel(level.upper())
|