pymmcore-plus 0.15.4__py3-none-any.whl → 0.17.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.
Files changed (35) hide show
  1. pymmcore_plus/__init__.py +20 -1
  2. pymmcore_plus/_accumulator.py +23 -5
  3. pymmcore_plus/_cli.py +44 -26
  4. pymmcore_plus/_discovery.py +344 -0
  5. pymmcore_plus/_ipy_completion.py +1 -1
  6. pymmcore_plus/_logger.py +3 -3
  7. pymmcore_plus/_util.py +9 -245
  8. pymmcore_plus/core/_device.py +57 -13
  9. pymmcore_plus/core/_mmcore_plus.py +20 -23
  10. pymmcore_plus/core/_property.py +35 -29
  11. pymmcore_plus/core/_sequencing.py +2 -0
  12. pymmcore_plus/core/events/_device_signal_view.py +8 -1
  13. pymmcore_plus/experimental/simulate/__init__.py +88 -0
  14. pymmcore_plus/experimental/simulate/_objects.py +670 -0
  15. pymmcore_plus/experimental/simulate/_render.py +510 -0
  16. pymmcore_plus/experimental/simulate/_sample.py +156 -0
  17. pymmcore_plus/experimental/unicore/__init__.py +2 -0
  18. pymmcore_plus/experimental/unicore/_device_manager.py +46 -13
  19. pymmcore_plus/experimental/unicore/core/_config.py +706 -0
  20. pymmcore_plus/experimental/unicore/core/_unicore.py +834 -18
  21. pymmcore_plus/experimental/unicore/devices/_device_base.py +13 -0
  22. pymmcore_plus/experimental/unicore/devices/_hub.py +50 -0
  23. pymmcore_plus/experimental/unicore/devices/_stage.py +46 -1
  24. pymmcore_plus/experimental/unicore/devices/_state.py +6 -0
  25. pymmcore_plus/install.py +149 -18
  26. pymmcore_plus/mda/_engine.py +268 -73
  27. pymmcore_plus/mda/handlers/_5d_writer_base.py +16 -5
  28. pymmcore_plus/mda/handlers/_tensorstore_handler.py +7 -1
  29. pymmcore_plus/metadata/_ome.py +553 -0
  30. pymmcore_plus/metadata/functions.py +2 -1
  31. {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/METADATA +7 -4
  32. {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/RECORD +35 -27
  33. {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/WHEEL +1 -1
  34. {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/entry_points.txt +0 -0
  35. {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.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__ = ["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
@@ -34,6 +35,12 @@ class Device:
34
35
  Device label assigned to this device.
35
36
  mmcore : CMMCorePlus
36
37
  CMMCorePlus instance that owns this device.
38
+ device_type : DeviceType or Device subclass, optional
39
+ The type of device to create. If not specified, the type will be inferred
40
+ from the core if the device is already loaded. If the device is not loaded,
41
+ an error will be raised. This parameter is mainly intended for usage when
42
+ calling from `CMMCorePlus.getDeviceObject()`. Otherwise, prefer using
43
+ `[SpecificDeviceSubclass].create()`.
37
44
 
38
45
  Examples
39
46
  --------
@@ -56,14 +63,39 @@ class Device:
56
63
  propertyChanged: PSignalInstance
57
64
 
58
65
  @classmethod
59
- def create(cls, device_label: str, mmcore: CMMCorePlus) -> Self:
60
- sub_cls = cls.get_subclass(device_label, mmcore)
66
+ def create(
67
+ cls,
68
+ device_label: str,
69
+ mmcore: CMMCorePlus,
70
+ device_type: type[Device] | DeviceType = DeviceType.Any,
71
+ ) -> Self:
72
+ if device_type in {DeviceType.Any, DeviceType.Unknown}:
73
+ try:
74
+ sub_cls = cls.get_subclass(device_label, mmcore)
75
+ except RuntimeError as e:
76
+ raise RuntimeError(
77
+ f"Could not determine device type for {device_label}. "
78
+ "If you are preloading a device object, "
79
+ "please specify `device_type` as a `pymmcore_plus.DeviceType`."
80
+ ) from e
81
+ else:
82
+ if isinstance(device_type, type) and issubclass(device_type, Device):
83
+ sub_cls = device_type
84
+ elif isinstance(device_type, DeviceType):
85
+ sub_cls = _TYPE_MAP[device_type]
86
+ else:
87
+ raise TypeError(
88
+ f"Invalid device_type: {device_type!r}. Must be a "
89
+ "pymmcore_plus `DeviceType` or `Device` subclass."
90
+ )
91
+
61
92
  # make sure it's an error to call this class method on a subclass with
62
93
  # a non-matching type
63
- if issubclass(sub_cls, cls):
64
- return sub_cls(device_label, mmcore)
65
- dev_type = mmcore.getDeviceType(device_label).name
66
- raise TypeError(f"Cannot cast {dev_type} {device_label!r} to {cls}")
94
+ if not issubclass(sub_cls, cls):
95
+ dev_type = mmcore.getDeviceType(device_label).name
96
+ raise TypeError(f"Cannot cast {dev_type} {device_label!r} to {cls}")
97
+
98
+ return sub_cls(device_label, mmcore)
67
99
 
68
100
  @classmethod
69
101
  def get_subclass(cls, device_label: str, mmcore: CMMCorePlus) -> type[Device]:
@@ -82,17 +114,18 @@ class Device:
82
114
  if mmcore is None:
83
115
  from ._mmcore_plus import CMMCorePlus
84
116
 
85
- self._mmc = CMMCorePlus.instance()
117
+ mmcore = CMMCorePlus.instance()
86
118
  else:
87
- self._mmc = mmcore
119
+ mmcore = mmcore
88
120
 
121
+ self._mmc_ref = weakref.ref(mmcore)
89
122
  self._label = device_label
90
123
  self._type = None
91
124
  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
125
+ adapter_name = mmcore.getDeviceLibrary(device_label)
126
+ device_name = mmcore.getDeviceName(device_label)
127
+ description = mmcore.getDeviceDescription(device_label)
128
+ type = mmcore.getDeviceType(device_label) # noqa: A001
96
129
  if self.type() != type:
97
130
  raise TypeError(
98
131
  f"Cannot create loaded device with label {device_label!r} and type "
@@ -103,7 +136,18 @@ class Device:
103
136
  self._device_name = device_name
104
137
  self._type = type
105
138
  self._description = description
106
- self.propertyChanged = _DevicePropValueSignal(device_label, None, self._mmc)
139
+ self.propertyChanged = _DevicePropValueSignal(device_label, None, mmcore)
140
+
141
+ @property
142
+ def _mmc(self) -> CMMCorePlus:
143
+ """Return the `CMMCorePlus` instance to which this Device is bound."""
144
+ mmc = self._mmc_ref()
145
+ if mmc is None: # pragma: no cover
146
+ raise RuntimeError(
147
+ "The CMMCorePlus instance to which this Device "
148
+ "is bound has been garbage collected."
149
+ )
150
+ return mmc
107
151
 
108
152
  @property
109
153
  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.
@@ -1260,18 +1268,7 @@ class CMMCorePlus(pymmcore.CMMCore):
1260
1268
  }
1261
1269
  }
1262
1270
  """
1263
- dev = _device.Device.create(device_label, mmcore=self)
1264
- if (isinstance(device_type, type) and not isinstance(dev, device_type)) or (
1265
- isinstance(device_type, DeviceType)
1266
- and device_type not in {DeviceType.Any, DeviceType.Unknown}
1267
- and dev.type() != device_type
1268
- ):
1269
- raise TypeError(
1270
- f"{device_type!r} requested but device with label "
1271
- f"{device_label!r} is a {dev.type()}."
1272
- )
1273
-
1274
- return dev
1271
+ return _device.Device.create(device_label, mmcore=self, device_type=device_type)
1275
1272
 
1276
1273
  def getConfigGroupObject(
1277
1274
  self, group_name: str, allow_missing: bool = False
@@ -1622,16 +1619,16 @@ class CMMCorePlus(pymmcore.CMMCore):
1622
1619
  **Why Override?** to emit the `imageSnapped` event after snapping an image.
1623
1620
  and to emit shutter property changes if `getAutoShutter` is `True`.
1624
1621
  """
1625
- if autoshutter := self.getAutoShutter():
1626
- self.events.propertyChanged.emit(self.getShutterDevice(), "State", True)
1622
+ if (autoshutter := self.getAutoShutter()) and (
1623
+ shutter := self.getShutterDevice()
1624
+ ):
1625
+ self.events.propertyChanged.emit(shutter, "State", True)
1627
1626
  try:
1628
1627
  self._do_snap_image()
1629
1628
  self.events.imageSnapped.emit(self.getCameraDevice())
1630
1629
  finally:
1631
- if autoshutter:
1632
- self.events.propertyChanged.emit(
1633
- self.getShutterDevice(), "State", False
1634
- )
1630
+ if autoshutter and shutter:
1631
+ self.events.propertyChanged.emit(shutter, "State", False)
1635
1632
 
1636
1633
  @property
1637
1634
  def mda(self) -> MDARunner:
@@ -2083,13 +2080,13 @@ class CMMCorePlus(pymmcore.CMMCore):
2083
2080
  super().deleteConfig(*args)
2084
2081
  self.events.configDeleted.emit(groupName, configName)
2085
2082
 
2086
- def deleteConfigGroup(self, group: str) -> None:
2083
+ def deleteConfigGroup(self, groupName: str) -> None:
2087
2084
  """Deletes an entire configuration `group`.
2088
2085
 
2089
2086
  **Why Override?** To emit a `configGroupDeleted` event.
2090
2087
  """
2091
- super().deleteConfigGroup(group)
2092
- self.events.configGroupDeleted.emit(group)
2088
+ super().deleteConfigGroup(groupName)
2089
+ self.events.configGroupDeleted.emit(groupName)
2093
2090
 
2094
2091
  @overload
2095
2092
  def defineConfig(self, groupName: str, configName: str) -> None: ...