pymmcore-plus 0.13.0__tar.gz → 0.13.2__tar.gz

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 (96) hide show
  1. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/PKG-INFO +1 -1
  2. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/core/_constants.py +8 -3
  3. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/mda/__init__.py +2 -1
  4. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/mda/_engine.py +6 -1
  5. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/mda/_runner.py +73 -33
  6. pymmcore_plus-0.13.2/src/pymmcore_plus/mda/handlers/__init__.py +40 -0
  7. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/mda/handlers/_img_sequence_writer.py +6 -2
  8. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/mda/handlers/_tensorstore_handler.py +18 -4
  9. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/mocks.py +1 -3
  10. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/model/_config_file.py +15 -2
  11. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/test_mda.py +42 -2
  12. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/test_model.py +9 -14
  13. pymmcore_plus-0.13.0/src/pymmcore_plus/mda/handlers/__init__.py +0 -11
  14. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/.gitignore +0 -0
  15. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/LICENSE +0 -0
  16. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/README.md +0 -0
  17. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/pyproject.toml +0 -0
  18. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/__init__.py +0 -0
  19. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/_benchmark.py +0 -0
  20. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/_build.py +0 -0
  21. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/_cli.py +0 -0
  22. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/_logger.py +0 -0
  23. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/_pymmcore.py +0 -0
  24. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/_util.py +0 -0
  25. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/core/__init__.py +0 -0
  26. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/core/_adapter.py +0 -0
  27. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/core/_config.py +0 -0
  28. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/core/_config_group.py +0 -0
  29. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/core/_device.py +0 -0
  30. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/core/_metadata.py +0 -0
  31. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/core/_mmcore_plus.py +0 -0
  32. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/core/_property.py +0 -0
  33. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/core/_sequencing.py +0 -0
  34. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/core/events/__init__.py +0 -0
  35. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/core/events/_device_signal_view.py +0 -0
  36. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/core/events/_norm_slot.py +0 -0
  37. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/core/events/_prop_event_mixin.py +0 -0
  38. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/core/events/_protocol.py +0 -0
  39. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/core/events/_psygnal.py +0 -0
  40. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/core/events/_qsignals.py +0 -0
  41. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/experimental/__init__.py +0 -0
  42. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/experimental/unicore/__init__.py +0 -0
  43. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/experimental/unicore/_device_manager.py +0 -0
  44. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/experimental/unicore/_proxy.py +0 -0
  45. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/experimental/unicore/_unicore.py +0 -0
  46. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/experimental/unicore/devices/__init__.py +0 -0
  47. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/experimental/unicore/devices/_device.py +0 -0
  48. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/experimental/unicore/devices/_properties.py +0 -0
  49. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/experimental/unicore/devices/_stage.py +0 -0
  50. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/install.py +0 -0
  51. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/mda/_protocol.py +0 -0
  52. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/mda/_thread_relay.py +0 -0
  53. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/mda/events/__init__.py +0 -0
  54. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/mda/events/_protocol.py +0 -0
  55. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/mda/events/_psygnal.py +0 -0
  56. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/mda/events/_qsignals.py +0 -0
  57. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/mda/handlers/_5d_writer_base.py +0 -0
  58. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/mda/handlers/_ome_tiff_writer.py +0 -0
  59. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/mda/handlers/_ome_zarr_writer.py +0 -0
  60. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/mda/handlers/_util.py +0 -0
  61. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/metadata/__init__.py +0 -0
  62. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/metadata/functions.py +0 -0
  63. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/metadata/schema.py +0 -0
  64. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/metadata/serialize.py +0 -0
  65. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/model/__init__.py +0 -0
  66. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/model/_config_group.py +0 -0
  67. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/model/_core_device.py +0 -0
  68. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/model/_core_link.py +0 -0
  69. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/model/_device.py +0 -0
  70. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/model/_microscope.py +0 -0
  71. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/model/_pixel_size_config.py +0 -0
  72. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/model/_property.py +0 -0
  73. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/py.typed +0 -0
  74. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/src/pymmcore_plus/seq_tester.py +0 -0
  75. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/__init__.py +0 -0
  76. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/conftest.py +0 -0
  77. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/io/test_image_sequence_writer.py +0 -0
  78. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/io/test_ome_tiff.py +0 -0
  79. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/io/test_zarr_writers.py +0 -0
  80. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/local_config.cfg +0 -0
  81. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/test_adapter_class.py +0 -0
  82. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/test_bench.py +0 -0
  83. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/test_cli.py +0 -0
  84. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/test_config_group_class.py +0 -0
  85. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/test_core.py +0 -0
  86. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/test_device_class.py +0 -0
  87. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/test_events.py +0 -0
  88. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/test_metadata.py +0 -0
  89. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/test_misc.py +0 -0
  90. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/test_pixel_config_class.py +0 -0
  91. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/test_property_class.py +0 -0
  92. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/test_sequencing.py +0 -0
  93. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/test_slm_image.py +0 -0
  94. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/test_thread_relay.py +0 -0
  95. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/unicore/test_unicore.py +0 -0
  96. {pymmcore_plus-0.13.0 → pymmcore_plus-0.13.2}/tests/unicore/test_xy_stage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pymmcore-plus
3
- Version: 0.13.0
3
+ Version: 0.13.2
4
4
  Summary: pymmcore superset providing improved APIs, event handling, and a pure python acquisition engine
5
5
  Project-URL: Source, https://github.com/pymmcore-plus/pymmcore-plus
6
6
  Project-URL: Tracker, https://github.com/pymmcore-plus/pymmcore-plus/issues
@@ -209,10 +209,15 @@ class PortType(IntEnum):
209
209
  HIDPort = pymmcore.HIDPort
210
210
 
211
211
 
212
+ # NB:
213
+ # do *not* use `pymmcore.FocusDirection...` enums here.
214
+ # the MMCore API does not use the device enums (which is what pymmcore exposes)
215
+ # but instead translates MM::FocusDirectionTowardSample into a different number:
216
+ # https://github.com/micro-manager/mmCoreAndDevices/tree/MMCore/MMCore.cpp#L2063-L2074
212
217
  class FocusDirection(IntEnum):
213
- Unknown = pymmcore.FocusDirectionUnknown
214
- TowardSample = pymmcore.FocusDirectionTowardSample
215
- AwayFromSample = pymmcore.FocusDirectionAwayFromSample
218
+ Unknown = 0
219
+ TowardSample = 1
220
+ AwayFromSample = -1
216
221
  # aliases
217
222
  FocusDirectionUnknown = Unknown
218
223
  FocusDirectionTowardSample = TowardSample
@@ -1,6 +1,6 @@
1
1
  from ._engine import MDAEngine
2
2
  from ._protocol import PMDAEngine
3
- from ._runner import MDARunner
3
+ from ._runner import MDARunner, SupportsFrameReady
4
4
  from ._thread_relay import mda_listeners_connected
5
5
  from .events import PMDASignaler
6
6
 
@@ -9,5 +9,6 @@ __all__ = [
9
9
  "MDARunner",
10
10
  "PMDAEngine",
11
11
  "PMDASignaler",
12
+ "SupportsFrameReady",
12
13
  "mda_listeners_connected",
13
14
  ]
@@ -248,7 +248,12 @@ class MDAEngine(PMDAEngine):
248
248
  self._mmc.setExposure(event.exposure)
249
249
  except Exception as e:
250
250
  logger.warning("Failed to set exposure. %s", e)
251
-
251
+ if event.properties is not None:
252
+ try:
253
+ for dev, prop, value in event.properties:
254
+ self._mmc.setProperty(dev, prop, value)
255
+ except Exception as e:
256
+ logger.warning("Failed to set properties. %s", e)
252
257
  if (
253
258
  # (if autoshutter wasn't set at the beginning of the sequence
254
259
  # then it never matters...)
@@ -5,8 +5,9 @@ import warnings
5
5
  from collections.abc import Iterable, Iterator, Sequence
6
6
  from contextlib import AbstractContextManager, nullcontext
7
7
  from pathlib import Path
8
- from typing import TYPE_CHECKING, Any
8
+ from typing import TYPE_CHECKING, Any, cast
9
9
  from unittest.mock import MagicMock
10
+ from weakref import WeakSet
10
11
 
11
12
  from useq import MDASequence
12
13
 
@@ -17,13 +18,40 @@ from ._thread_relay import mda_listeners_connected
17
18
  from .events import PMDASignaler, _get_auto_MDA_callback_class
18
19
 
19
20
  if TYPE_CHECKING:
20
- from typing import TypeAlias
21
+ from typing import Protocol, TypeAlias
21
22
 
23
+ import numpy as np
22
24
  from useq import MDAEvent
23
25
 
26
+ from pymmcore_plus.metadata.schema import FrameMetaV1
27
+
24
28
  from ._engine import MDAEngine
25
29
 
26
- SingleOutput: TypeAlias = Path | str | object
30
+ class FrameReady0(Protocol):
31
+ """Data handler with a no-argument `frameReady` method."""
32
+
33
+ def frameReady(self) -> Any: ...
34
+
35
+ class FrameReady1(Protocol):
36
+ """Data handler with a `frameReady` method that takes `(image,)` ."""
37
+
38
+ def frameReady(self, img: np.ndarray, /) -> Any: ...
39
+
40
+ class FrameReady2(Protocol):
41
+ """Data handler with a `frameReady` method that takes `(image, event)`."""
42
+
43
+ def frameReady(self, img: np.ndarray, event: MDAEvent, /) -> Any: ...
44
+
45
+ class FrameReady3(Protocol):
46
+ """Data handler with a `frameReady` method that takes `(image, event, meta)`."""
47
+
48
+ def frameReady(
49
+ self, img: np.ndarray, event: MDAEvent, meta: FrameMetaV1, /
50
+ ) -> Any: ...
51
+
52
+
53
+ SupportsFrameReady: TypeAlias = "FrameReady0 | FrameReady1 | FrameReady2 | FrameReady3"
54
+ SingleOutput: TypeAlias = "Path | str | SupportsFrameReady"
27
55
 
28
56
  MSG = (
29
57
  "This sequence is a placeholder for a generator of events with unknown "
@@ -67,7 +95,7 @@ class MDARunner:
67
95
  self._paused = False
68
96
  self._paused_time: float = 0
69
97
  self._pause_interval: float = 0.1 # sec to wait between checking pause state
70
-
98
+ self._handlers: WeakSet[SupportsFrameReady] = WeakSet()
71
99
  self._canceled = False
72
100
  self._sequence: MDASequence | None = None
73
101
  # timer for the full sequence, reset only once at the beginning of the sequence
@@ -189,6 +217,10 @@ class MDARunner:
189
217
  - A handler object that implements the `DataHandler` protocol, currently
190
218
  meaning it has a `frameReady` method. See `mda_listeners_connected`
191
219
  for more details.
220
+
221
+ During the course of the sequence, the `get_output_handlers` method can be
222
+ used to get the currently connected output handlers (including those that
223
+ were created automatically based on file paths).
192
224
  """
193
225
  error = None
194
226
  sequence = events if isinstance(events, MDASequence) else GeneratorMDASequence()
@@ -206,6 +238,31 @@ class MDARunner:
206
238
  if error is not None:
207
239
  raise error
208
240
 
241
+ def get_output_handlers(self) -> tuple[SupportsFrameReady, ...]:
242
+ """Return the data handlers that are currently connected.
243
+
244
+ Output handlers are connected by passing them to the `output` parameter of the
245
+ `run` method; the run method accepts objects with a `frameReady` method *or*
246
+ strings representing paths. If a string is passed, a handler will be created
247
+ internally.
248
+
249
+ This method returns a tuple of currently connected handlers, including those
250
+ that were explicitly passed to `run()`, as well as those that were created based
251
+ on file paths. Internally, handlers are held by weak references, so if you want
252
+ the handler to persist, you must keep a reference to it. The only guaranteed
253
+ API that the handler will have is the `frameReady` method, but it could be any
254
+ user-defined object that implements that method.
255
+
256
+ Handlers are cleared each time `run()` is called, (but not at the end
257
+ of the sequence).
258
+
259
+ Returns
260
+ -------
261
+ tuple[SupportsFrameReady, ...]
262
+ Tuple of objects that (minimally) support the `frameReady` method.
263
+ """
264
+ return tuple(self._handlers)
265
+
209
266
  def seconds_elapsed(self) -> float:
210
267
  """Return the number of seconds since the start of the acquisition."""
211
268
  return time.perf_counter() - self._sequence_t0
@@ -228,48 +285,31 @@ class MDARunner:
228
285
  if isinstance(output, (str, Path)) or not isinstance(output, Sequence):
229
286
  output = [output]
230
287
 
231
- # convert all items to handler objects
232
- handlers: list[Any] = []
288
+ # convert all items to handler objects, preserving order
289
+ _handlers: list[SupportsFrameReady] = []
233
290
  for item in output:
234
291
  if isinstance(item, (str, Path)):
235
- handlers.append(self._handler_for_path(item))
292
+ _handlers.append(self._handler_for_path(item))
236
293
  else:
237
- # TODO: better check for valid handler protocol
238
- # quick hack for now.
239
- if not hasattr(item, "frameReady"):
294
+ if not callable(getattr(item, "frameReady", None)):
240
295
  raise TypeError(
241
- "Output handlers must have a frameReady method. "
296
+ "Output handlers must have a callable frameReady method. "
242
297
  f"Got {item} with type {type(item)}."
243
298
  )
244
- handlers.append(item)
299
+ _handlers.append(item)
245
300
 
246
- return mda_listeners_connected(*handlers, mda_events=self._signals)
301
+ self._handlers.clear()
302
+ self._handlers.update(_handlers)
303
+ return mda_listeners_connected(*_handlers, mda_events=self._signals)
247
304
 
248
- def _handler_for_path(self, path: str | Path) -> object:
305
+ def _handler_for_path(self, path: str | Path) -> SupportsFrameReady:
249
306
  """Convert a string or Path into a handler object.
250
307
 
251
308
  This method picks from the built-in handlers based on the extension of the path.
252
309
  """
253
- path = str(Path(path).expanduser().resolve())
254
- if path.endswith(".zarr"):
255
- from pymmcore_plus.mda.handlers import OMEZarrWriter
256
-
257
- return OMEZarrWriter(path)
258
-
259
- if path.endswith((".tiff", ".tif")):
260
- from pymmcore_plus.mda.handlers import OMETiffWriter
261
-
262
- return OMETiffWriter(path)
263
-
264
- # FIXME: ugly hack for the moment to represent a non-existent directory
265
- # there are many features that ImageSequenceWriter supports, and it's unclear
266
- # how to infer them all from a single string.
267
- if not (Path(path).suffix or Path(path).exists()):
268
- from pymmcore_plus.mda.handlers import ImageSequenceWriter
269
-
270
- return ImageSequenceWriter(path)
310
+ from pymmcore_plus.mda.handlers import handler_for_path
271
311
 
272
- raise ValueError(f"Could not infer a writer handler for path: '{path}'")
312
+ return cast("SupportsFrameReady", handler_for_path(path))
273
313
 
274
314
  def _run(self, engine: PMDAEngine, events: Iterable[MDAEvent]) -> None:
275
315
  """Main execution of events, inside the try/except block of `run`."""
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from ._img_sequence_writer import ImageSequenceWriter
6
+ from ._ome_tiff_writer import OMETiffWriter
7
+ from ._ome_zarr_writer import OMEZarrWriter
8
+ from ._tensorstore_handler import TensorStoreHandler
9
+
10
+ __all__ = [
11
+ "ImageSequenceWriter",
12
+ "OMETiffWriter",
13
+ "OMEZarrWriter",
14
+ "TensorStoreHandler",
15
+ "handler_for_path",
16
+ ]
17
+
18
+
19
+ def handler_for_path(path: str | Path) -> object:
20
+ """Convert a string or Path into a handler object.
21
+
22
+ This method picks from the built-in handlers based on the extension of the path.
23
+ """
24
+ if str(path).rstrip("/").rstrip(":").lower() == "memory":
25
+ return TensorStoreHandler(kvstore="memory://")
26
+
27
+ path = str(Path(path).expanduser().resolve())
28
+ if path.endswith(".zarr"):
29
+ return OMEZarrWriter(path)
30
+
31
+ if path.endswith((".tiff", ".tif")):
32
+ return OMETiffWriter(path)
33
+
34
+ # FIXME: ugly hack for the moment to represent a non-existent directory
35
+ # there are many features that ImageSequenceWriter supports, and it's unclear
36
+ # how to infer them all from a single string.
37
+ if not (Path(path).suffix or Path(path).exists()):
38
+ return ImageSequenceWriter(path)
39
+
40
+ raise ValueError(f"Could not infer a writer handler for path: '{path}'")
@@ -22,6 +22,8 @@ if TYPE_CHECKING:
22
22
  import useq
23
23
  from typing_extensions import TypeAlias # py310
24
24
 
25
+ from pymmcore_plus.metadata.schema import FrameMetaV1
26
+
25
27
  ImgWriter: TypeAlias = Callable[[str, npt.NDArray], Any]
26
28
 
27
29
  FRAME_KEY = "frame"
@@ -118,7 +120,7 @@ class ImageSequenceWriter:
118
120
  shutil.rmtree(self._directory)
119
121
 
120
122
  # ongoing dict of frame meta... stored for easy rewrite without reading
121
- self._frame_metadata: dict[str, dict] = {}
123
+ self._frame_metadata: dict[str, FrameMetaV1] = {}
122
124
  self._frame_meta_file = self._directory.joinpath(self.FRAME_META_PATH)
123
125
  self._seq_meta_file = self._directory.joinpath(self.SEQ_META_PATH)
124
126
 
@@ -186,7 +188,9 @@ class ImageSequenceWriter:
186
188
  # write final frame metadata to disk
187
189
  self._frame_meta_file.write_bytes(json_dumps(self._frame_metadata, indent=2))
188
190
 
189
- def frameReady(self, frame: np.ndarray, event: useq.MDAEvent, meta: dict) -> None:
191
+ def frameReady(
192
+ self, frame: np.ndarray, event: useq.MDAEvent, meta: FrameMetaV1, /
193
+ ) -> None:
190
194
  """Write a frame to disk."""
191
195
  frame_idx = next(self._counter)
192
196
  if self._name_template:
@@ -111,6 +111,8 @@ class TensorStoreHandler:
111
111
  self.delete_existing = delete_existing
112
112
  self.spec = spec
113
113
 
114
+ self._current_sequence: useq.MDASequence | None = None
115
+
114
116
  # storage of individual frame metadata
115
117
  # maps position key to list of frame metadata
116
118
  self.frame_metadatas: list[tuple[useq.MDAEvent, FrameMetaV1]] = []
@@ -176,13 +178,25 @@ class TensorStoreHandler:
176
178
 
177
179
  return cls(path=path, **kwargs)
178
180
 
179
- def sequenceStarted(self, seq: useq.MDASequence, meta: SummaryMetaV1) -> None:
180
- """On sequence started, simply store the sequence."""
181
+ def reset(self, sequence: useq.MDASequence) -> None:
182
+ """Reset state to prepare for new `sequence`."""
181
183
  self._frame_index = 0
182
184
  self._store = None
183
185
  self._futures.clear()
184
186
  self.frame_metadatas.clear()
185
- self.current_sequence = seq
187
+ self._current_sequence = sequence
188
+
189
+ @property
190
+ def current_sequence(self) -> useq.MDASequence | None:
191
+ """Return current sequence, or none.
192
+
193
+ Use `.reset()` to initialize the handler for a new sequence.
194
+ """
195
+ return self._current_sequence
196
+
197
+ def sequenceStarted(self, seq: useq.MDASequence, meta: SummaryMetaV1) -> None:
198
+ """On sequence started, simply store the sequence."""
199
+ self.reset(seq)
186
200
 
187
201
  def sequenceFinished(self, seq: useq.MDASequence) -> None:
188
202
  """On sequence finished, clear the current sequence."""
@@ -199,7 +213,7 @@ class TensorStoreHandler:
199
213
  self.finalize_metadata()
200
214
 
201
215
  def frameReady(
202
- self, frame: np.ndarray, event: useq.MDAEvent, meta: FrameMetaV1
216
+ self, frame: np.ndarray, event: useq.MDAEvent, meta: FrameMetaV1, /
203
217
  ) -> None:
204
218
  """Write frame to the zarr array for the appropriate position."""
205
219
  if self._store is None:
@@ -28,9 +28,7 @@ class MockSequenceableCore(MagicMock):
28
28
  self.loadExposureSequence.return_value = None
29
29
  self.loadStageSequence.return_value = None
30
30
  self.loadXYStageSequence.return_value = None
31
- # TODO: add support in pymmcore-nano
32
- if hasattr(CMMCorePlus, "loadSLMSequence"):
33
- self.loadSLMSequence.return_value = None
31
+ self.loadSLMSequence.return_value = None
34
32
 
35
33
  self.loadPropertySequence.return_value = None
36
34
 
@@ -209,18 +209,28 @@ def run_command(line: str, scope: Microscope) -> None:
209
209
  return
210
210
 
211
211
  exec_cmd, expected_n_args = COMMAND_EXECUTORS[command]
212
+ should_raise = command in SHOULD_RAISE
212
213
 
213
214
  if (nargs := len(args) + 1) not in expected_n_args:
214
215
  exp_str = " or ".join(map(str, expected_n_args))
215
- raise ValueError(
216
+ msg = (
216
217
  f"Invalid configuration line encountered for command {cmd_name}. "
217
218
  f"Expected {exp_str} arguments, got {nargs}: {line!r}"
218
219
  )
220
+ if should_raise:
221
+ raise ValueError(msg)
222
+ else:
223
+ warnings.warn(msg, RuntimeWarning, stacklevel=2)
224
+ return
219
225
 
220
226
  try:
221
227
  exec_cmd(scope, args)
222
228
  except Exception as exc:
223
- raise ValueError(f"Error executing command {line!r}: {exc}") from exc
229
+ if should_raise:
230
+ raise ValueError(f"Error executing command {line!r}: {exc}") from exc
231
+ warnings.warn(
232
+ f"Failed to execute command {line!r}: {exc}", RuntimeWarning, stacklevel=2
233
+ )
224
234
 
225
235
 
226
236
  def _exec_Device(scope: Microscope, args: Sequence[str]) -> None:
@@ -352,3 +362,6 @@ COMMAND_EXECUTORS: dict[CFGCommand, tuple[Executor, set[int]]] = {
352
362
  CFGCommand.ParentID: (_exec_ParentID, {3}),
353
363
  CFGCommand.FocusDirection: (_exec_FocusDirection, {3}),
354
364
  }
365
+
366
+ # Commands that should raise when fail
367
+ SHOULD_RAISE = {CFGCommand.Device, CFGCommand.Property}
@@ -49,19 +49,33 @@ def test_mda_waiting(core: CMMCorePlus) -> None:
49
49
 
50
50
  def test_setting_position(core: CMMCorePlus) -> None:
51
51
  core.mda._running = True
52
- event1 = MDAEvent(exposure=123, x_pos=123, y_pos=456, z_pos=1)
52
+ event1 = MDAEvent(
53
+ exposure=123,
54
+ x_pos=123,
55
+ y_pos=456,
56
+ z_pos=1,
57
+ properties=[("Camera", "TestProperty1", 0.05)],
58
+ )
53
59
  core.mda.engine.setup_event(event1)
54
60
  assert tuple(core.getXYPosition()) == (123, 456)
55
61
  assert core.getPosition() == 1
56
62
  assert core.getExposure() == 123
63
+ assert core.getProperty("Camera", "TestProperty1") == "0.0500"
57
64
 
58
65
  # check that we aren't check things like: if event.x_pos
59
66
  # because then we will not set to zero
60
- event2 = MDAEvent(exposure=321, x_pos=0, y_pos=0, z_pos=0)
67
+ event2 = MDAEvent(
68
+ exposure=321,
69
+ x_pos=0,
70
+ y_pos=0,
71
+ z_pos=0,
72
+ properties=[("Camera", "TestProperty2", -0.07)],
73
+ )
61
74
  core.mda.engine.setup_event(event2)
62
75
  assert tuple(core.getXYPosition()) == (0, 0)
63
76
  assert core.getPosition() == 0
64
77
  assert core.getExposure() == 321
78
+ assert core.getProperty("Camera", "TestProperty2") == "-0.0700"
65
79
 
66
80
 
67
81
  class BrokenEngine:
@@ -450,3 +464,29 @@ def test_queue_mda(core: CMMCorePlus) -> None:
450
464
  # make sure that the engine's iterator was NOT used when running an iter(Queue)
451
465
  mock_engine.event_iterator.assert_not_called()
452
466
  assert mock_engine.setup_event.call_count == 2
467
+
468
+
469
+ def test_get_handlers(core: CMMCorePlus) -> None:
470
+ """Test that we can get the handlers"""
471
+ runner = core.mda
472
+
473
+ assert not runner.get_output_handlers()
474
+ on_start_names: list[str] = []
475
+ on_finish_names: list[str] = []
476
+
477
+ @runner.events.sequenceStarted.connect
478
+ def _on_start() -> None:
479
+ on_start_names.extend([type(h).__name__ for h in runner.get_output_handlers()])
480
+
481
+ @runner.events.sequenceFinished.connect
482
+ def _on_end() -> None:
483
+ on_finish_names.extend([type(h).__name__ for h in runner.get_output_handlers()])
484
+
485
+ runner.run([MDAEvent()], output="memory://")
486
+
487
+ # weakref is used to store the handlers,
488
+ # handlers should be cleared after the sequence is finished
489
+ assert not runner.get_output_handlers()
490
+ # but they should have been available during start and finish events
491
+ assert on_start_names == ["TensorStoreHandler"]
492
+ assert on_finish_names == ["TensorStoreHandler"]
@@ -4,8 +4,6 @@ from pathlib import Path
4
4
 
5
5
  import pytest
6
6
 
7
- import pymmcore_plus
8
- import pymmcore_plus._pymmcore
9
7
  from pymmcore_plus import CMMCorePlus, DeviceType, find_micromanager
10
8
  from pymmcore_plus.metadata import summary_metadata
11
9
  from pymmcore_plus.model import CoreDevice, Device, Microscope
@@ -142,46 +140,47 @@ def test_load_errors() -> None:
142
140
  model.load_config("Property,A,B,C,D,E")
143
141
  with pytest.raises(ValueError, match="not an integer"):
144
142
  model.load_config("Property,Core,Initialize,NotAnInt")
145
- with pytest.raises(ValueError, match="not an integer"):
143
+ model.load_config("Device,Dichroic,DemoCamera,DWheel") # fine
144
+ with pytest.warns(RuntimeWarning, match="not an integer"):
146
145
  model.load_config(
147
146
  """
148
147
  Device,Dichroic,DemoCamera,DWheel
149
148
  Label,Dichroic,NotAnInt,Q505LP
150
149
  """
151
150
  )
152
- with pytest.raises(ValueError, match="'NotAPreset' not found"):
151
+ with pytest.warns(RuntimeWarning, match="'NotAPreset' not found"):
153
152
  model.load_config("PixelSize_um,NotAPreset,0.5")
154
- with pytest.raises(ValueError, match="Expected a float"):
153
+ with pytest.warns(RuntimeWarning, match="Expected a float"):
155
154
  model.load_config(
156
155
  """
157
156
  ConfigPixelSize,Res40x,Objective,Label,Nikon 40X Plan Flueor ELWD
158
157
  PixelSize_um,Res40x,NotAFloat
159
158
  """
160
159
  )
161
- with pytest.raises(ValueError, match="'Res10x' not found"):
160
+ with pytest.warns(RuntimeWarning, match="'Res10x' not found"):
162
161
  model.load_config("PixelSizeAffine,Res10x,1.0,0.0,0.0,0.0,1.1,0.0")
163
- with pytest.raises(ValueError, match="Expected 8 arguments, got 5"):
162
+ with pytest.warns(RuntimeWarning, match="Expected 8 arguments, got 5"):
164
163
  model.load_config(
165
164
  """
166
165
  ConfigPixelSize,Res40x,Objective,Label,Nikon 40X Plan Flueor ELWD
167
166
  PixelSizeAffine,Res40x,1.0,0.0,0.0
168
167
  """
169
168
  )
170
- with pytest.raises(ValueError, match="Expected 6 floats"):
169
+ with pytest.warns(RuntimeWarning, match="Expected 6 floats"):
171
170
  model.load_config(
172
171
  """
173
172
  ConfigPixelSize,Res40x,Objective,Label,Nikon 40X Plan Flueor ELWD
174
173
  PixelSizeAffine,Res40x,1.0,0.0,0.0,0.0,1.1,NoFloat
175
174
  """
176
175
  )
177
- with pytest.raises(ValueError, match="Expected a float"):
176
+ with pytest.warns(RuntimeWarning, match="Expected a float"):
178
177
  model.load_config(
179
178
  """
180
179
  Device,Shutter,DemoCamera,DShutter
181
180
  Delay,Shutter,NotAFloat
182
181
  """
183
182
  )
184
- with pytest.raises(ValueError, match="not a valid FocusDirection"):
183
+ with pytest.warns(RuntimeWarning, match="not a valid FocusDirection"):
185
184
  model.load_config(
186
185
  """
187
186
  Device,Z,DemoCamera,DStage
@@ -201,10 +200,6 @@ def test_scope_errs():
201
200
  Microscope(devices=[CoreDevice()])
202
201
 
203
202
 
204
- @pytest.mark.skipif(
205
- pymmcore_plus._pymmcore.BACKEND == "pymmcore-nano",
206
- reason="This is still hanging on pymmcore-nano",
207
- )
208
203
  def test_apply():
209
204
  core1 = CMMCorePlus()
210
205
  core1.loadSystemConfiguration()
@@ -1,11 +0,0 @@
1
- from ._img_sequence_writer import ImageSequenceWriter
2
- from ._ome_tiff_writer import OMETiffWriter
3
- from ._ome_zarr_writer import OMEZarrWriter
4
- from ._tensorstore_handler import TensorStoreHandler
5
-
6
- __all__ = [
7
- "ImageSequenceWriter",
8
- "OMETiffWriter",
9
- "OMEZarrWriter",
10
- "TensorStoreHandler",
11
- ]
File without changes
File without changes