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.
pymmcore_plus/__init__.py CHANGED
@@ -2,16 +2,19 @@
2
2
 
3
3
  import logging
4
4
  from importlib.metadata import PackageNotFoundError, version
5
+ from typing import TYPE_CHECKING, Any
5
6
 
6
7
  try:
7
8
  __version__ = version("pymmcore-plus")
8
9
  except PackageNotFoundError: # pragma: no cover
9
10
  __version__ = "unknown"
10
11
 
12
+ if TYPE_CHECKING:
13
+ from ._ipy_completion import install_pymmcore_ipy_completion
11
14
 
12
15
  from ._accumulator import AbstractChangeAccumulator
16
+ from ._discovery import find_micromanager, use_micromanager
13
17
  from ._logger import configure_logging
14
- from ._util import find_micromanager, use_micromanager
15
18
  from .core import (
16
19
  ActionType,
17
20
  CFGCommand,
@@ -63,10 +66,26 @@ __all__ = [
63
66
  "__version__",
64
67
  "configure_logging",
65
68
  "find_micromanager",
69
+ "install_pymmcore_ipy_completion",
66
70
  "use_micromanager",
67
71
  ]
68
72
 
69
73
 
74
+ def __getattr__(name: str) -> Any:
75
+ """Lazy import for compatibility with pymmcore."""
76
+ if name == "install_pymmcore_ipy_completion":
77
+ try:
78
+ from ._ipy_completion import install_pymmcore_ipy_completion
79
+ except ImportError as e: # pragma: no cover
80
+ raise ImportError(
81
+ f"Error importing IPython completion for pymmcore-plus: {e}"
82
+ ) from None
83
+
84
+ return install_pymmcore_ipy_completion
85
+ raise AttributeError(f"module '{__name__}' has no attribute '{name}'. ")
86
+
87
+
88
+ # install the IPython completer when imported, if running in an IPython environment
70
89
  def _install_ipy_completer() -> None: # pragma: no cover
71
90
  import os
72
91
  import sys
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import abc
6
6
  import sys
7
+ import weakref
7
8
  from abc import ABC, abstractmethod
8
9
  from collections.abc import Sequence
9
10
  from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, TypeVar
@@ -166,9 +167,10 @@ class DeviceAccumulator(abc.ABC, Generic[DT]):
166
167
  mmcore: CMMCorePlus | None = None,
167
168
  **kwargs: Any,
168
169
  ) -> None:
169
- self._mmcore = mmcore or CMMCorePlus.instance()
170
+ core = mmcore or CMMCorePlus.instance()
171
+ self._mmcore_ref = weakref.ref(core)
170
172
  dev_type = self._device_type()
171
- if not self._mmcore.getDeviceType(device_label) == dev_type: # pragma: no cover
173
+ if not core.getDeviceType(device_label) == dev_type: # pragma: no cover
172
174
  raise ValueError(
173
175
  f"Cannot create {self.__class__.__name__}. "
174
176
  f"Device {device_label!r} is not a {dev_type.name}. "
@@ -177,6 +179,16 @@ class DeviceAccumulator(abc.ABC, Generic[DT]):
177
179
  self._device_label = device_label
178
180
  super().__init__(**kwargs)
179
181
 
182
+ @property
183
+ def _mmcore(self) -> CMMCorePlus:
184
+ """Get the CMMCorePlus instance for this accumulator."""
185
+ if (mmcore := self._mmcore_ref()) is None: # pragma: no cover
186
+ raise RuntimeError(
187
+ f"The CMMCorePlus instance for this {self.__class__.__name__!r} has "
188
+ "been garbage collected."
189
+ )
190
+ return mmcore
191
+
180
192
  def _is_busy(self) -> bool:
181
193
  return self._mmcore.deviceBusy(self._device_label)
182
194
 
@@ -204,18 +216,24 @@ class DeviceAccumulator(abc.ABC, Generic[DT]):
204
216
  device_type = mmcore.getDeviceType(device)
205
217
  if cache_key not in DeviceAccumulator._CACHE:
206
218
  if device_type == cls._device_type():
207
- cls._CACHE[cache_key] = cls(device_label=device, mmcore=mmcore)
219
+ DeviceAccumulator._CACHE[cache_key] = cls(
220
+ device_label=device, mmcore=mmcore
221
+ )
208
222
  else:
209
223
  for sub in cls.__subclasses__():
210
224
  if sub._device_type() == device_type: # noqa: SLF001
211
- cls._CACHE[cache_key] = sub(device_label=device, mmcore=mmcore)
225
+ DeviceAccumulator._CACHE[cache_key] = sub(
226
+ device_label=device, mmcore=mmcore
227
+ )
212
228
  break
213
229
  else:
214
230
  raise ValueError(
215
231
  "No matching DeviceTypeMixin subclass found for device type "
216
232
  f"{device_type.name} (for device {device!r})."
217
233
  )
218
- obj = cls._CACHE[cache_key]
234
+
235
+ weakref.finalize(mmcore, DeviceAccumulator._CACHE.pop, cache_key, None)
236
+ obj = DeviceAccumulator._CACHE[cache_key]
219
237
  if not isinstance(obj, cls):
220
238
  raise TypeError(
221
239
  f"Cannot create {cls.__name__} for {device!r}. "
pymmcore_plus/_cli.py CHANGED
@@ -7,9 +7,9 @@ import sys
7
7
  import time
8
8
  from contextlib import suppress
9
9
  from pathlib import Path
10
+ from platform import system
10
11
  from typing import Optional, Union, cast
11
12
 
12
- from pymmcore_plus._util import get_device_interface_version
13
13
  from pymmcore_plus.core._device import Device
14
14
  from pymmcore_plus.core._mmcore_plus import CMMCorePlus
15
15
 
@@ -26,9 +26,9 @@ import pymmcore_plus
26
26
  from pymmcore_plus._build import DEFAULT_PACKAGES, build
27
27
  from pymmcore_plus._logger import configure_logging
28
28
  from pymmcore_plus._util import USER_DATA_MM_PATH
29
- from pymmcore_plus.install import PLATFORM
30
29
 
31
30
  app = typer.Typer(name="mmcore", no_args_is_help=True)
31
+ PLATFORM = system()
32
32
 
33
33
 
34
34
  def _show_version_and_exit(value: bool) -> None:
@@ -103,32 +103,44 @@ def clean(
103
103
 
104
104
  @app.command(name="list")
105
105
  def _list() -> None:
106
- """Show all Micro-Manager installs downloaded by pymmcore-plus."""
106
+ """Show all discovered Micro-Manager installations."""
107
+ from pymmcore_plus import _discovery
108
+
107
109
  configure_logging(stderr_level="CRITICAL")
108
- found: dict[Path, list[str]] = {}
110
+ found: dict[Path, list[tuple[str, _discovery.DiscoveredMM]]] = {}
109
111
  with suppress(Exception):
110
- for p in pymmcore_plus.find_micromanager(return_first=False):
111
- pth = Path(p)
112
- found.setdefault(pth.parent, []).append(pth.name)
112
+ for dm in _discovery.discover_mm():
113
+ pth = Path(dm.path)
114
+ found.setdefault(pth.parent, []).append((pth.name, dm))
115
+
116
+ active_mm = _discovery.find_micromanager(return_first=True)
117
+ required_div = _discovery.PYMMCORE_DIV
113
118
 
114
119
  if found:
115
- first = True
120
+ print(f"[magenta]Required pymmcore device interface version: {required_div}")
116
121
  for parent, items in found.items():
117
- print(f":file_folder:[bold green] {parent}")
118
- for item in items:
119
- version = ""
120
- for _lib in (parent / item).glob("*_dal_*"):
121
- with suppress(Exception):
122
- div = get_device_interface_version(_lib)
123
- version = f" (Dev. Interface {div})"
124
- break
125
- bullet = " [bold yellow]*" if first else " •"
126
- using = " [bold blue](active)" if first else ""
127
- print(f"{bullet} [cyan]{item}{version}{using}")
128
- first = False
122
+ print(f"\n:file_folder:[bold green] {parent}")
123
+ for item_name, dm in items:
124
+ version = f" (DIV {dm.device_interface})" if dm.device_interface else ""
125
+ is_active = str(dm.path) == active_mm
126
+ is_compatible = dm.div_compatible
127
+
128
+ # Choose bullet and status
129
+ bullet = " •"
130
+ status = ""
131
+ if is_active:
132
+ bullet = " [bold yellow]"
133
+ if is_compatible:
134
+ status = " [bold blue](active)[/bold blue]"
135
+ else:
136
+ status = " [bold red](active, incompatible!)[/bold red]"
137
+ else:
138
+ status = " [red](incompatible)[/red]"
139
+
140
+ print(f"{bullet} [cyan]{item_name}{version}{status}")
129
141
  else:
130
- print(":x: [bold red]There are no pymmcore-plus Micro-Manager files.")
131
- print("[magenta]run `mmcore install` to install a version of Micro-Manager")
142
+ print(":x: [bold red]No Micro-Manager installations found.")
143
+ print("[magenta]Run `mmcore install` to install a version of Micro-Manager")
132
144
 
133
145
 
134
146
  @app.command()
@@ -174,18 +186,24 @@ def install(
174
186
  help="Do not use rich output. Useful for scripting.",
175
187
  show_default=False,
176
188
  ),
189
+ test_adapters: bool = typer.Option(
190
+ False,
191
+ "--test-adapters",
192
+ help="Install only test adapters (e.g. DemoCamera and others for testing).",
193
+ ),
177
194
  ) -> None:
178
195
  """Install Micro-Manager Device adapters from <https://download.micro-manager.org>."""
179
196
  import pymmcore_plus.install
180
197
 
198
+ kwargs = {}
181
199
  if plain_output:
182
200
 
183
201
  def _log_msg(text: str, color: str = "", emoji: str = "") -> None:
184
202
  print(text)
185
203
 
186
- pymmcore_plus.install.install(dest, release, log_msg=_log_msg)
187
- else:
188
- pymmcore_plus.install.install(dest, release)
204
+ kwargs["log_msg"] = _log_msg
205
+
206
+ pymmcore_plus.install.install(dest, release, test_adapters=test_adapters, **kwargs)
189
207
 
190
208
 
191
209
  @app.command()
@@ -414,7 +432,7 @@ def use(
414
432
  ),
415
433
  ) -> None:
416
434
  """Change the currently used Micro-manager version/path."""
417
- from pymmcore_plus._util import use_micromanager
435
+ from pymmcore_plus._discovery import use_micromanager
418
436
 
419
437
  _pth = Path(pattern)
420
438
  if _pth.exists():
@@ -0,0 +1,344 @@
1
+ from __future__ import annotations
2
+
3
+ import ctypes
4
+ import os
5
+ import re
6
+ import sys
7
+ import warnings
8
+ from contextlib import suppress
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, overload
12
+
13
+ from platformdirs import user_data_dir
14
+
15
+ from . import _pymmcore
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Iterator
19
+ from typing import Literal
20
+
21
+
22
+ __all__ = ["discover_mm", "find_micromanager", "use_micromanager"]
23
+
24
+ APP_NAME = "pymmcore-plus"
25
+ USER_DATA_DIR = Path(user_data_dir(appname=APP_NAME))
26
+ USER_DATA_MM_PATH = USER_DATA_DIR / "mm"
27
+ CURRENT_MM_PATH = USER_DATA_MM_PATH / ".current_mm"
28
+ PYMMCORE_PLUS_PATH = Path(__file__).parent.parent
29
+
30
+
31
+ PYMMCORE_DIV = _pymmcore.version_info.device_interface
32
+
33
+
34
+ @dataclass
35
+ class DiscoveredMM:
36
+ """A discovered path with micro-manager devices."""
37
+
38
+ path: Path
39
+ env_var: str | None = None
40
+ device_interface: int | None = None
41
+ device_paths: set[Path] | None = field(default=None, repr=False)
42
+ num_devices: int | None = None
43
+ div_compatible: bool = False
44
+ is_current: bool = False
45
+
46
+ def __post_init__(self) -> None:
47
+ # normalize early to avoid cache key duplication from symlinks or relative paths
48
+ try:
49
+ self.path = self.path.resolve()
50
+ except Exception:
51
+ # be defensive if resolution fails on odd platforms
52
+ self.path = Path(self.path)
53
+
54
+ def _populate_info(self, force: bool = False) -> None:
55
+ # one time on-demand population of additional metadata
56
+ if self.device_interface is None or force:
57
+ self.device_interface = get_first_device_interface_version(self.path)
58
+ self.div_compatible = self.device_interface == PYMMCORE_DIV
59
+ self.num_devices = len(set(_iter_device_paths(self.path)))
60
+
61
+ def merge_from(self, other: DiscoveredMM) -> None:
62
+ # preserve flags from whichever source had them
63
+ if other.is_current:
64
+ self.is_current = True
65
+ if other.env_var and not self.env_var:
66
+ self.env_var = other.env_var
67
+ if other.device_interface and not self.device_interface:
68
+ self.device_interface = other.device_interface
69
+ self.div_compatible = other.div_compatible
70
+ self.num_devices = other.num_devices
71
+
72
+
73
+ def _env_var_mm() -> Iterator[DiscoveredMM]:
74
+ """Discover Micro-Manager installations from the MICROMANAGER_PATH env var."""
75
+ env_path = os.getenv("MICROMANAGER_PATH")
76
+ if env_path and os.path.isdir(env_path):
77
+ yield DiscoveredMM(path=Path(env_path), env_var="MICROMANAGER_PATH")
78
+
79
+
80
+ def _current_mm() -> Iterator[DiscoveredMM]:
81
+ """Discover Micro-Manager installation at the CURRENT_MM_PATH file."""
82
+ if CURRENT_MM_PATH.exists():
83
+ path = Path(CURRENT_MM_PATH.read_text().strip())
84
+ if path.is_dir():
85
+ yield DiscoveredMM(path=path, is_current=True)
86
+ else:
87
+ from ._logger import logger
88
+
89
+ logger.warning(
90
+ f"User-selected micromanager path {path} is not a directory, clearing."
91
+ )
92
+ CURRENT_MM_PATH.unlink(missing_ok=True)
93
+
94
+
95
+ def _mmcore_installed_mm(glob: str = "Micro-Manager*") -> Iterator[DiscoveredMM]:
96
+ """Discover Micro-Manager installations in the pymmcore-plus user data directory."""
97
+ for path in sorted(USER_DATA_MM_PATH.glob(glob), reverse=True):
98
+ if path.is_dir():
99
+ yield DiscoveredMM(path=path)
100
+
101
+
102
+ def _mm_test_adapter_mm() -> Iterator[DiscoveredMM]:
103
+ """Discover Micro-Manager from the mm-test-adapters package."""
104
+ with suppress(ImportError, AttributeError):
105
+ import mm_test_adapters
106
+
107
+ path = Path(mm_test_adapters.device_adapter_path())
108
+ if path.is_dir():
109
+ yield DiscoveredMM(path=path)
110
+
111
+
112
+ def _application_installs() -> Iterator[DiscoveredMM]:
113
+ """Discover official Micro-Manager installations in the application directory."""
114
+ applications = {
115
+ "darwin": Path("/Applications/"),
116
+ "win32": Path("C:/Program Files/"),
117
+ "linux": Path("/usr/local/lib"),
118
+ }
119
+ if app_path := applications.get(sys.platform):
120
+ for pth in app_path.glob("[m,M]icro-[m,M]anager*"):
121
+ yield DiscoveredMM(path=pth)
122
+
123
+
124
+ def _iter_mm_paths() -> Iterator[DiscoveredMM]:
125
+ """Iterate over all discovered Micro-Manager paths.
126
+
127
+ Order here influences the return value of find_micromanager() when
128
+ `return_first` is True.
129
+ """
130
+ yield from _env_var_mm()
131
+ yield from _current_mm()
132
+ yield from _mmcore_installed_mm()
133
+ yield from _mm_test_adapter_mm()
134
+ yield from _application_installs()
135
+
136
+
137
+ _DISCOVERED_MMS: dict[Path, DiscoveredMM] = {}
138
+
139
+
140
+ def discover_mm() -> Iterator[DiscoveredMM]:
141
+ """Discover Micro-Manager installations with caching and dedup by path."""
142
+ yielded: set[Path] = set()
143
+
144
+ for candidate in _iter_mm_paths():
145
+ key = candidate.path
146
+ existing = _DISCOVERED_MMS.get(key)
147
+
148
+ if existing is None:
149
+ candidate._populate_info() # noqa: SLF001
150
+ # only cache and consider entries that look like real installs
151
+ # If the discovery came from an explicit environment variable, keep
152
+ # the entry even if it doesn't currently contain device adapter
153
+ # libraries. This allows users (and tests) to point to a path that
154
+ # may be populated later or is intentionally empty.
155
+ if candidate.device_interface is None and candidate.env_var is None:
156
+ continue
157
+ _DISCOVERED_MMS[key] = existing = candidate
158
+ else:
159
+ # enrich cached entry with flags learned from other discovery sources
160
+ existing.merge_from(candidate)
161
+
162
+ if key not in yielded: # never double yield
163
+ yielded.add(key)
164
+ yield existing
165
+
166
+
167
+ @overload
168
+ def find_micromanager(return_first: Literal[True] = ...) -> str | None: ...
169
+ @overload
170
+ def find_micromanager(return_first: Literal[False]) -> list[str]: ...
171
+ def find_micromanager(return_first: bool = True) -> str | None | list[str]:
172
+ r"""Locate a Micro-Manager folder (for device adapters).
173
+
174
+ In order, this will look for:
175
+
176
+ 1. An environment variable named `MICROMANAGER_PATH`
177
+ 2. A path stored in the `CURRENT_MM_PATH` file (set by `use_micromanager`).
178
+ 3. A `Micro-Manager*` folder in the `pymmcore-plus` user data directory
179
+ (this is the default install location when running `mmcore install`)
180
+
181
+ - **Windows**: C:\Users\\[user]\AppData\Local\pymmcore-plus\pymmcore-plus
182
+ - **macOS**: ~/Library/Application Support/pymmcore-plus
183
+ - **Linux**: ~/.local/share/pymmcore-plus
184
+
185
+ 4. A `Micro-Manager*` folder in the `pymmcore_plus` package directory (this is the
186
+ default install location when running `python -m pymmcore_plus.install`)
187
+ 5. The default micro-manager install location:
188
+
189
+ - **Windows**: `C:/Program Files/`
190
+ - **macOS**: `/Applications`
191
+ - **Linux**: `/usr/local/lib`
192
+
193
+ !!! note
194
+
195
+ This function is used by [`pymmcore_plus.CMMCorePlus`][] to locate the
196
+ micro-manager device adapters. By default, the output of this function
197
+ is passed to
198
+ [`setDeviceAdapterSearchPaths`][pymmcore_plus.CMMCorePlus.setDeviceAdapterSearchPaths]
199
+ when creating a new `CMMCorePlus` instance.
200
+
201
+ Parameters
202
+ ----------
203
+ return_first : bool, optional
204
+ If True (default), return the first found path. If False, return a list of
205
+ all found paths.
206
+ """
207
+ from ._logger import logger
208
+
209
+ for discovered_mm in discover_mm():
210
+ if return_first:
211
+ # If the path was explicitly provided via the MICROMANAGER_PATH
212
+ # environment variable, prefer it even if it doesn't currently
213
+ # contain device adapter libraries. This allows users (and tests)
214
+ # to point to a path that will be used as-is.
215
+ if discovered_mm.env_var is not None:
216
+ logger.debug(
217
+ "Using Micro-Manager path from %s: %s",
218
+ discovered_mm.env_var,
219
+ discovered_mm.path,
220
+ )
221
+ return str(discovered_mm.path)
222
+
223
+ if discovered_mm.div_compatible:
224
+ logger.debug(
225
+ "Using Micro-Manager path: %s (device interface %s)",
226
+ discovered_mm.path,
227
+ discovered_mm.device_interface,
228
+ )
229
+ return str(discovered_mm.path)
230
+ elif discovered_mm.is_current:
231
+ warnings.warn(
232
+ f"The current user-selected version of Micro-Manager at "
233
+ f"{discovered_mm.path} has an incompatible device interface "
234
+ f"({discovered_mm.device_interface}). The installed version "
235
+ f"of pymmcore requires: {PYMMCORE_DIV}). Clearing.",
236
+ stacklevel=2,
237
+ )
238
+ CURRENT_MM_PATH.unlink(missing_ok=True)
239
+ object.__setattr__(discovered_mm, "is_current", False)
240
+
241
+ if return_first:
242
+ # if we got here it means no compatible version was found
243
+
244
+ others = "\n".join(
245
+ [str(d.path) for d in _DISCOVERED_MMS.values() if not d.div_compatible]
246
+ )
247
+
248
+ logger.error(
249
+ f"Could not find a compatible Micro-Manager installation for the "
250
+ f"device interface required by pymmcore {PYMMCORE_DIV}.\n\n"
251
+ f"Discovered installations:\n"
252
+ f"{others}\n"
253
+ f"Please run 'mmcore install'."
254
+ )
255
+ return None
256
+
257
+ return [str(d.path) for d in _DISCOVERED_MMS.values()]
258
+
259
+
260
+ def _match_mm_pattern(pattern: str | re.Pattern[str]) -> Path | None:
261
+ """Locate an existing Micro-Manager folder using a regex pattern."""
262
+ for _path in find_micromanager(return_first=False):
263
+ if not isinstance(pattern, re.Pattern):
264
+ pattern = str(pattern)
265
+ if re.search(pattern, _path) is not None:
266
+ return Path(_path)
267
+ return None
268
+
269
+
270
+ def use_micromanager(
271
+ path: str | Path | None = None, pattern: str | re.Pattern[str] | None = None
272
+ ) -> Path | None:
273
+ """Set the preferred Micro-Manager path.
274
+
275
+ This sets the preferred micromanager path, and persists across sessions.
276
+ This path takes precedence over everything *except* the `MICROMANAGER_PATH`
277
+ environment variable.
278
+
279
+ Parameters
280
+ ----------
281
+ path : str | Path | None
282
+ Path to an existing directory. This directory should contain micro-manager
283
+ device adapters. If `None`, the path will be determined using `pattern`.
284
+ pattern : str Pattern | | None
285
+ A regex pattern to match against the micromanager paths found by
286
+ `find_micromanager`. If no match is found, a `FileNotFoundError` will be raised.
287
+ """
288
+ if path is None:
289
+ if pattern is None: # pragma: no cover
290
+ raise ValueError("One of 'path' or 'pattern' must be provided")
291
+ if (path := _match_mm_pattern(pattern)) is None:
292
+ options = "\n".join(find_micromanager(return_first=False))
293
+ raise FileNotFoundError(
294
+ f"No micromanager path found matching: {pattern!r}. Options:\n{options}"
295
+ )
296
+
297
+ if not isinstance(path, Path): # pragma: no cover
298
+ path = Path(path)
299
+
300
+ path = path.expanduser().resolve()
301
+ if not path.is_dir(): # pragma: no cover
302
+ if not path.exists():
303
+ raise FileNotFoundError(f"Path not found: {path!r}")
304
+ raise NotADirectoryError(f"Not a directory: {path!r}")
305
+
306
+ USER_DATA_MM_PATH.mkdir(parents=True, exist_ok=True)
307
+ CURRENT_MM_PATH.write_text(str(path))
308
+ return path
309
+
310
+
311
+ def _iter_device_paths(folder: Path) -> Iterator[Path]:
312
+ """Iterate over device shared library paths in `folder`."""
313
+ valid_extensions = {".dll", ".0", "", ".so", ".dylib"}
314
+ for lib_path in folder.glob("*mmgr_dal*"):
315
+ if lib_path.suffix in valid_extensions:
316
+ yield lib_path
317
+
318
+
319
+ def get_first_device_interface_version(folder: Path | str) -> int | None:
320
+ for dev_path in _iter_device_paths(Path(folder)):
321
+ try:
322
+ return get_device_interface_version(dev_path)
323
+ except Exception:
324
+ continue
325
+ return None
326
+
327
+
328
+ def get_device_interface_version(lib_path: str | Path) -> int:
329
+ """Return the device interface version from the given library path."""
330
+ if sys.platform.startswith("win"):
331
+ lib = ctypes.WinDLL(str(lib_path))
332
+ else:
333
+ lib = ctypes.CDLL(str(lib_path))
334
+
335
+ try:
336
+ func = lib.GetDeviceInterfaceVersion
337
+ except AttributeError:
338
+ raise RuntimeError(
339
+ f"Function 'GetDeviceInterfaceVersion' not found in {lib_path}"
340
+ ) from None
341
+
342
+ func.restype = ctypes.c_long
343
+ func.argtypes = []
344
+ return func() # type: ignore[no-any-return]
pymmcore_plus/_logger.py CHANGED
@@ -24,7 +24,7 @@ if "PYTEST_RUNNING" in os.environ:
24
24
  elif PYMM_LOG_FILE not in ("", "0", "false", "no", "none"):
25
25
  LOG_FILE = Path(PYMM_LOG_FILE).expanduser().resolve()
26
26
  else:
27
- from ._util import USER_DATA_DIR
27
+ from ._discovery import USER_DATA_DIR
28
28
 
29
29
  LOG_FILE = USER_DATA_DIR / "logs" / "pymmcore-plus.log"
30
30