pymmcore-plus 0.9.3__py3-none-any.whl → 0.13.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 (68) hide show
  1. pymmcore_plus/__init__.py +7 -4
  2. pymmcore_plus/_benchmark.py +203 -0
  3. pymmcore_plus/_build.py +6 -1
  4. pymmcore_plus/_cli.py +131 -31
  5. pymmcore_plus/_logger.py +19 -10
  6. pymmcore_plus/_pymmcore.py +12 -0
  7. pymmcore_plus/_util.py +139 -32
  8. pymmcore_plus/core/__init__.py +5 -0
  9. pymmcore_plus/core/_config.py +6 -4
  10. pymmcore_plus/core/_config_group.py +4 -3
  11. pymmcore_plus/core/_constants.py +135 -10
  12. pymmcore_plus/core/_device.py +4 -4
  13. pymmcore_plus/core/_metadata.py +3 -3
  14. pymmcore_plus/core/_mmcore_plus.py +254 -170
  15. pymmcore_plus/core/_property.py +6 -6
  16. pymmcore_plus/core/_sequencing.py +370 -233
  17. pymmcore_plus/core/events/__init__.py +6 -6
  18. pymmcore_plus/core/events/_device_signal_view.py +8 -6
  19. pymmcore_plus/core/events/_norm_slot.py +2 -4
  20. pymmcore_plus/core/events/_prop_event_mixin.py +7 -4
  21. pymmcore_plus/core/events/_protocol.py +5 -2
  22. pymmcore_plus/core/events/_psygnal.py +2 -2
  23. pymmcore_plus/experimental/__init__.py +0 -0
  24. pymmcore_plus/experimental/unicore/__init__.py +14 -0
  25. pymmcore_plus/experimental/unicore/_device_manager.py +173 -0
  26. pymmcore_plus/experimental/unicore/_proxy.py +127 -0
  27. pymmcore_plus/experimental/unicore/_unicore.py +703 -0
  28. pymmcore_plus/experimental/unicore/devices/__init__.py +0 -0
  29. pymmcore_plus/experimental/unicore/devices/_device.py +269 -0
  30. pymmcore_plus/experimental/unicore/devices/_properties.py +400 -0
  31. pymmcore_plus/experimental/unicore/devices/_stage.py +221 -0
  32. pymmcore_plus/install.py +16 -11
  33. pymmcore_plus/mda/__init__.py +1 -1
  34. pymmcore_plus/mda/_engine.py +320 -148
  35. pymmcore_plus/mda/_protocol.py +6 -4
  36. pymmcore_plus/mda/_runner.py +62 -51
  37. pymmcore_plus/mda/_thread_relay.py +5 -3
  38. pymmcore_plus/mda/events/__init__.py +2 -2
  39. pymmcore_plus/mda/events/_protocol.py +10 -2
  40. pymmcore_plus/mda/events/_psygnal.py +2 -2
  41. pymmcore_plus/mda/handlers/_5d_writer_base.py +106 -15
  42. pymmcore_plus/mda/handlers/__init__.py +7 -1
  43. pymmcore_plus/mda/handlers/_img_sequence_writer.py +11 -6
  44. pymmcore_plus/mda/handlers/_ome_tiff_writer.py +8 -4
  45. pymmcore_plus/mda/handlers/_ome_zarr_writer.py +82 -9
  46. pymmcore_plus/mda/handlers/_tensorstore_handler.py +374 -0
  47. pymmcore_plus/mda/handlers/_util.py +1 -1
  48. pymmcore_plus/metadata/__init__.py +36 -0
  49. pymmcore_plus/metadata/functions.py +353 -0
  50. pymmcore_plus/metadata/schema.py +472 -0
  51. pymmcore_plus/metadata/serialize.py +120 -0
  52. pymmcore_plus/mocks.py +51 -0
  53. pymmcore_plus/model/_config_file.py +5 -6
  54. pymmcore_plus/model/_config_group.py +29 -2
  55. pymmcore_plus/model/_core_device.py +12 -1
  56. pymmcore_plus/model/_core_link.py +2 -1
  57. pymmcore_plus/model/_device.py +39 -8
  58. pymmcore_plus/model/_microscope.py +39 -3
  59. pymmcore_plus/model/_pixel_size_config.py +27 -4
  60. pymmcore_plus/model/_property.py +13 -3
  61. pymmcore_plus/seq_tester.py +1 -1
  62. {pymmcore_plus-0.9.3.dist-info → pymmcore_plus-0.13.0.dist-info}/METADATA +22 -12
  63. pymmcore_plus-0.13.0.dist-info/RECORD +71 -0
  64. {pymmcore_plus-0.9.3.dist-info → pymmcore_plus-0.13.0.dist-info}/WHEEL +1 -1
  65. pymmcore_plus/core/_state.py +0 -244
  66. pymmcore_plus-0.9.3.dist-info/RECORD +0 -55
  67. {pymmcore_plus-0.9.3.dist-info → pymmcore_plus-0.13.0.dist-info}/entry_points.txt +0 -0
  68. {pymmcore_plus-0.9.3.dist-info → pymmcore_plus-0.13.0.dist-info}/licenses/LICENSE +0 -0
pymmcore_plus/__init__.py CHANGED
@@ -9,7 +9,7 @@ except PackageNotFoundError: # pragma: no cover
9
9
 
10
10
 
11
11
  from ._logger import configure_logging
12
- from ._util import find_micromanager
12
+ from ._util import find_micromanager, use_micromanager
13
13
  from .core import (
14
14
  CFGCommand,
15
15
  CFGGroup,
@@ -26,6 +26,7 @@ from .core import (
26
26
  FocusDirection,
27
27
  Keyword,
28
28
  Metadata,
29
+ PixelFormat,
29
30
  PortType,
30
31
  PropertyType,
31
32
  )
@@ -33,7 +34,6 @@ from .core.events import CMMCoreSignaler, PCoreSignaler
33
34
  from .mda._runner import GeneratorMDASequence
34
35
 
35
36
  __all__ = [
36
- "__version__",
37
37
  "ActionType",
38
38
  "CFGCommand",
39
39
  "CFGGroup",
@@ -41,7 +41,6 @@ __all__ = [
41
41
  "CMMCoreSignaler",
42
42
  "ConfigGroup",
43
43
  "Configuration",
44
- "configure_logging",
45
44
  "Device",
46
45
  "DeviceAdapter",
47
46
  "DeviceDetectionStatus",
@@ -49,12 +48,16 @@ __all__ = [
49
48
  "DeviceNotification",
50
49
  "DeviceProperty",
51
50
  "DeviceType",
52
- "find_micromanager",
53
51
  "FocusDirection",
54
52
  "GeneratorMDASequence",
55
53
  "Keyword",
56
54
  "Metadata",
57
55
  "PCoreSignaler",
56
+ "PixelFormat",
58
57
  "PortType",
59
58
  "PropertyType",
59
+ "__version__",
60
+ "configure_logging",
61
+ "find_micromanager",
62
+ "use_micromanager",
60
63
  ]
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+
3
+ import timeit
4
+ import warnings
5
+ from typing import TYPE_CHECKING
6
+
7
+ from pymmcore_plus import CMMCorePlus, DeviceType
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Iterable, Iterator, Sequence
11
+
12
+ from pymmcore_plus.core._device import Device
13
+
14
+
15
+ class Benchmark:
16
+ device_type = DeviceType.Camera
17
+
18
+ def __init__(self, core: CMMCorePlus, label: str = "") -> None:
19
+ self.core = core
20
+ self.label = label
21
+
22
+ def setup(self) -> None:
23
+ pass
24
+
25
+ def device(self) -> Device | None:
26
+ if self.label is not None:
27
+ return self.core.getDeviceObject(self.label)
28
+ return None
29
+
30
+ def run(self, number: int) -> Iterator[tuple[str, float | str]]:
31
+ # get methods in the order of definition, in reverse MRO order
32
+
33
+ try:
34
+ self.setup()
35
+ except Exception as e: # pragma: no cover
36
+ warnings.warn(
37
+ f"Setup failed on device {self.label!r}: {e}",
38
+ RuntimeWarning,
39
+ stacklevel=2,
40
+ )
41
+ return
42
+
43
+ methods: list[str] = []
44
+ for base in reversed(type(self).mro()):
45
+ methods.extend(m for m in base.__dict__ if m.startswith("bench_"))
46
+
47
+ for method_name in methods:
48
+ try:
49
+ t = timeit.timeit(getattr(self, method_name), number=number)
50
+ result: float | str = round(1000 * t / number, 3)
51
+ except Exception as e:
52
+ result = str(e)
53
+ yield method_name[6:], result
54
+
55
+
56
+ class CoreBenchmark(Benchmark):
57
+ device_type = DeviceType.Core
58
+
59
+ def bench_getDeviceAdapterNames(self) -> None:
60
+ self.core.getDeviceAdapterNames()
61
+
62
+ def bench_getLoadedDevices(self) -> None:
63
+ self.core.getLoadedDevices()
64
+
65
+ def bench_getSystemState(self) -> None:
66
+ self.core.getSystemState()
67
+
68
+
69
+ class CameraBenchmark(Benchmark):
70
+ device_type = DeviceType.Camera
71
+
72
+ def setup(self) -> None:
73
+ self.core.setCameraDevice(self.label)
74
+ self.core.setExposure(self.label, 1)
75
+
76
+ def bench_getMultiROI(self) -> None:
77
+ self.core.getMultiROI()
78
+
79
+ def bench_getExposure(self) -> None:
80
+ self.core.getExposure(self.label)
81
+
82
+ def bench_snapImage(self) -> None:
83
+ self.core.snapImage()
84
+
85
+ def bench_getImage(self) -> None:
86
+ self.core.getImage()
87
+
88
+ def bench_getImageWidth(self) -> None:
89
+ self.core.getImageWidth()
90
+
91
+ def bench_getImageHeight(self) -> None:
92
+ self.core.getImageHeight()
93
+
94
+ def bench_getImageBufferSize(self) -> None:
95
+ self.core.getImageBufferSize()
96
+
97
+ def bench_getImageBitDepth(self) -> None:
98
+ self.core.getImageBitDepth()
99
+
100
+ def bench_getNumberOfComponents(self) -> None:
101
+ self.core.getNumberOfComponents()
102
+
103
+ def bench_getNumberOfCameraChannels(self) -> None:
104
+ self.core.getNumberOfCameraChannels()
105
+
106
+
107
+ class XYStageBenchmark(Benchmark):
108
+ device_type = DeviceType.XYStage
109
+
110
+ def setup(self) -> None:
111
+ self.core.setXYStageDevice(self.label)
112
+ self.position = self.core.getXYPosition(self.label)
113
+
114
+ def bench_getXYPosition(self) -> None:
115
+ self.core.getXYPosition(self.label)
116
+
117
+ def bench_getXPosition(self) -> None:
118
+ self.core.getXPosition(self.label)
119
+
120
+ def bench_getYPosition(self) -> None:
121
+ self.core.getYPosition(self.label)
122
+
123
+ def bench_setXYPosition(self) -> None:
124
+ self.core.setXYPosition(self.label, *self.position)
125
+
126
+ def bench_setRelativeXYPosition(self) -> None:
127
+ self.core.setRelativeXYPosition(self.label, 0, 0)
128
+
129
+ def bench_isXYStageSequenceable(self) -> None:
130
+ self.core.isXYStageSequenceable(self.label)
131
+
132
+
133
+ class StageBenchmark(Benchmark):
134
+ device_type = DeviceType.Stage
135
+
136
+ def setup(self) -> None:
137
+ self.position = self.core.getPosition(self.label)
138
+
139
+ def bench_getPosition(self) -> None:
140
+ self.core.getPosition(self.label)
141
+
142
+ def bench_setPosition(self) -> None:
143
+ self.core.setPosition(self.label, self.position)
144
+
145
+ def bench_setRelativePosition(self) -> None:
146
+ self.core.setRelativePosition(self.label, 0)
147
+
148
+ def bench_isStageSequenceable(self) -> None:
149
+ self.core.isStageSequenceable(self.label)
150
+
151
+ def bench_isStageLinearSequenceable(self) -> None:
152
+ self.core.isStageLinearSequenceable(self.label)
153
+
154
+
155
+ class StateBenchmark(Benchmark):
156
+ device_type = DeviceType.State
157
+
158
+ def setup(self) -> None:
159
+ self.initial_state = self.core.getState(self.label)
160
+ try:
161
+ self.labels: Sequence[str] = self.core.getStateLabels(self.label)
162
+ except Exception:
163
+ self.labels = []
164
+
165
+ def bench_getState(self) -> None:
166
+ self.core.getState(self.label)
167
+
168
+ def bench_setState(self) -> None:
169
+ self.core.setState(self.label, self.initial_state)
170
+
171
+ def bench_getNumberOfStates(self) -> None:
172
+ self.core.getNumberOfStates(self.label)
173
+
174
+ def bench_getStateLabel(self) -> None:
175
+ self.core.getStateLabel(self.label)
176
+
177
+ def bench_getStateFromLabel(self) -> None:
178
+ for label in self.labels:
179
+ self.core.getStateFromLabel(self.label, label)
180
+
181
+
182
+ def benchmark_core_and_devices(
183
+ core: CMMCorePlus, number: int = 100
184
+ ) -> Iterable[Device | None | tuple[str, float | str]]:
185
+ """Take an initialized core with devices and benchmark various methods.
186
+
187
+ Yields
188
+ ------
189
+ Device | None | tuple[str, float | str]
190
+ If a `Device`, it is the device object being benchmarked.
191
+ If None, it is the core object being benchmarked.
192
+ If a tuple, it is the method name and the time taken to run it.
193
+ """
194
+ for cls in Benchmark.__subclasses__():
195
+ if cls.device_type == DeviceType.Core:
196
+ bench = cls(core, "Core")
197
+ yield bench.device()
198
+ yield from bench.run(number)
199
+ else:
200
+ for dev in core.getLoadedDevicesOfType(cls.device_type):
201
+ bench = cls(core, dev)
202
+ yield bench.device()
203
+ yield from bench.run(number)
pymmcore_plus/_build.py CHANGED
@@ -1,5 +1,7 @@
1
1
  """Clone the micro-manager source code from GitHub and build dev devices."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import json
4
6
  import os
5
7
  import platform
@@ -9,12 +11,15 @@ import subprocess
9
11
  import tempfile
10
12
  from contextlib import contextmanager
11
13
  from pathlib import Path
12
- from typing import Iterator, Sequence
14
+ from typing import TYPE_CHECKING
13
15
  from urllib.request import Request, urlopen
14
16
 
15
17
  from rich import print
16
18
  from rich.prompt import Prompt
17
19
 
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Iterator, Sequence
22
+
18
23
  MM_REPO = "micro-manager/micro-manager"
19
24
  MMCORE_AND_DEV = "micro-manager/mmCoreAndDevices"
20
25
  MM_REPO_URL = f"https://github.com/{MM_REPO}.git"
pymmcore_plus/_cli.py CHANGED
@@ -6,7 +6,10 @@ import sys
6
6
  import time
7
7
  from contextlib import suppress
8
8
  from pathlib import Path
9
- from typing import List, Optional, Union, cast
9
+ from typing import Optional, Union, cast
10
+
11
+ from pymmcore_plus.core._device import Device
12
+ from pymmcore_plus.core._mmcore_plus import CMMCorePlus
10
13
 
11
14
  try:
12
15
  import typer
@@ -18,23 +21,41 @@ except ImportError: # pragma: no cover
18
21
  ) from None
19
22
 
20
23
  import pymmcore_plus
24
+ from pymmcore_plus._build import DEFAULT_PACKAGES, build
21
25
  from pymmcore_plus._logger import configure_logging
22
26
  from pymmcore_plus._util import USER_DATA_MM_PATH
23
27
  from pymmcore_plus.install import PLATFORM
24
28
 
25
- app = typer.Typer(no_args_is_help=True)
29
+ app = typer.Typer(name="mmcore", no_args_is_help=True)
26
30
 
27
31
 
28
32
  def _show_version_and_exit(value: bool) -> None:
29
33
  if value:
30
- import pymmcore
31
-
32
34
  typer.echo(f"pymmcore-plus v{pymmcore_plus.__version__}")
33
- typer.echo(f"pymmcore v{pymmcore.__version__}") # type: ignore [attr-defined]
34
- typer.echo(f"MMCore v{pymmcore.CMMCore().getAPIVersionInfo()}")
35
+ try:
36
+ import pymmcore_nano as pymmcore
37
+
38
+ typer.echo(f"pymmcore-nano v{pymmcore.__version__}")
39
+ except ImportError:
40
+ import pymmcore
41
+
42
+ typer.echo(f"pymmcore v{pymmcore.__version__}")
43
+ typer.echo(f"MMCore v{pymmcore.CMMCore().getVersionInfo()}")
44
+ typer.echo(f"{pymmcore.CMMCore().getAPIVersionInfo()}")
35
45
  raise typer.Exit()
36
46
 
37
47
 
48
+ CONFIG_PARAM = typer.Option(
49
+ None,
50
+ "-c",
51
+ "--config",
52
+ dir_okay=False,
53
+ exists=True,
54
+ resolve_path=True,
55
+ help="Path to Micro-Manager system configuration file.",
56
+ )
57
+
58
+
38
59
  @app.callback()
39
60
  def _main(
40
61
  version: Optional[bool] = typer.Option(
@@ -47,18 +68,21 @@ def _main(
47
68
  ) -> None:
48
69
  """mmcore: pymmcore-plus command line (v{version}).
49
70
 
50
- For additional help on a specific command: type 'mmcore [command] --help'
71
+ For additional help on a specific command: type `mmcore [command] --help`
51
72
  """
52
73
  # fix for windows CI encoding and emoji printing
53
74
  if getattr(sys.stdout, "encoding", None) != "utf-8":
54
75
  with suppress(AttributeError):
55
- sys.stdout.reconfigure(encoding="utf-8") # type: ignore [attr-defined]
76
+ sys.stdout.reconfigure(encoding="utf-8") # type: ignore [union-attr]
56
77
 
57
78
 
58
- _main.__doc__ = typer.style(
59
- (_main.__doc__ or "").format(version=pymmcore_plus.__version__),
60
- fg=typer.colors.BRIGHT_YELLOW,
61
- )
79
+ if "mkdocs" in sys.argv[0]: # pragma: no cover
80
+ _main.__doc__ = (_main.__doc__ or "").replace(" (v{version})", "")
81
+ else:
82
+ _main.__doc__ = typer.style(
83
+ (_main.__doc__ or "").format(version=pymmcore_plus.__version__),
84
+ fg=typer.colors.BRIGHT_YELLOW,
85
+ )
62
86
 
63
87
 
64
88
  @app.command()
@@ -91,7 +115,8 @@ def _list() -> None:
91
115
  print(f":file_folder:[bold green] {parent}")
92
116
  for item in items:
93
117
  bullet = " [bold yellow]*" if first else " •"
94
- print(f"{bullet} [cyan]{item}")
118
+ using = " [bold blue](active)" if first else ""
119
+ print(f"{bullet} [cyan]{item}{using}")
95
120
  first = False
96
121
  else:
97
122
  print(":x: [bold red]There are no pymmcore-plus Micro-Manager files.")
@@ -100,7 +125,11 @@ def _list() -> None:
100
125
 
101
126
  @app.command()
102
127
  def mmstudio() -> None: # pragma: no cover
103
- """Run the Java Micro-Manager GUI."""
128
+ """Run the Java Micro-Manager GUI.
129
+
130
+ This command will attempt to locate an execute an ImageJ application found in
131
+ the active Micro-Manager directory.
132
+ """
104
133
  mm = pymmcore_plus.find_micromanager()
105
134
  app = (
106
135
  next((x for x in Path(mm).glob("ImageJ*") if not str(x).endswith("cfg")), None)
@@ -137,7 +166,7 @@ def install(
137
166
  show_default=False,
138
167
  ),
139
168
  ) -> None:
140
- """Install Micro-Manager Device adapters."""
169
+ """Install Micro-Manager Device adapters from <https://download.micro-manager.org>."""
141
170
  import pymmcore_plus.install
142
171
 
143
172
  if plain_output:
@@ -159,15 +188,7 @@ def run(
159
188
  resolve_path=True,
160
189
  help="Path to useq-schema file.",
161
190
  ),
162
- config: Optional[Path] = typer.Option(
163
- None,
164
- "-c",
165
- "--config",
166
- dir_okay=False,
167
- exists=True,
168
- resolve_path=True,
169
- help="Path to Micro-Manager system configuration file.",
170
- ),
191
+ config: Optional[Path] = CONFIG_PARAM,
171
192
  z_go_up: Optional[bool] = typer.Option(None, help="Acquire from bottom to top."),
172
193
  z_top: Optional[float] = typer.Option(None, help="Top of z-stack."),
173
194
  z_bottom: Optional[float] = typer.Option(None, help="Bottom of z-stack."),
@@ -181,10 +202,10 @@ def run(
181
202
  None, help="Asymmetric range of z-stack below position."
182
203
  ),
183
204
  z_step: Optional[float] = typer.Option(None, help="Step size of z-stack."),
184
- z_relative: Optional[List[float]] = typer.Option(
205
+ z_relative: Optional[list[float]] = typer.Option(
185
206
  None, "-zr", help="Relative z-positions to acquire (may use multiple times)."
186
207
  ),
187
- z_absolute: Optional[List[float]] = typer.Option(
208
+ z_absolute: Optional[list[float]] = typer.Option(
188
209
  None, "-za", help="Absolute z-positions to acquire (may use multiple times)."
189
210
  ),
190
211
  t_interval: Optional[float] = typer.Option(
@@ -198,7 +219,7 @@ def run(
198
219
  axis_order: Optional[str] = typer.Option(
199
220
  None, help="Order of axes to acquire (e.g. 'TPCZ')."
200
221
  ),
201
- channel: Optional[List[str]] = typer.Option(
222
+ channel: Optional[list[str]] = typer.Option(
202
223
  None,
203
224
  help="\bChannel to acquire. Argument is a string of the following form:\n"
204
225
  '\b - name: "DAPI"\n'
@@ -285,8 +306,8 @@ def run(
285
306
 
286
307
  @app.command()
287
308
  def build_dev(
288
- devices: Optional[List[str]] = typer.Argument(
289
- None, help="Device adapters to build. Defaults to DemoCamera and Utilities."
309
+ devices: Optional[list[str]] = typer.Argument(
310
+ None, help=f"Device adapters to build. Defaults to {DEFAULT_PACKAGES}"
290
311
  ),
291
312
  dest: Path = typer.Option(
292
313
  USER_DATA_MM_PATH,
@@ -305,9 +326,10 @@ def build_dev(
305
326
  "If not specified, will prompt.",
306
327
  ),
307
328
  ) -> None: # pragma: no cover
308
- """Build DemoCamera and Utility adapters from source for apple silicon."""
309
- from pymmcore_plus._build import DEFAULT_PACKAGES, build
329
+ """Build Micro-Manager device adapters from the git repo.
310
330
 
331
+ Currently only supports macos and linux.
332
+ """
311
333
  devices = DEFAULT_PACKAGES if not devices else devices
312
334
  try:
313
335
  build(dest, overwrite=overwrite, devices=devices)
@@ -374,6 +396,31 @@ def info() -> None:
374
396
  typer.secho(f"{key:{length}}: {value}")
375
397
 
376
398
 
399
+ @app.command()
400
+ def use(
401
+ pattern: str = typer.Argument(
402
+ ...,
403
+ help="Path to an existing directory, or pattern to match against installations "
404
+ "found by `mmcore list`",
405
+ ),
406
+ ) -> None:
407
+ """Change the currently used Micro-manager version/path."""
408
+ from pymmcore_plus._util import use_micromanager
409
+
410
+ _pth = Path(pattern)
411
+ if _pth.exists():
412
+ if not _pth.is_dir():
413
+ raise typer.BadParameter("must be a directory")
414
+ result = use_micromanager(path=_pth)
415
+ else:
416
+ try:
417
+ result = use_micromanager(pattern=pattern)
418
+ except FileNotFoundError as e:
419
+ raise typer.BadParameter(str(e)) from None
420
+
421
+ typer.secho(f"using {result}", fg=typer.colors.BRIGHT_GREEN)
422
+
423
+
377
424
  def _tail_file(file_path: Union[str, Path], interval: float = 0.1) -> None:
378
425
  with open(file_path) as file:
379
426
  # Move the file pointer to the end
@@ -388,5 +435,58 @@ def _tail_file(file_path: Union[str, Path], interval: float = 0.1) -> None:
388
435
  time.sleep(1)
389
436
 
390
437
 
438
+ @app.command()
439
+ def bench(
440
+ config: Optional[Path] = CONFIG_PARAM,
441
+ number: int = typer.Option(
442
+ 10, "-n", "--number", help="Number of iterations for each test."
443
+ ),
444
+ ) -> None:
445
+ """Run a benchmark of Core and Devices loaded with `config` (or Demo)."""
446
+ from rich.console import Console
447
+ from rich.live import Live
448
+ from rich.table import Table
449
+
450
+ from pymmcore_plus._benchmark import benchmark_core_and_devices
451
+
452
+ console = Console()
453
+
454
+ core = CMMCorePlus()
455
+ if config is not None:
456
+ console.log(
457
+ f"Loading config {config} ...",
458
+ style="bright_blue",
459
+ end="",
460
+ )
461
+ core.loadSystemConfiguration(str(config))
462
+ else:
463
+ console.log("Loading DEMO configuration ...", style="bright_blue", end="")
464
+ core.loadSystemConfiguration()
465
+ console.log("Loaded.", style="bright_blue")
466
+
467
+ table = Table()
468
+ table.add_column("Method")
469
+ table.add_column("Time (ms)")
470
+
471
+ with Live(table, console=console, refresh_per_second=4):
472
+ for item in benchmark_core_and_devices(core, number):
473
+ if item is None:
474
+ table.add_row("Device: Core", "------", style="yellow")
475
+ elif isinstance(item, Device):
476
+ console.print(
477
+ f"Measuring ({item.type()}) Device: "
478
+ f"{item.label!r} <{item.library()}::{item.name()}>"
479
+ f": {item.description()}",
480
+ style="#333333",
481
+ )
482
+ table.add_row(f"Device: {item.label}", "------", style="yellow")
483
+ else:
484
+ method, time = item
485
+ if isinstance(time, float):
486
+ table.add_row(method, f"{time:.4f}")
487
+ else:
488
+ table.add_row(method, str(time), style="red")
489
+
490
+
391
491
  def main() -> None: # pragma: no cover
392
492
  app()
pymmcore_plus/_logger.py CHANGED
@@ -6,22 +6,23 @@ import sys
6
6
  from contextlib import contextmanager
7
7
  from logging.handlers import RotatingFileHandler
8
8
  from pathlib import Path
9
- from typing import ClassVar, Iterator
9
+ from typing import TYPE_CHECKING, ClassVar
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Iterator
10
13
 
11
14
  __all__ = ["logger"]
12
15
 
13
16
 
14
17
  logger = logging.getLogger("pymmcore-plus")
15
18
 
16
-
19
+ PYMM_LOG_FILE = os.getenv("PYMM_LOG_FILE", "")
17
20
  DEFAULT_LOG_LEVEL: str = os.getenv("PYMM_LOG_LEVEL", "INFO").upper()
18
- if any(x.endswith("pytest") for x in sys.argv):
21
+
22
+ if "PYTEST_RUNNING" in os.environ:
19
23
  LOG_FILE = None
20
- elif "PYMM_LOG_FILE" in os.environ:
21
- if os.environ["PYMM_LOG_FILE"].lower() in ("", "0", "false", "no", "none"):
22
- LOG_FILE = None
23
- else:
24
- LOG_FILE = Path(os.environ["PYMM_LOG_FILE"]).expanduser().resolve()
24
+ elif PYMM_LOG_FILE not in ("", "0", "false", "no", "none"):
25
+ LOG_FILE = Path(PYMM_LOG_FILE).expanduser().resolve()
25
26
  else:
26
27
  from ._util import USER_DATA_DIR
27
28
 
@@ -117,9 +118,17 @@ def configure_logging(
117
118
 
118
119
  # automatically log to stderr
119
120
  if log_to_stderr and sys.stderr:
120
- stderr_handler = logging.StreamHandler(sys.stderr)
121
+ # try to use rich for stderr logging
122
+ # fallback to plain text if rich is not installed
123
+ try:
124
+ from rich.logging import RichHandler
125
+
126
+ stderr_handler: logging.Handler = RichHandler()
127
+ except ImportError:
128
+ stderr_handler = logging.StreamHandler(sys.stderr)
129
+ stderr_handler.setFormatter(CustomFormatter())
130
+
121
131
  stderr_handler.setLevel(stderr_level)
122
- stderr_handler.setFormatter(CustomFormatter())
123
132
  logger.addHandler(stderr_handler)
124
133
 
125
134
  # automatically log to file
@@ -0,0 +1,12 @@
1
+ """Internal module to choose between pymmcore and pymmcore-nano."""
2
+
3
+ try:
4
+ from pymmcore_nano import * # noqa F403
5
+ from pymmcore_nano import __version__
6
+
7
+ BACKEND = "pymmcore-nano"
8
+ except ImportError:
9
+ from pymmcore import * # noqa F403
10
+ from pymmcore import __version__ # noqa F401
11
+
12
+ BACKEND = "pymmcore"