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
@@ -64,6 +64,7 @@ class Device(_Lockable, ABC):
64
64
  {}, self._cls_prop_controllers
65
65
  )
66
66
  self._core_proxy_: CMMCoreProxy | None = None
67
+ self._parent_label_: str = "" # label of the parent hub device
67
68
 
68
69
  @property
69
70
  def core(self) -> CMMCoreProxy:
@@ -243,6 +244,18 @@ class Device(_Lockable, ABC):
243
244
  """Return `True` if the property is read-only."""
244
245
  return self._get_prop_or_raise(prop_name).is_read_only
245
246
 
247
+ # PARENT HUB RELATIONSHIP
248
+
249
+ @final # may not be overridden
250
+ def get_parent_label(self) -> str:
251
+ """Return the label of the parent hub device, or empty string if none."""
252
+ return self._parent_label_
253
+
254
+ @final # may not be overridden
255
+ def set_parent_label(self, parent_label: str) -> None:
256
+ """Set the label of the parent hub device."""
257
+ self._parent_label_ = parent_label
258
+
246
259
 
247
260
  SeqT = TypeVar("SeqT")
248
261
 
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, ClassVar, Literal
4
+
5
+ from pymmcore_plus.core._constants import DeviceType
6
+
7
+ from ._device_base import Device
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Sequence
11
+
12
+
13
+ class HubDevice(Device):
14
+ """ABC for Hub devices that can have peripheral devices attached.
15
+
16
+ Hub devices represent a central device (e.g., a controller) that can have
17
+ multiple peripheral devices attached to it. Examples include multi-channel
18
+ controllers, or devices that manage multiple sub-devices.
19
+
20
+ To implement a Hub device, simply override `get_installed_peripherals()`:
21
+
22
+ ```python
23
+ class MyHub(HubDevice):
24
+ def get_installed_peripherals(self) -> Sequence[tuple[str, str]]:
25
+ return [
26
+ ("Motor1", "First motor controller"),
27
+ ("Motor2", "Second motor controller"),
28
+ ]
29
+ ```
30
+
31
+ If your hub needs to perform expensive detection (e.g., scanning a bus),
32
+ implement caching inside your `get_installed_peripherals()` method.
33
+ """
34
+
35
+ _TYPE: ClassVar[Literal[DeviceType.Hub]] = DeviceType.Hub
36
+
37
+ def get_installed_peripherals(self) -> Sequence[tuple[str, str]]:
38
+ """Return information about installed peripheral devices.
39
+
40
+ Override this method to return a sequence of `tuple[str, str]` objects
41
+ describing all devices that can be loaded as peripherals of this hub.
42
+
43
+ Returns
44
+ -------
45
+ Sequence[tuple[str, str]]
46
+ A sequence of (name, description) tuples for each available peripheral.
47
+ The name MUST be the name of a class, importable from the same module as
48
+ this hub.
49
+ """
50
+ return ()
@@ -2,7 +2,7 @@ from abc import abstractmethod
2
2
  from typing import ClassVar, Literal
3
3
 
4
4
  from pymmcore_plus.core import DeviceType
5
- from pymmcore_plus.core._constants import Keyword
5
+ from pymmcore_plus.core._constants import FocusDirection, Keyword
6
6
 
7
7
  from ._device_base import SeqT, SequenceableDevice
8
8
 
@@ -38,6 +38,51 @@ class StageDevice(_BaseStage[float]):
38
38
  def get_position_um(self) -> float:
39
39
  """Returns the current position of the stage in microns."""
40
40
 
41
+ def get_focus_direction(self) -> FocusDirection:
42
+ """Returns the focus direction of the stage."""
43
+ return FocusDirection.Unknown
44
+
45
+ def set_focus_direction(self, sign: int) -> None:
46
+ """Sets the focus direction of the stage."""
47
+ raise NotImplementedError( # pragma: no cover
48
+ "This device does not support setting focus direction"
49
+ )
50
+
51
+ def set_relative_position_um(self, d: float) -> None:
52
+ """Move the stage by a relative amount.
53
+
54
+ Can be overridden for more efficient implementations.
55
+ """
56
+ pos = self.get_position_um()
57
+ self.set_position_um(pos + d)
58
+
59
+ def set_adapter_origin_um(self, newZUm: float) -> None:
60
+ """Enable software translation of coordinates.
61
+
62
+ The current position of the stage becomes Z = newZUm.
63
+ Only some stages support this functionality; it is recommended that
64
+ set_origin() be used instead where available.
65
+ """
66
+ # Default implementation does nothing - subclasses can override
67
+ pass
68
+
69
+ def is_linear_sequenceable(self) -> bool:
70
+ """Return True if the stage supports linear sequences.
71
+
72
+ A linear sequence is defined by a step size and number of slices.
73
+ """
74
+ return False
75
+
76
+ def set_linear_sequence(self, dZ_um: float, nSlices: int) -> None:
77
+ """Load a linear sequence defined by step size and number of slices."""
78
+ raise NotImplementedError( # pragma: no cover
79
+ "This device does not support linear sequences"
80
+ )
81
+
82
+ def is_continuous_focus_drive(self) -> bool:
83
+ """Return True if positions can be set while continuous focus runs."""
84
+ return False
85
+
41
86
 
42
87
  # TODO: consider if we can just subclass StageDevice instead of _BaseStage
43
88
  class XYStageDevice(_BaseStage[tuple[float, float]]):
@@ -135,8 +135,14 @@ class StateDevice(Device):
135
135
  # internal method to set the state, called by the property setter
136
136
  # to keep the label and state property in sync
137
137
  self.set_state(state) # call the device-specific method
138
+ self.core.events.propertyChanged.emit(
139
+ self.get_label(), Keyword.State.value, state
140
+ )
138
141
  label = self._state_to_label.get(state, "")
139
142
  self.set_property_value(Keyword.Label, label)
143
+ self.core.events.propertyChanged.emit(
144
+ self.get_label(), Keyword.Label.value, label
145
+ )
140
146
 
141
147
  def _get_current_label(self) -> str:
142
148
  # internal method to get the current label, called by the property getter
pymmcore_plus/install.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import os
4
5
  import re
5
6
  import shutil
@@ -7,6 +8,7 @@ import ssl
7
8
  import subprocess
8
9
  import sys
9
10
  import tempfile
11
+ import zipfile
10
12
  from contextlib import contextmanager, nullcontext
11
13
  from functools import cache
12
14
  from pathlib import Path
@@ -16,6 +18,7 @@ from urllib.request import urlopen, urlretrieve
16
18
 
17
19
  import typer
18
20
 
21
+ from pymmcore_plus import _pymmcore
19
22
  from pymmcore_plus._util import USER_DATA_MM_PATH
20
23
 
21
24
  if TYPE_CHECKING:
@@ -62,11 +65,12 @@ MACH = machine()
62
65
  BASE_URL = "https://download.micro-manager.org"
63
66
  plat = {"Darwin": "Mac", "Windows": "Windows", "Linux": "Linux"}.get(PLATFORM)
64
67
  DOWNLOADS_URL = f"{BASE_URL}/nightly/2.0/{plat}/"
65
-
68
+ TEST_ADAPTERS_REPO = "micro-manager/mm-test-adapters"
69
+ PYMMCORE_DIV = _pymmcore.version_info.device_interface
66
70
 
67
71
  # Dates of release for each interface version.
68
72
  # generally running `mmcore install -r <some_date>` will bring in devices with
69
- # the NEW interface.
73
+ # the NEW interface, introduced that day.
70
74
  INTERFACES: dict[int, str] = {
71
75
  74: "20250815",
72
76
  73: "20250318",
@@ -173,11 +177,19 @@ def _mac_install(dmg: Path, dest: Path, log_msg: _MsgLogger) -> None:
173
177
 
174
178
  @cache
175
179
  def available_versions() -> dict[str, str]:
176
- """Return a map of version -> url available for download."""
180
+ """Return a map of version -> url available for download.
181
+
182
+ Returns a dict like:
183
+ {
184
+ '20220906': 'https://download.micro-manager.org/nightly/2.0/Mac/Micro-Manager-2.0.1-20220906.dmg',
185
+ '20220901': 'https://download.micro-manager.org/nightly/2.0/Mac/Micro-Manager-2.0.1-20220901.dmg',
186
+ '20220823': 'https://download.micro-manager.org/nightly/2.0/Mac/Micro-Manager-2.0.1-20220823.dmg',
187
+ }
188
+ """
177
189
  with urlopen(DOWNLOADS_URL) as resp:
178
190
  html = resp.read().decode("utf-8")
179
191
 
180
- all_links = re.findall(r"href=\"([^\"]+)\"", html)
192
+ all_links = re.findall(r'href="([^"]+)"', html)
181
193
  delim = "_" if PLATFORM == "Windows" else "-"
182
194
  return {
183
195
  ref.rsplit(delim, 1)[-1].split(".")[0]: BASE_URL + ref
@@ -186,6 +198,122 @@ def available_versions() -> dict[str, str]:
186
198
  }
187
199
 
188
200
 
201
+ @cache
202
+ def _available_test_adapter_releases() -> dict[str, str]:
203
+ """Get available releases from GitHub mm-test-adapters repository.
204
+
205
+ Returns a dict like:
206
+ {
207
+ '20250825': '74.20250825',
208
+ ...
209
+ }
210
+ """
211
+ github_api_url = f"https://api.github.com/repos/{TEST_ADAPTERS_REPO}/releases"
212
+
213
+ with urlopen(github_api_url) as resp:
214
+ releases = json.loads(resp.read().decode("utf-8"))
215
+
216
+ release_map = {}
217
+ for release in releases:
218
+ tag_name = release["tag_name"] # e.g., "74.20250825"
219
+ if "." in tag_name:
220
+ _, date_part = tag_name.split(".", 1) # Extract YYYYMMDD part
221
+ release_map[date_part] = tag_name
222
+
223
+ return release_map
224
+
225
+
226
+ def _get_platform_arch_string() -> str:
227
+ """Get platform and architecture string for GitHub releases."""
228
+ if PLATFORM == "Darwin":
229
+ arch = "ARM64" if MACH == "arm64" else "X64"
230
+ return f"macOS-{arch}"
231
+ elif PLATFORM == "Windows":
232
+ return "Windows-X64"
233
+ elif PLATFORM == "Linux":
234
+ return "Linux-X64"
235
+ else: # pragma: no cover
236
+ raise ValueError(f"Unsupported platform: {PLATFORM}")
237
+
238
+
239
+ def _test_dev_install(
240
+ dest: Path | str = USER_DATA_MM_PATH,
241
+ release: str = "latest",
242
+ ) -> None:
243
+ """Install just the test devices into dest.
244
+
245
+ From https://github.com/micro-manager/mm-test-adapters/releases/latest
246
+ (Releases on github are versioned as: DIV.YYYYMMDD)
247
+
248
+ If release is `latest-compatible`, it will install the latest compatible version
249
+ of the test devices for the current device interface version.
250
+
251
+ Parameters
252
+ ----------
253
+ dest : Path | str, optional
254
+ Where to install Micro-Manager, by default will install to a pymmcore-plus
255
+ folder in the user's data directory: `appdirs.user_data_dir()`.
256
+ release : str, optional
257
+ Which release to install, by default "latest-compatible". Should be a date
258
+ in the form YYYYMMDD, "latest" to install the latest nightly release, or
259
+ "latest-compatible" to install the latest nightly release that is
260
+ compatible with the device interface version of the current pymmcore version.
261
+ """
262
+ dest = Path(dest).expanduser().resolve()
263
+ dest.mkdir(parents=True, exist_ok=True)
264
+
265
+ # Get available GitHub releases
266
+ if not (github_releases := _available_test_adapter_releases()): # pragma: no cover
267
+ raise ValueError("No test device releases found on GitHub")
268
+
269
+ # Build download URL
270
+ filename = f"mm-test-adapters-{_get_platform_arch_string()}.zip"
271
+ base_url = f"https://github.com/{TEST_ADAPTERS_REPO}/releases"
272
+ if release == "latest-compatible":
273
+ # Find the latest release compatible with the current device interface
274
+ # this is easier with test devices because their version starts with the DIV
275
+ available = sorted(github_releases.values(), reverse=True)
276
+ for version in available:
277
+ if version.startswith(f"{PYMMCORE_DIV}."):
278
+ # Found a compatible version
279
+ download_url = f"{base_url}/download/{version}/{filename}"
280
+ tag_name = version
281
+ break
282
+ else: # pragma: no cover
283
+ raise ValueError(
284
+ f"No compatible releases found for device interface {PYMMCORE_DIV}. "
285
+ f"Found: {available}"
286
+ )
287
+ elif release == "latest":
288
+ download_url = f"{base_url}/latest/download/{filename}"
289
+ tag_name = sorted(github_releases.values(), reverse=True)[0]
290
+ else:
291
+ if release not in github_releases:
292
+ _available = ", ".join(sorted(github_releases.keys(), reverse=True))
293
+ raise ValueError(
294
+ f"Release {release!r} not found. Available releases: {_available}"
295
+ )
296
+ tag_name = github_releases[release]
297
+ download_url = f"{base_url}/download/{tag_name}/{filename}"
298
+
299
+ _dest = dest / f"Micro-Manager-{tag_name}"
300
+ with tempfile.TemporaryDirectory() as tmpdir:
301
+ # Download the zip file
302
+ zip_path = Path(tmpdir) / filename
303
+ _download_url(
304
+ url=download_url, output_path=zip_path, show_progress=progress is not None
305
+ )
306
+
307
+ # Extract the zip file
308
+ with zipfile.ZipFile(zip_path, "r") as zip_ref:
309
+ zip_ref.extractall(_dest)
310
+
311
+ # On macOS, remove quarantine attribute
312
+ if PLATFORM == "Darwin":
313
+ cmd = ["xattr", "-r", "-d", "com.apple.quarantine", str(_dest)]
314
+ subprocess.run(cmd, check=False) # Don't fail if xattr fails
315
+
316
+
189
317
  def _download_url(url: str, output_path: Path, show_progress: bool = True) -> None:
190
318
  """Download `url` to `output_path` with a nice progress bar."""
191
319
  ctx = ssl.create_default_context()
@@ -219,6 +347,7 @@ def install(
219
347
  dest: Path | str = USER_DATA_MM_PATH,
220
348
  release: str = "latest-compatible",
221
349
  log_msg: _MsgLogger = _pretty_print,
350
+ test_adapters: bool = False,
222
351
  ) -> None:
223
352
  """Install Micro-Manager to `dest`.
224
353
 
@@ -236,30 +365,32 @@ def install(
236
365
  Callback to log messages, must have signature:
237
366
  `def logger(text: str, color: str = "", emoji: str = ""): ...`
238
367
  May ignore color and emoji.
368
+ test_adapters : bool, optional
369
+ Whether to install test adapters, by default False.
239
370
  """
371
+ if test_adapters:
372
+ _test_dev_install(dest=dest, release=release)
373
+ return
374
+
240
375
  if PLATFORM not in ("Darwin", "Windows") or (
241
376
  PLATFORM == "Darwin" and MACH == "arm64"
242
- ): # pragma: no cover
377
+ ):
243
378
  log_msg(
244
- f"Unsupported platform/architecture: {PLATFORM}/{MACH}", "bold red", ":x:"
379
+ f"Unsupported platform/architecture for nightly build: {PLATFORM}/{MACH}\n"
380
+ " (Downloading just test adapters) ...",
381
+ "bold magenta",
382
+ ":exclamation:",
245
383
  )
246
- log_msg(
247
- "Consider building from source (mmcore build-dev).",
248
- "bold yellow",
249
- ":light_bulb:",
250
- )
251
- raise sys.exit(1)
384
+ _test_dev_install(dest=dest, release=release)
385
+ return
252
386
 
253
387
  if release == "latest-compatible":
254
- from pymmcore_plus import _pymmcore
255
-
256
- div = _pymmcore.version_info.device_interface
257
388
  # date when the device interface version FOLLOWING the version that this
258
389
  # pymmcore supports was released.
259
- next_div_date = INTERFACES.get(div + 1, None)
390
+ next_div_date = INTERFACES.get(PYMMCORE_DIV + 1, None)
260
391
 
261
392
  # if div is equal to the greatest known interface version, use latest
262
- if div == max(INTERFACES.keys()) or next_div_date is None:
393
+ if PYMMCORE_DIV == max(INTERFACES.keys()) or next_div_date is None:
263
394
  release = "latest"
264
395
  else: # pragma: no cover
265
396
  # otherwise, find the date of the release in available_versions() that
@@ -273,7 +404,7 @@ def install(
273
404
  # fallback to latest if no compatible versions found
274
405
  raise ValueError(
275
406
  "Unable to find a compatible release for device interface"
276
- f"{div} at {DOWNLOADS_URL} "
407
+ f"{PYMMCORE_DIV} at {DOWNLOADS_URL} "
277
408
  )
278
409
 
279
410
  if release == "latest":