pymmcore-plus 0.15.4__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/_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__ = ["find_micromanager", "no_stdout", "retry", "signals_backend"]
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
- from qtpy import API_NAME, QT_VERSION
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
@@ -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
- self._mmc = CMMCorePlus.instance()
86
+ mmcore = CMMCorePlus.instance()
86
87
  else:
87
- self._mmc = mmcore
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 = self._mmc.getDeviceLibrary(device_label)
93
- device_name = self._mmc.getDeviceName(device_label)
94
- description = self._mmc.getDeviceDescription(device_label)
95
- type = self._mmc.getDeviceType(device_label) # noqa: A001
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, self._mmc)
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 find_micromanager, print_tabular_data
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
- return FocusDirection(super().getFocusDirection(stageLabel))
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.events.propertyChanged.emit(self.getShutterDevice(), "State", True)
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:
@@ -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._mmc = mmcore
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._mmc)
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._mmc.hasProperty(self.device, self.name)
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._mmc is not None and self.device in self._mmc.getLoadedDevices()
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._mmc.getProperty(self.device, self.name)
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._mmc.getPropertyFromCache(self.device, self.name)
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._mmc.setProperty(self.device, self.name, val)
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._mmc.isPropertyReadOnly(self.device, self.name)
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._mmc.isPropertyPreInit(self.device, self.name)
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._mmc.hasPropertyLimits(self.device, self.name)
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._mmc.getPropertyLowerLimit(self.device, self.name)
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._mmc.getPropertyUpperLimit(self.device, self.name)
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._mmc.getPropertyType(self.device, self.name)
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._mmc.getDeviceType(self.device)
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._mmc.getAllowedPropertyValues(self.device, self.name)
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._mmc.getNumberOfStates(self.device)
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._mmc.getStateLabels(self.device)
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._mmc.isPropertySequenceable(self.device, self.name)
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._mmc.getPropertySequenceMaxLength(self.device, self.name)
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._mmc.loadPropertySequence(self.device, self.name, eventSequence)
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._mmc.startPropertySequence(self.device, self.name)
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._mmc.stopPropertySequence(self.device, self.name)
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._mmc).strip("<>")
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._mmc = mmcore
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)
@@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
23
23
  class PyDeviceManager:
24
24
  """Manages loaded Python devices."""
25
25
 
26
- __slots__ = ("_devices",)
26
+ __slots__ = ("_devices", "_executor")
27
27
 
28
28
  def __init__(self) -> None:
29
29
  self._devices: dict[str, Device] = {}
@@ -53,9 +53,8 @@ class PyDeviceManager:
53
53
 
54
54
  # Initialize all devices in parallel
55
55
  with ThreadPoolExecutor() as executor:
56
- for future in as_completed(
57
- executor.submit(self.initialize, label) for label in labels
58
- ):
56
+ futures = [executor.submit(self.initialize, label) for label in labels]
57
+ for future in as_completed(futures):
59
58
  future.result()
60
59
 
61
60
  def wait_for(
@@ -75,17 +74,25 @@ class PyDeviceManager:
75
74
  )
76
75
  time.sleep(polling_interval)
77
76
 
78
- def wait_for_device_type(self, dev_type: int, timeout_ms: float = 5000) -> None:
77
+ def wait_for_device_type(
78
+ self, dev_type: int, timeout_ms: float = 5000, *, parallel: bool = True
79
+ ) -> None:
79
80
  if not (labels := self.get_labels_of_type(dev_type)):
80
81
  return # pragma: no cover
81
-
82
- # Wait for all python devices of the given type in parallel
83
- with ThreadPoolExecutor() as executor:
84
- futures = (
85
- executor.submit(self.wait_for, lbl, timeout_ms) for lbl in labels
86
- )
87
- for future in as_completed(futures):
88
- future.result() # Raises any exceptions from wait_for_device
82
+ if not parallel:
83
+ for lbl in labels:
84
+ self.wait_for(lbl, timeout_ms)
85
+ else:
86
+ # Wait for all python devices of the given type in parallel
87
+ # it's critical that this be a list comprehension,
88
+ # not a generator expression, otherwise the executor may be shut down
89
+ # before any tasks are actually submitted
90
+ with ThreadPoolExecutor() as executor:
91
+ futures = [
92
+ executor.submit(self.wait_for, lbl, timeout_ms) for lbl in labels
93
+ ]
94
+ for future in as_completed(futures):
95
+ future.result() # Raises any exceptions from wait_for_device
89
96
 
90
97
  def get_initialization_state(self, label: str) -> DeviceInitializationState:
91
98
  """Return the initialization state of the device with the given label."""