pymmcore-plus 0.15.3__py3-none-any.whl → 0.16.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.
- pymmcore_plus/__init__.py +20 -1
- pymmcore_plus/_accumulator.py +23 -5
- pymmcore_plus/_cli.py +44 -26
- pymmcore_plus/_discovery.py +344 -0
- pymmcore_plus/_logger.py +1 -1
- pymmcore_plus/_util.py +9 -245
- pymmcore_plus/core/_device.py +20 -7
- pymmcore_plus/core/_mmcore_plus.py +16 -8
- pymmcore_plus/core/_property.py +34 -28
- pymmcore_plus/core/events/_device_signal_view.py +8 -1
- pymmcore_plus/core/events/_protocol.py +3 -0
- pymmcore_plus/core/events/_psygnal.py +1 -0
- pymmcore_plus/core/events/_qsignals.py +1 -0
- pymmcore_plus/experimental/unicore/_device_manager.py +20 -13
- pymmcore_plus/experimental/unicore/core/_unicore.py +4 -1
- pymmcore_plus/install.py +150 -18
- pymmcore_plus/mda/_engine.py +268 -73
- pymmcore_plus/mda/events/__init__.py +1 -1
- pymmcore_plus/metadata/_ome.py +499 -0
- {pymmcore_plus-0.15.3.dist-info → pymmcore_plus-0.16.0.dist-info}/METADATA +3 -2
- {pymmcore_plus-0.15.3.dist-info → pymmcore_plus-0.16.0.dist-info}/RECORD +24 -22
- {pymmcore_plus-0.15.3.dist-info → pymmcore_plus-0.16.0.dist-info}/WHEEL +0 -0
- {pymmcore_plus-0.15.3.dist-info → pymmcore_plus-0.16.0.dist-info}/entry_points.txt +0 -0
- {pymmcore_plus-0.15.3.dist-info → pymmcore_plus-0.16.0.dist-info}/licenses/LICENSE +0 -0
pymmcore_plus/_util.py
CHANGED
|
@@ -2,9 +2,9 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import datetime
|
|
4
4
|
import importlib
|
|
5
|
+
import importlib.metadata
|
|
5
6
|
import os
|
|
6
7
|
import platform
|
|
7
|
-
import re
|
|
8
8
|
import sys
|
|
9
9
|
import warnings
|
|
10
10
|
from collections import defaultdict
|
|
@@ -17,9 +17,10 @@ from typing import TYPE_CHECKING, cast, overload
|
|
|
17
17
|
|
|
18
18
|
from platformdirs import user_data_dir
|
|
19
19
|
|
|
20
|
+
from . import _discovery
|
|
21
|
+
|
|
20
22
|
if TYPE_CHECKING:
|
|
21
23
|
from collections.abc import Iterator
|
|
22
|
-
from re import Pattern
|
|
23
24
|
from typing import Any, Callable, Literal, TypeVar
|
|
24
25
|
|
|
25
26
|
QtConnectionType = Literal["AutoConnection", "DirectConnection", "QueuedConnection"]
|
|
@@ -39,213 +40,14 @@ except ImportError:
|
|
|
39
40
|
from contextlib import nullcontext as no_stdout
|
|
40
41
|
|
|
41
42
|
|
|
42
|
-
__all__ = ["
|
|
43
|
+
__all__ = ["no_stdout", "retry", "signals_backend"]
|
|
43
44
|
|
|
44
45
|
APP_NAME = "pymmcore-plus"
|
|
45
46
|
USER_DATA_DIR = Path(user_data_dir(appname=APP_NAME))
|
|
46
47
|
USER_DATA_MM_PATH = USER_DATA_DIR / "mm"
|
|
47
48
|
CURRENT_MM_PATH = USER_DATA_MM_PATH / ".current_mm"
|
|
48
49
|
PYMMCORE_PLUS_PATH = Path(__file__).parent.parent
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
@overload
|
|
52
|
-
def find_micromanager(return_first: Literal[True] = True) -> str | None: ...
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
@overload
|
|
56
|
-
def find_micromanager(return_first: Literal[False]) -> list[str]: ...
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def find_micromanager(return_first: bool = True) -> str | None | list[str]:
|
|
60
|
-
r"""Locate a Micro-Manager folder (for device adapters).
|
|
61
|
-
|
|
62
|
-
In order, this will look for:
|
|
63
|
-
|
|
64
|
-
1. An environment variable named `MICROMANAGER_PATH`
|
|
65
|
-
2. A path stored in the `CURRENT_MM_PATH` file (set by `use_micromanager`).
|
|
66
|
-
3. A `Micro-Manager*` folder in the `pymmcore-plus` user data directory
|
|
67
|
-
(this is the default install location when running `mmcore install`)
|
|
68
|
-
|
|
69
|
-
- **Windows**: C:\Users\\[user]\AppData\Local\pymmcore-plus\pymmcore-plus
|
|
70
|
-
- **macOS**: ~/Library/Application Support/pymmcore-plus
|
|
71
|
-
- **Linux**: ~/.local/share/pymmcore-plus
|
|
72
|
-
|
|
73
|
-
4. A `Micro-Manager*` folder in the `pymmcore_plus` package directory (this is the
|
|
74
|
-
default install location when running `python -m pymmcore_plus.install`)
|
|
75
|
-
5. The default micro-manager install location:
|
|
76
|
-
|
|
77
|
-
- **Windows**: `C:/Program Files/`
|
|
78
|
-
- **macOS**: `/Applications`
|
|
79
|
-
- **Linux**: `/usr/local/lib`
|
|
80
|
-
|
|
81
|
-
!!! note
|
|
82
|
-
|
|
83
|
-
This function is used by [`pymmcore_plus.CMMCorePlus`][] to locate the
|
|
84
|
-
micro-manager device adapters. By default, the output of this function
|
|
85
|
-
is passed to
|
|
86
|
-
[`setDeviceAdapterSearchPaths`][pymmcore_plus.CMMCorePlus.setDeviceAdapterSearchPaths]
|
|
87
|
-
when creating a new `CMMCorePlus` instance.
|
|
88
|
-
|
|
89
|
-
Parameters
|
|
90
|
-
----------
|
|
91
|
-
return_first : bool, optional
|
|
92
|
-
If True (default), return the first found path. If False, return a list of
|
|
93
|
-
all found paths.
|
|
94
|
-
"""
|
|
95
|
-
from ._logger import logger
|
|
96
|
-
|
|
97
|
-
# we use a dict here to avoid duplicates, while retaining order
|
|
98
|
-
full_list: dict[str, None] = {}
|
|
99
|
-
|
|
100
|
-
# environment variable takes precedence
|
|
101
|
-
env_path = os.getenv("MICROMANAGER_PATH")
|
|
102
|
-
if env_path and os.path.isdir(env_path):
|
|
103
|
-
if return_first:
|
|
104
|
-
logger.debug("using MM path from env var: %s", env_path)
|
|
105
|
-
return env_path
|
|
106
|
-
full_list[env_path] = None
|
|
107
|
-
|
|
108
|
-
# then check for a path in CURRENT_MM_PATH
|
|
109
|
-
if CURRENT_MM_PATH.exists():
|
|
110
|
-
path = CURRENT_MM_PATH.read_text().strip()
|
|
111
|
-
if os.path.isdir(path):
|
|
112
|
-
if return_first:
|
|
113
|
-
logger.debug("using MM path from current_mm: %s", path)
|
|
114
|
-
return path
|
|
115
|
-
full_list[path] = None
|
|
116
|
-
|
|
117
|
-
# then look for mm-device-adapters
|
|
118
|
-
with suppress(ImportError):
|
|
119
|
-
import mm_device_adapters
|
|
120
|
-
|
|
121
|
-
from . import _pymmcore
|
|
122
|
-
|
|
123
|
-
mm_dev_div = mm_device_adapters.__version__.split(".")[0]
|
|
124
|
-
pymm_div = str(_pymmcore.version_info.device_interface)
|
|
125
|
-
|
|
126
|
-
if pymm_div != mm_dev_div: # pragma: no cover
|
|
127
|
-
warnings.warn(
|
|
128
|
-
"mm-device-adapters installed, but its device interface "
|
|
129
|
-
f"version ({mm_dev_div}) "
|
|
130
|
-
f"does not match the device interface version of {_pymmcore.BACKEND}"
|
|
131
|
-
f"({pymm_div}). You may wish to run"
|
|
132
|
-
f" `pip install --force-reinstall mm-device-adapters=={pymm_div}`. "
|
|
133
|
-
"mm-device-adapters will be ignored.",
|
|
134
|
-
stacklevel=2,
|
|
135
|
-
)
|
|
136
|
-
else:
|
|
137
|
-
path = mm_device_adapters.device_adapter_path()
|
|
138
|
-
if return_first:
|
|
139
|
-
logger.debug("using MM path from mm-device-adapters: %s", path)
|
|
140
|
-
return str(path)
|
|
141
|
-
full_list[path] = None
|
|
142
|
-
|
|
143
|
-
# then look in user_data_dir
|
|
144
|
-
_folders = (p for p in USER_DATA_MM_PATH.glob("Micro-Manager*") if p.is_dir())
|
|
145
|
-
if user_install := sorted(_folders, reverse=True):
|
|
146
|
-
if return_first and (
|
|
147
|
-
first := next(
|
|
148
|
-
(x for x in user_install if _mm_path_has_compatible_div(x)), None
|
|
149
|
-
)
|
|
150
|
-
):
|
|
151
|
-
logger.debug("using MM path from user install: %s", first)
|
|
152
|
-
return str(first)
|
|
153
|
-
for x in user_install:
|
|
154
|
-
full_list[str(x)] = None
|
|
155
|
-
|
|
156
|
-
# then look for an installation in this folder (from `pymmcore_plus.install`)
|
|
157
|
-
sfx = "_win" if os.name == "nt" else "_mac"
|
|
158
|
-
local_install = [
|
|
159
|
-
p for p in PYMMCORE_PLUS_PATH.glob(f"**/Micro-Manager*{sfx}") if p.is_dir()
|
|
160
|
-
]
|
|
161
|
-
if local_install:
|
|
162
|
-
if return_first and (
|
|
163
|
-
first := next(
|
|
164
|
-
(x for x in local_install if _mm_path_has_compatible_div(x)), None
|
|
165
|
-
)
|
|
166
|
-
): # pragma: no cover
|
|
167
|
-
logger.debug("using MM path from local install: %s", first)
|
|
168
|
-
return str(first)
|
|
169
|
-
for x in local_install:
|
|
170
|
-
full_list[str(x)] = None
|
|
171
|
-
|
|
172
|
-
applications = {
|
|
173
|
-
"darwin": Path("/Applications/"),
|
|
174
|
-
"win32": Path("C:/Program Files/"),
|
|
175
|
-
"linux": Path("/usr/local/lib"),
|
|
176
|
-
}
|
|
177
|
-
if sys.platform not in applications:
|
|
178
|
-
raise NotImplementedError(
|
|
179
|
-
f"MM autodiscovery not implemented for platform: {sys.platform}"
|
|
180
|
-
)
|
|
181
|
-
app_path = applications[sys.platform]
|
|
182
|
-
pth = next(app_path.glob("[m,M]icro-[m,M]anager*"), None)
|
|
183
|
-
if return_first:
|
|
184
|
-
if pth and _mm_path_has_compatible_div(pth): # pragma: no cover
|
|
185
|
-
logger.debug("using MM path found in applications: %s", pth)
|
|
186
|
-
return str(pth)
|
|
187
|
-
from . import _pymmcore
|
|
188
|
-
|
|
189
|
-
div = _pymmcore.version_info.device_interface
|
|
190
|
-
logger.error(
|
|
191
|
-
f"could not find micromanager directory for device interface {div}. "
|
|
192
|
-
"Please run 'mmcore install'"
|
|
193
|
-
)
|
|
194
|
-
return None
|
|
195
|
-
if pth is not None:
|
|
196
|
-
full_list[str(pth)] = None
|
|
197
|
-
return list(full_list)
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def _match_mm_pattern(pattern: str | Pattern[str]) -> Path | None:
|
|
201
|
-
"""Locate an existing Micro-Manager folder using a regex pattern."""
|
|
202
|
-
for _path in find_micromanager(return_first=False):
|
|
203
|
-
if not isinstance(pattern, re.Pattern):
|
|
204
|
-
pattern = str(pattern)
|
|
205
|
-
if re.search(pattern, _path) is not None:
|
|
206
|
-
return Path(_path)
|
|
207
|
-
return None
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def use_micromanager(
|
|
211
|
-
path: str | Path | None = None, pattern: str | Pattern[str] | None = None
|
|
212
|
-
) -> Path | None:
|
|
213
|
-
"""Set the preferred Micro-Manager path.
|
|
214
|
-
|
|
215
|
-
This sets the preferred micromanager path, and persists across sessions.
|
|
216
|
-
This path takes precedence over everything *except* the `MICROMANAGER_PATH`
|
|
217
|
-
environment variable.
|
|
218
|
-
|
|
219
|
-
Parameters
|
|
220
|
-
----------
|
|
221
|
-
path : str | Path | None
|
|
222
|
-
Path to an existing directory. This directory should contain micro-manager
|
|
223
|
-
device adapters. If `None`, the path will be determined using `pattern`.
|
|
224
|
-
pattern : str Pattern | | None
|
|
225
|
-
A regex pattern to match against the micromanager paths found by
|
|
226
|
-
`find_micromanager`. If no match is found, a `FileNotFoundError` will be raised.
|
|
227
|
-
"""
|
|
228
|
-
if path is None:
|
|
229
|
-
if pattern is None: # pragma: no cover
|
|
230
|
-
raise ValueError("One of 'path' or 'pattern' must be provided")
|
|
231
|
-
if (path := _match_mm_pattern(pattern)) is None:
|
|
232
|
-
options = "\n".join(find_micromanager(return_first=False))
|
|
233
|
-
raise FileNotFoundError(
|
|
234
|
-
f"No micromanager path found matching: {pattern!r}. Options:\n{options}"
|
|
235
|
-
)
|
|
236
|
-
|
|
237
|
-
if not isinstance(path, Path): # pragma: no cover
|
|
238
|
-
path = Path(path)
|
|
239
|
-
|
|
240
|
-
path = path.expanduser().resolve()
|
|
241
|
-
if not path.is_dir(): # pragma: no cover
|
|
242
|
-
if not path.exists():
|
|
243
|
-
raise FileNotFoundError(f"Path not found: {path!r}")
|
|
244
|
-
raise NotADirectoryError(f"Not a directory: {path!r}")
|
|
245
|
-
|
|
246
|
-
USER_DATA_MM_PATH.mkdir(parents=True, exist_ok=True)
|
|
247
|
-
CURRENT_MM_PATH.write_text(str(path))
|
|
248
|
-
return path
|
|
50
|
+
PYMM_SIGNALS_BACKEND = "PYMM_SIGNALS_BACKEND"
|
|
249
51
|
|
|
250
52
|
|
|
251
53
|
def _imported_qt_modules() -> Iterator[str]:
|
|
@@ -266,9 +68,6 @@ def _qt_app_is_running() -> bool:
|
|
|
266
68
|
return False # pragma: no cover
|
|
267
69
|
|
|
268
70
|
|
|
269
|
-
PYMM_SIGNALS_BACKEND = "PYMM_SIGNALS_BACKEND"
|
|
270
|
-
|
|
271
|
-
|
|
272
71
|
def signals_backend() -> Literal["qt", "psygnal"]:
|
|
273
72
|
"""Return the name of the event backend to use."""
|
|
274
73
|
env_var = os.environ.get(PYMM_SIGNALS_BACKEND, "auto").lower()
|
|
@@ -302,8 +101,6 @@ def retry(
|
|
|
302
101
|
delay: float | None = ...,
|
|
303
102
|
logger: Callable[[str], Any] | None = ...,
|
|
304
103
|
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
|
305
|
-
|
|
306
|
-
|
|
307
104
|
@overload
|
|
308
105
|
def retry(
|
|
309
106
|
func: Callable[P, R],
|
|
@@ -312,8 +109,6 @@ def retry(
|
|
|
312
109
|
delay: float | None = ...,
|
|
313
110
|
logger: Callable[[str], Any] | None = ...,
|
|
314
111
|
) -> Callable[P, R]: ...
|
|
315
|
-
|
|
316
|
-
|
|
317
112
|
def retry(
|
|
318
113
|
func: Callable[P, R] | None = None,
|
|
319
114
|
tries: int = 3,
|
|
@@ -609,7 +404,7 @@ def system_info() -> dict[str, str]:
|
|
|
609
404
|
info["core-version-info"] = core.getVersionInfo()
|
|
610
405
|
info["api-version-info"] = core.getAPIVersionInfo()
|
|
611
406
|
|
|
612
|
-
if (mm_path := find_micromanager()) is not None:
|
|
407
|
+
if (mm_path := _discovery.find_micromanager()) is not None:
|
|
613
408
|
path = str(Path(mm_path).resolve())
|
|
614
409
|
path = path.replace(os.path.expanduser("~"), "~") # privacy
|
|
615
410
|
info["adapter-path"] = path
|
|
@@ -628,10 +423,10 @@ def system_info() -> dict[str, str]:
|
|
|
628
423
|
info[pkg] = importlib.metadata.version(pkg)
|
|
629
424
|
|
|
630
425
|
if pkg == "pymmcore-widgets":
|
|
631
|
-
with suppress(ImportError):
|
|
632
|
-
|
|
426
|
+
with suppress(ImportError, AttributeError):
|
|
427
|
+
import qtpy
|
|
633
428
|
|
|
634
|
-
info["qt"] = f"{API_NAME} {QT_VERSION}"
|
|
429
|
+
info["qt"] = f"{qtpy.API_NAME} {qtpy.QT_VERSION}"
|
|
635
430
|
|
|
636
431
|
return info
|
|
637
432
|
|
|
@@ -655,34 +450,3 @@ def timestamp() -> str:
|
|
|
655
450
|
with suppress(Exception):
|
|
656
451
|
now = now.astimezone()
|
|
657
452
|
return now.isoformat()
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
def get_device_interface_version(lib_path: str | Path) -> int:
|
|
661
|
-
"""Return the device interface version from the given library path."""
|
|
662
|
-
import ctypes
|
|
663
|
-
|
|
664
|
-
if sys.platform.startswith("win"):
|
|
665
|
-
lib = ctypes.WinDLL(str(lib_path))
|
|
666
|
-
else:
|
|
667
|
-
lib = ctypes.CDLL(str(lib_path))
|
|
668
|
-
|
|
669
|
-
try:
|
|
670
|
-
func = lib.GetDeviceInterfaceVersion
|
|
671
|
-
except AttributeError:
|
|
672
|
-
raise RuntimeError(
|
|
673
|
-
f"Function 'GetDeviceInterfaceVersion' not found in {lib_path}"
|
|
674
|
-
) from None
|
|
675
|
-
|
|
676
|
-
func.restype = ctypes.c_long
|
|
677
|
-
func.argtypes = []
|
|
678
|
-
return func() # type: ignore[no-any-return]
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
def _mm_path_has_compatible_div(folder: Path | str) -> bool:
|
|
682
|
-
from . import _pymmcore
|
|
683
|
-
|
|
684
|
-
div = _pymmcore.version_info.device_interface
|
|
685
|
-
for lib_path in Path(folder).glob("*mmgr_dal*"):
|
|
686
|
-
with suppress(Exception):
|
|
687
|
-
return get_device_interface_version(lib_path) == div
|
|
688
|
-
return False # pragma: no cover
|
pymmcore_plus/core/_device.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import weakref
|
|
3
4
|
from typing import TYPE_CHECKING, Any, Literal, overload
|
|
4
5
|
|
|
5
6
|
from ._constants import DeviceType, FocusDirection, Keyword
|
|
@@ -82,17 +83,18 @@ class Device:
|
|
|
82
83
|
if mmcore is None:
|
|
83
84
|
from ._mmcore_plus import CMMCorePlus
|
|
84
85
|
|
|
85
|
-
|
|
86
|
+
mmcore = CMMCorePlus.instance()
|
|
86
87
|
else:
|
|
87
|
-
|
|
88
|
+
mmcore = mmcore
|
|
88
89
|
|
|
90
|
+
self._mmc_ref = weakref.ref(mmcore)
|
|
89
91
|
self._label = device_label
|
|
90
92
|
self._type = None
|
|
91
93
|
if self.isLoaded():
|
|
92
|
-
adapter_name =
|
|
93
|
-
device_name =
|
|
94
|
-
description =
|
|
95
|
-
type =
|
|
94
|
+
adapter_name = mmcore.getDeviceLibrary(device_label)
|
|
95
|
+
device_name = mmcore.getDeviceName(device_label)
|
|
96
|
+
description = mmcore.getDeviceDescription(device_label)
|
|
97
|
+
type = mmcore.getDeviceType(device_label) # noqa: A001
|
|
96
98
|
if self.type() != type:
|
|
97
99
|
raise TypeError(
|
|
98
100
|
f"Cannot create loaded device with label {device_label!r} and type "
|
|
@@ -103,7 +105,18 @@ class Device:
|
|
|
103
105
|
self._device_name = device_name
|
|
104
106
|
self._type = type
|
|
105
107
|
self._description = description
|
|
106
|
-
self.propertyChanged = _DevicePropValueSignal(device_label, None,
|
|
108
|
+
self.propertyChanged = _DevicePropValueSignal(device_label, None, mmcore)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def _mmc(self) -> CMMCorePlus:
|
|
112
|
+
"""Return the `CMMCorePlus` instance to which this Device is bound."""
|
|
113
|
+
mmc = self._mmc_ref()
|
|
114
|
+
if mmc is None: # pragma: no cover
|
|
115
|
+
raise RuntimeError(
|
|
116
|
+
"The CMMCorePlus instance to which this Device "
|
|
117
|
+
"is bound has been garbage collected."
|
|
118
|
+
)
|
|
119
|
+
return mmc
|
|
107
120
|
|
|
108
121
|
@property
|
|
109
122
|
def label(self) -> str:
|
|
@@ -27,8 +27,9 @@ from psygnal import SignalInstance
|
|
|
27
27
|
from typing_extensions import deprecated
|
|
28
28
|
|
|
29
29
|
import pymmcore_plus._pymmcore as pymmcore
|
|
30
|
+
from pymmcore_plus._discovery import find_micromanager
|
|
30
31
|
from pymmcore_plus._logger import current_logfile, logger
|
|
31
|
-
from pymmcore_plus._util import
|
|
32
|
+
from pymmcore_plus._util import print_tabular_data
|
|
32
33
|
from pymmcore_plus.mda import MDAEngine, MDARunner, PMDAEngine
|
|
33
34
|
from pymmcore_plus.metadata.functions import summary_metadata
|
|
34
35
|
|
|
@@ -334,8 +335,10 @@ class CMMCorePlus(pymmcore.CMMCore):
|
|
|
334
335
|
def __del__(self) -> None:
|
|
335
336
|
if hasattr(self, "_weak_clean"):
|
|
336
337
|
atexit.unregister(self._weak_clean)
|
|
338
|
+
|
|
337
339
|
try:
|
|
338
340
|
super().registerCallback(None)
|
|
341
|
+
|
|
339
342
|
self.reset()
|
|
340
343
|
# clean up logging
|
|
341
344
|
self.setPrimaryLogFile("")
|
|
@@ -517,7 +520,12 @@ class CMMCorePlus(pymmcore.CMMCore):
|
|
|
517
520
|
**Why Override?** The returned [`pymmcore_plus.FocusDirection`][] enum is more
|
|
518
521
|
interpretable than the raw `int` returned by `pymmcore`
|
|
519
522
|
"""
|
|
520
|
-
|
|
523
|
+
try:
|
|
524
|
+
return FocusDirection(super().getFocusDirection(stageLabel))
|
|
525
|
+
except (ValueError, TypeError):
|
|
526
|
+
# On some platforms (notably Windows), device adapters may return
|
|
527
|
+
# invalid values that cannot be converted to FocusDirection enum.
|
|
528
|
+
return FocusDirection.Unknown
|
|
521
529
|
|
|
522
530
|
def getPropertyType(self, label: str, propName: str) -> PropertyType:
|
|
523
531
|
"""Return the intrinsic property type for a given device and property.
|
|
@@ -1622,16 +1630,16 @@ class CMMCorePlus(pymmcore.CMMCore):
|
|
|
1622
1630
|
**Why Override?** to emit the `imageSnapped` event after snapping an image.
|
|
1623
1631
|
and to emit shutter property changes if `getAutoShutter` is `True`.
|
|
1624
1632
|
"""
|
|
1625
|
-
if autoshutter := self.getAutoShutter()
|
|
1626
|
-
self.
|
|
1633
|
+
if (autoshutter := self.getAutoShutter()) and (
|
|
1634
|
+
shutter := self.getShutterDevice()
|
|
1635
|
+
):
|
|
1636
|
+
self.events.propertyChanged.emit(shutter, "State", True)
|
|
1627
1637
|
try:
|
|
1628
1638
|
self._do_snap_image()
|
|
1629
1639
|
self.events.imageSnapped.emit(self.getCameraDevice())
|
|
1630
1640
|
finally:
|
|
1631
|
-
if autoshutter:
|
|
1632
|
-
self.events.propertyChanged.emit(
|
|
1633
|
-
self.getShutterDevice(), "State", False
|
|
1634
|
-
)
|
|
1641
|
+
if autoshutter and shutter:
|
|
1642
|
+
self.events.propertyChanged.emit(shutter, "State", False)
|
|
1635
1643
|
|
|
1636
1644
|
@property
|
|
1637
1645
|
def mda(self) -> MDARunner:
|
pymmcore_plus/core/_property.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import weakref
|
|
3
4
|
from functools import cached_property
|
|
4
5
|
from typing import TYPE_CHECKING, Any, TypedDict
|
|
5
6
|
|
|
@@ -58,29 +59,34 @@ class DeviceProperty:
|
|
|
58
59
|
) -> None:
|
|
59
60
|
self.device = device_label
|
|
60
61
|
self.name = property_name
|
|
61
|
-
self.
|
|
62
|
+
self._mmc_ref = weakref.ref(mmcore)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def core(self) -> CMMCorePlus:
|
|
66
|
+
"""Return the `CMMCorePlus` instance to which this Property is bound."""
|
|
67
|
+
if (mmc := self._mmc_ref()) is None: # pragma: no cover
|
|
68
|
+
raise RuntimeError(
|
|
69
|
+
"The CMMCorePlus instance to which this Property "
|
|
70
|
+
"is bound has been deleted."
|
|
71
|
+
)
|
|
72
|
+
return mmc
|
|
62
73
|
|
|
63
74
|
@cached_property
|
|
64
75
|
def valueChanged(self) -> _DevicePropValueSignal:
|
|
65
|
-
return _DevicePropValueSignal(self.device, self.name, self.
|
|
76
|
+
return _DevicePropValueSignal(self.device, self.name, self.core)
|
|
66
77
|
|
|
67
78
|
def isValid(self) -> bool:
|
|
68
79
|
"""Return `True` if device is loaded and has a property by this name."""
|
|
69
|
-
return self.isLoaded() and self.
|
|
80
|
+
return self.isLoaded() and self.core.hasProperty(self.device, self.name)
|
|
70
81
|
|
|
71
82
|
def isLoaded(self) -> bool:
|
|
72
83
|
"""Return true if the device name is loaded."""
|
|
73
|
-
return self.
|
|
74
|
-
|
|
75
|
-
@property
|
|
76
|
-
def core(self) -> CMMCorePlus:
|
|
77
|
-
"""Return the `CMMCorePlus` instance to which this Property is bound."""
|
|
78
|
-
return self._mmc
|
|
84
|
+
return self.core is not None and self.device in self.core.getLoadedDevices()
|
|
79
85
|
|
|
80
86
|
@property
|
|
81
87
|
def value(self) -> Any:
|
|
82
88
|
"""Return current property value, cast to appropriate type if applicable."""
|
|
83
|
-
v = self.
|
|
89
|
+
v = self.core.getProperty(self.device, self.name)
|
|
84
90
|
if type_ := self.type().to_python():
|
|
85
91
|
v = type_(v)
|
|
86
92
|
return v
|
|
@@ -92,7 +98,7 @@ class DeviceProperty:
|
|
|
92
98
|
|
|
93
99
|
def fromCache(self) -> Any:
|
|
94
100
|
"""Return cached property value."""
|
|
95
|
-
return self.
|
|
101
|
+
return self.core.getPropertyFromCache(self.device, self.name)
|
|
96
102
|
|
|
97
103
|
def setValue(self, val: Any) -> None:
|
|
98
104
|
"""Functional alternate to property setter."""
|
|
@@ -103,7 +109,7 @@ class DeviceProperty:
|
|
|
103
109
|
f"'{self.device}::{self.name}' is a read-only property.", stacklevel=2
|
|
104
110
|
)
|
|
105
111
|
try:
|
|
106
|
-
self.
|
|
112
|
+
self.core.setProperty(self.device, self.name, val)
|
|
107
113
|
except RuntimeError as e:
|
|
108
114
|
msg = str(e)
|
|
109
115
|
if allowed := self.allowedValues():
|
|
@@ -112,23 +118,23 @@ class DeviceProperty:
|
|
|
112
118
|
|
|
113
119
|
def isReadOnly(self) -> bool:
|
|
114
120
|
"""Return `True` if property is read only."""
|
|
115
|
-
return self.
|
|
121
|
+
return self.core.isPropertyReadOnly(self.device, self.name)
|
|
116
122
|
|
|
117
123
|
def isPreInit(self) -> bool:
|
|
118
124
|
"""Return `True` if property must be defined prior to initialization."""
|
|
119
|
-
return self.
|
|
125
|
+
return self.core.isPropertyPreInit(self.device, self.name)
|
|
120
126
|
|
|
121
127
|
def hasLimits(self) -> bool:
|
|
122
128
|
"""Return `True` if property has limits."""
|
|
123
|
-
return self.
|
|
129
|
+
return self.core.hasPropertyLimits(self.device, self.name)
|
|
124
130
|
|
|
125
131
|
def lowerLimit(self) -> float:
|
|
126
132
|
"""Return lower limit if property has limits, or 0 otherwise."""
|
|
127
|
-
return self.
|
|
133
|
+
return self.core.getPropertyLowerLimit(self.device, self.name)
|
|
128
134
|
|
|
129
135
|
def upperLimit(self) -> float:
|
|
130
136
|
"""Return upper limit if property has limits, or 0 otherwise."""
|
|
131
|
-
return self.
|
|
137
|
+
return self.core.getPropertyUpperLimit(self.device, self.name)
|
|
132
138
|
|
|
133
139
|
def range(self) -> tuple[float, float]:
|
|
134
140
|
"""Return (lowerLimit, upperLimit) range tuple."""
|
|
@@ -136,31 +142,31 @@ class DeviceProperty:
|
|
|
136
142
|
|
|
137
143
|
def type(self) -> PropertyType:
|
|
138
144
|
"""Return `PropertyType` of this property."""
|
|
139
|
-
return self.
|
|
145
|
+
return self.core.getPropertyType(self.device, self.name)
|
|
140
146
|
|
|
141
147
|
def deviceType(self) -> DeviceType:
|
|
142
148
|
"""Return `DeviceType` of the device owning this property."""
|
|
143
|
-
return self.
|
|
149
|
+
return self.core.getDeviceType(self.device)
|
|
144
150
|
|
|
145
151
|
def allowedValues(self) -> tuple[str, ...]:
|
|
146
152
|
"""Return allowed values for this property, if contstrained."""
|
|
147
153
|
# https://github.com/micro-manager/mmCoreAndDevices/issues/172
|
|
148
|
-
allowed = self.
|
|
154
|
+
allowed = self.core.getAllowedPropertyValues(self.device, self.name)
|
|
149
155
|
if not allowed and self.deviceType() is DeviceType.StateDevice:
|
|
150
156
|
if self.name == Keyword.State:
|
|
151
|
-
n_states = self.
|
|
157
|
+
n_states = self.core.getNumberOfStates(self.device)
|
|
152
158
|
allowed = tuple(str(i) for i in range(n_states))
|
|
153
159
|
elif self.name == Keyword.Label:
|
|
154
|
-
allowed = self.
|
|
160
|
+
allowed = self.core.getStateLabels(self.device)
|
|
155
161
|
return allowed
|
|
156
162
|
|
|
157
163
|
def isSequenceable(self) -> bool:
|
|
158
164
|
"""Return `True` if property can be used in a sequence."""
|
|
159
|
-
return self.
|
|
165
|
+
return self.core.isPropertySequenceable(self.device, self.name)
|
|
160
166
|
|
|
161
167
|
def sequenceMaxLength(self) -> int:
|
|
162
168
|
"""Return maximum number of property events that can be put in a sequence."""
|
|
163
|
-
return self.
|
|
169
|
+
return self.core.getPropertySequenceMaxLength(self.device, self.name)
|
|
164
170
|
|
|
165
171
|
def loadSequence(self, eventSequence: Sequence[str]) -> None:
|
|
166
172
|
"""Transfer a sequence of events/states/whatever to the device.
|
|
@@ -171,15 +177,15 @@ class DeviceProperty:
|
|
|
171
177
|
The sequence of events/states that the device will execute in response
|
|
172
178
|
to external triggers
|
|
173
179
|
"""
|
|
174
|
-
self.
|
|
180
|
+
self.core.loadPropertySequence(self.device, self.name, eventSequence)
|
|
175
181
|
|
|
176
182
|
def startSequence(self) -> None:
|
|
177
183
|
"""Start an ongoing sequence of triggered events in a property."""
|
|
178
|
-
self.
|
|
184
|
+
self.core.startPropertySequence(self.device, self.name)
|
|
179
185
|
|
|
180
186
|
def stopSequence(self) -> None:
|
|
181
187
|
"""Stop an ongoing sequence of triggered events in a property."""
|
|
182
|
-
self.
|
|
188
|
+
self.core.stopPropertySequence(self.device, self.name)
|
|
183
189
|
|
|
184
190
|
def dict(self) -> InfoDict:
|
|
185
191
|
"""Return dict of info about this Property.
|
|
@@ -224,5 +230,5 @@ class DeviceProperty:
|
|
|
224
230
|
|
|
225
231
|
def __repr__(self) -> str:
|
|
226
232
|
v = f"value={self.value!r}" if self.isValid() else "INVALID"
|
|
227
|
-
core = repr(self.
|
|
233
|
+
core = repr(self.core).strip("<>")
|
|
228
234
|
return f"<Property '{self.device}::{self.name}' on {core}: {v}>"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import weakref
|
|
3
4
|
from typing import TYPE_CHECKING, Any
|
|
4
5
|
|
|
5
6
|
if TYPE_CHECKING:
|
|
@@ -14,7 +15,13 @@ class _DevicePropValueSignal:
|
|
|
14
15
|
) -> None:
|
|
15
16
|
self._dev = device_label
|
|
16
17
|
self._prop = property_name
|
|
17
|
-
self.
|
|
18
|
+
self._mmc_ref = weakref.ref(mmcore)
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def _mmc(self) -> CMMCorePlus:
|
|
22
|
+
if (mmc := self._mmc_ref()) is None: # pragma: no cover
|
|
23
|
+
raise RuntimeError("CMMCorePlus instance has been garbage collected.")
|
|
24
|
+
return mmc
|
|
18
25
|
|
|
19
26
|
def connect(self, callback: _C) -> _C:
|
|
20
27
|
sig = self._mmc.events.devicePropertyChanged(self._dev, self._prop)
|
|
@@ -131,6 +131,9 @@ class PCoreSignaler(Protocol):
|
|
|
131
131
|
"""Emits `(name: str, newExposure: float)` when the exposure of the SLM device changes.""" # noqa: E501
|
|
132
132
|
sLMExposureChanged: ClassVar[PSignal] # alias
|
|
133
133
|
|
|
134
|
+
shutterOpenChanged: ClassVar[PSignal]
|
|
135
|
+
"""Emits `(name: str, isOpen: bool)` when the shutter open state has changed."""
|
|
136
|
+
|
|
134
137
|
# added for CMMCorePlus
|
|
135
138
|
configSet: ClassVar[PSignal]
|
|
136
139
|
"""Emits `(str, str)` when a config has been set.
|
|
@@ -24,6 +24,7 @@ class CMMCoreSignaler(SignalGroup, _DevicePropertyEventMixin):
|
|
|
24
24
|
XYStagePositionChanged = Signal(str, float, float)
|
|
25
25
|
exposureChanged = Signal(str, float)
|
|
26
26
|
SLMExposureChanged = Signal(str, float)
|
|
27
|
+
shutterOpenChanged = Signal(str, bool)
|
|
27
28
|
|
|
28
29
|
# https://github.com/micro-manager/mmCoreAndDevices/pull/659
|
|
29
30
|
imageSnapped = Signal(str) # on snapImage()
|
|
@@ -25,6 +25,7 @@ class QCoreSignaler(QObject):
|
|
|
25
25
|
exposureChanged = Signal(str, float)
|
|
26
26
|
SLMExposureChanged = Signal(str, float)
|
|
27
27
|
sLMExposureChanged = SLMExposureChanged # alias
|
|
28
|
+
shutterOpenChanged = Signal(str, bool)
|
|
28
29
|
|
|
29
30
|
# https://github.com/micro-manager/mmCoreAndDevices/pull/659
|
|
30
31
|
imageSnapped = Signal(str) # on snapImage()
|