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.
- pymmcore_plus/__init__.py +20 -1
- pymmcore_plus/_accumulator.py +23 -5
- pymmcore_plus/_cli.py +44 -26
- pymmcore_plus/_discovery.py +344 -0
- pymmcore_plus/_ipy_completion.py +1 -1
- pymmcore_plus/_logger.py +3 -3
- pymmcore_plus/_util.py +9 -245
- pymmcore_plus/core/_device.py +57 -13
- pymmcore_plus/core/_mmcore_plus.py +20 -23
- pymmcore_plus/core/_property.py +35 -29
- pymmcore_plus/core/_sequencing.py +2 -0
- pymmcore_plus/core/events/_device_signal_view.py +8 -1
- pymmcore_plus/experimental/simulate/__init__.py +88 -0
- pymmcore_plus/experimental/simulate/_objects.py +670 -0
- pymmcore_plus/experimental/simulate/_render.py +510 -0
- pymmcore_plus/experimental/simulate/_sample.py +156 -0
- pymmcore_plus/experimental/unicore/__init__.py +2 -0
- pymmcore_plus/experimental/unicore/_device_manager.py +46 -13
- pymmcore_plus/experimental/unicore/core/_config.py +706 -0
- pymmcore_plus/experimental/unicore/core/_unicore.py +834 -18
- pymmcore_plus/experimental/unicore/devices/_device_base.py +13 -0
- pymmcore_plus/experimental/unicore/devices/_hub.py +50 -0
- pymmcore_plus/experimental/unicore/devices/_stage.py +46 -1
- pymmcore_plus/experimental/unicore/devices/_state.py +6 -0
- pymmcore_plus/install.py +149 -18
- pymmcore_plus/mda/_engine.py +268 -73
- pymmcore_plus/mda/handlers/_5d_writer_base.py +16 -5
- pymmcore_plus/mda/handlers/_tensorstore_handler.py +7 -1
- pymmcore_plus/metadata/_ome.py +553 -0
- pymmcore_plus/metadata/functions.py +2 -1
- {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/METADATA +7 -4
- {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/RECORD +35 -27
- {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/WHEEL +1 -1
- {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/entry_points.txt +0 -0
- {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
|
|
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
|
-
):
|
|
377
|
+
):
|
|
243
378
|
log_msg(
|
|
244
|
-
f"Unsupported platform/architecture: {PLATFORM}/{MACH}"
|
|
379
|
+
f"Unsupported platform/architecture for nightly build: {PLATFORM}/{MACH}\n"
|
|
380
|
+
" (Downloading just test adapters) ...",
|
|
381
|
+
"bold magenta",
|
|
382
|
+
":exclamation:",
|
|
245
383
|
)
|
|
246
|
-
|
|
247
|
-
|
|
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(
|
|
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
|
|
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"{
|
|
407
|
+
f"{PYMMCORE_DIV} at {DOWNLOADS_URL} "
|
|
277
408
|
)
|
|
278
409
|
|
|
279
410
|
if release == "latest":
|