pymmcore-plus 0.15.3__py3-none-any.whl → 0.16.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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."""
@@ -130,7 +130,10 @@ class UniMMCore(CMMCorePlus):
130
130
 
131
131
  def reset(self) -> None:
132
132
  with suppress(TimeoutError):
133
- self.waitForSystem()
133
+ self._pydevices.wait_for_device_type(
134
+ DeviceType.AnyType, self.getTimeoutMs(), parallel=False
135
+ )
136
+ super().waitForDeviceType(DeviceType.AnyType)
134
137
  self.unloadAllDevices()
135
138
  self._pycore.reset_current()
136
139
  super().reset()
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,12 +65,14 @@ 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] = {
75
+ 74: "20250815",
71
76
  73: "20250318",
72
77
  72: "20250318",
73
78
  71: "20221031",
@@ -172,11 +177,19 @@ def _mac_install(dmg: Path, dest: Path, log_msg: _MsgLogger) -> None:
172
177
 
173
178
  @cache
174
179
  def available_versions() -> dict[str, str]:
175
- """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
+ """
176
189
  with urlopen(DOWNLOADS_URL) as resp:
177
190
  html = resp.read().decode("utf-8")
178
191
 
179
- all_links = re.findall(r"href=\"([^\"]+)\"", html)
192
+ all_links = re.findall(r'href="([^"]+)"', html)
180
193
  delim = "_" if PLATFORM == "Windows" else "-"
181
194
  return {
182
195
  ref.rsplit(delim, 1)[-1].split(".")[0]: BASE_URL + ref
@@ -185,6 +198,122 @@ def available_versions() -> dict[str, str]:
185
198
  }
186
199
 
187
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
+
188
317
  def _download_url(url: str, output_path: Path, show_progress: bool = True) -> None:
189
318
  """Download `url` to `output_path` with a nice progress bar."""
190
319
  ctx = ssl.create_default_context()
@@ -218,6 +347,7 @@ def install(
218
347
  dest: Path | str = USER_DATA_MM_PATH,
219
348
  release: str = "latest-compatible",
220
349
  log_msg: _MsgLogger = _pretty_print,
350
+ test_adapters: bool = False,
221
351
  ) -> None:
222
352
  """Install Micro-Manager to `dest`.
223
353
 
@@ -235,30 +365,32 @@ def install(
235
365
  Callback to log messages, must have signature:
236
366
  `def logger(text: str, color: str = "", emoji: str = ""): ...`
237
367
  May ignore color and emoji.
368
+ test_adapters : bool, optional
369
+ Whether to install test adapters, by default False.
238
370
  """
371
+ if test_adapters:
372
+ _test_dev_install(dest=dest, release=release)
373
+ return
374
+
239
375
  if PLATFORM not in ("Darwin", "Windows") or (
240
376
  PLATFORM == "Darwin" and MACH == "arm64"
241
- ): # pragma: no cover
377
+ ):
242
378
  log_msg(
243
- 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:",
244
383
  )
245
- log_msg(
246
- "Consider building from source (mmcore build-dev).",
247
- "bold yellow",
248
- ":light_bulb:",
249
- )
250
- raise sys.exit(1)
384
+ _test_dev_install(dest=dest, release=release)
385
+ return
251
386
 
252
387
  if release == "latest-compatible":
253
- from pymmcore_plus import _pymmcore
254
-
255
- div = _pymmcore.version_info.device_interface
256
388
  # date when the device interface version FOLLOWING the version that this
257
389
  # pymmcore supports was released.
258
- next_div_date = INTERFACES.get(div + 1, None)
390
+ next_div_date = INTERFACES.get(PYMMCORE_DIV + 1, None)
259
391
 
260
392
  # if div is equal to the greatest known interface version, use latest
261
- if div == max(INTERFACES.keys()) or next_div_date is None:
393
+ if PYMMCORE_DIV == max(INTERFACES.keys()) or next_div_date is None:
262
394
  release = "latest"
263
395
  else: # pragma: no cover
264
396
  # otherwise, find the date of the release in available_versions() that
@@ -272,7 +404,7 @@ def install(
272
404
  # fallback to latest if no compatible versions found
273
405
  raise ValueError(
274
406
  "Unable to find a compatible release for device interface"
275
- f"{div} at {DOWNLOADS_URL} "
407
+ f"{PYMMCORE_DIV} at {DOWNLOADS_URL} "
276
408
  )
277
409
 
278
410
  if release == "latest":