Skip to content

Commit

Permalink
reorg, changes, and docs
Browse files Browse the repository at this point in the history
  • Loading branch information
tlambert03 committed Nov 3, 2024
1 parent 6319e60 commit c8bf7e6
Show file tree
Hide file tree
Showing 12 changed files with 357 additions and 189 deletions.
53 changes: 52 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,55 @@
[![CI](https://github.com/pymmcore-plus/pymmcore-remote/actions/workflows/ci.yml/badge.svg)](https://github.com/pymmcore-plus/pymmcore-remote/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/pymmcore-plus/pymmcore-remote/branch/main/graph/badge.svg)](https://codecov.io/gh/pymmcore-plus/pymmcore-remote)

RPC for pymmcore-plus
**Remote process communication for pymmcore-plus**

-----------

This package provides experimental support for running
[pymmcore-plus](https://github.com/pymmcore-plus/pymmcore-plus) in a remote
process and communicating with it via RPC (currently mediated by
[Pyro5](https://github.com/irmen/Pyro5))

## Installation

For now, please install from the main branch on github:

```bash
pip install git+https://github.com/pymmcore-plus/pymmcore-remote
```

## Usage

Start a server on the machine with the microscope:

```sh
mmcore-remote
```

> You can also specify the port with `--port` and the hostname with `--host`.
Run `mmcore-remote --help` for more options.

Then, in a separate process, connect to the server using
using `pymmcore_remote.MMCoreProxy`:

```python
from pymmcore_remote import MMCorePlusProxy

with MMCorePlusProxy() as core:
core.loadSystemConfiguration("path/to/config.cfg")
# continue using core as you would with pymmcore_plus.CMMCorePlus
```

Commands are serialized and sent to the server, which executes them in the
context of a `CMMCorePlus` object. The results are then serialized and sent
back to the client.

See the [pymmcore-plus documentation](https://pymmcore-plus.github.io/pymmcore-plus/) for standard usage of the CMMCorePlus object.

## Considerations

This package is experimental: The goal is for the API to be identical to that of
`pymmcore-plus`, but there may be some differences, and various serialization
issues may still be undiscovered. Please [open an
issue](https://github.com/pymmcore-plus/pymmcore-remote/issues/new) if you
encounter any problems.
11 changes: 4 additions & 7 deletions x.py → examples/remote_mda.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import numpy as np
from useq import MDAEvent, MDASequence, TIntervalLoops

from pymmcore_remote.client import MMCoreProxy
from pymmcore_remote import MMCorePlusProxy

with MMCoreProxy() as core:
print(core)
with MMCorePlusProxy() as core:
core.loadSystemConfiguration()

@core.mda.events.frameReady.connect
def _onframe(frame: np.ndarray, event: MDAEvent, meta: dict) -> None:
print(frame.shape, event, meta)

core.loadSystemConfiguration()
print(core.getLoadedDevices())
print(f"received frame shape {frame.shape}, index {event.index}")

core.mda.run(MDASequence(time_plan=TIntervalLoops(interval=0.2, loops=8)))
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ classifiers = [
"Programming Language :: Python :: 3.13",
"Typing :: Typed",
]
dependencies = ["Pyro5", "pymmcore-plus"]
dependencies = ["Pyro5", "pymmcore-plus[cli]", "msgpack"]

# https://peps.python.org/pep-0621/#dependencies-optional-dependencies
[project.optional-dependencies]
Expand All @@ -48,7 +48,7 @@ dev = [
]

[project.scripts]
mmcore-remote = "pymmcore_remote.server:serve"
mmcore-remote = "pymmcore_remote.server._server:main"

[project.urls]
homepage = "https://github.com/pymmcore-plus/pymmcore-remote"
Expand Down
5 changes: 3 additions & 2 deletions src/pymmcore_remote/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
__author__ = "Talley Lambert"
__email__ = "[email protected]"

from .client import MMCoreProxy
from .client import MMCorePlusProxy
from .server import serve, server_process

__all__ = ["MMCoreProxy"]
__all__ = ["MMCorePlusProxy", "serve", "server_process"]
11 changes: 10 additions & 1 deletion src/pymmcore_remote/_serialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from abc import ABC, abstractmethod
from collections import deque
from collections.abc import Sized
from functools import lru_cache
from multiprocessing.shared_memory import SharedMemory
from typing import ClassVar, Generic, TypeVar

Expand All @@ -15,7 +16,14 @@
from pymmcore_plus.core import Configuration, Metadata

# https://pyro5.readthedocs.io/en/latest/clientcode.html#serialization
Pyro5.config.SERIALIZER = "msgpack" # msgpack|serpent|json, all work - but not marshal
try:
import msgpack # noqa: F401

# msgpack|serpent|json, all work - but not marshal
Pyro5.config.SERIALIZER = "msgpack"
except ImportError: # pragma: no cover
pass

T = TypeVar("T")


Expand Down Expand Up @@ -162,6 +170,7 @@ def fix_unregister(name: Sized, rtype: str) -> None: # pragma: no cover
del resource_tracker._CLEANUP_FUNCS["shared_memory"] # type: ignore [attr-defined]


@lru_cache # only register once
def register_serializers() -> None:
remove_shm_from_resource_tracker()
for i in globals().values():
Expand Down
37 changes: 27 additions & 10 deletions src/pymmcore_remote/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import TYPE_CHECKING, Any, cast

import Pyro5.api
import Pyro5.errors
from pymmcore_plus.core.events import CMMCoreSignaler
from pymmcore_plus.mda.events import MDASignaler

Expand Down Expand Up @@ -32,7 +33,7 @@ def __enter__(self) -> MDARunner:
return super().__enter__() # type: ignore [no-any-return]


class MMCoreProxy(Pyro5.api.Proxy):
class MMCorePlusProxy(Pyro5.api.Proxy):
"""Proxy for CMMCorePlus object on server."""

_mda_runner: MDARunnerProxy
Expand All @@ -45,30 +46,46 @@ def __init__(
register_serializers()
uri = f"PYRO:{server.CORE_NAME}@{host}:{port}"
super().__init__(uri)

# check that the connection is valid
try:
self._pyroBind()
except Pyro5.errors.CommunicationError as e:
raise ConnectionRefusedError(
f"Failed to connect to server at {uri}.\n"
"Is the pymmcore-plus server running? "
"You can start it with: 'mmcore-remote'"
) from e

# create a proxy object to receive and connect CMMCoreSignaler events
# here on the client side
events = ClientSideCMMCoreSignaler()
object.__setattr__(self, "events", events)

# create daemon thread to listen for callbacks/signals coming from the server
# and register the callback handler
cb_thread = _DaemonThread(name="CallbackDaemon")
cb_thread.api_daemon.register(events)
# connect our local callback handler to the server's signaler
self.connect_client_side_callback(events) # must come after register()

# Retrieve the existing MDARunner URI instead of creating a new one
mda_runner_uri = self.get_mda_runner_uri()
# Create a proxy object for the mda_runner as well, passing in the daemon thread
# so it too can receive signals from the server
object.__setattr__(
self, "_mda_runner", MDARunnerProxy(mda_runner_uri, cb_thread)
self, "_mda_runner", MDARunnerProxy(self.get_mda_runner_uri(), cb_thread)
)
# start the callback-handling thread
cb_thread.start()

# this is a lie... but it's more useful than -> Self
def __enter__(self) -> CMMCorePlus:
"""Use as a context manager."""
return super().__enter__() # type: ignore [no-any-return]

@property
def mda(self) -> MDARunner:
"""Return the MDARunner proxy."""
return self._mda_runner

# this is a lie... but it's more useful than -> Self
def __enter__(self) -> CMMCorePlus:
"""Use as a context manager."""
return super().__enter__() # type: ignore [no-any-return]


@Pyro5.api.expose # type: ignore [misc]
def receive_server_callback(self: Any, signal_name: str, args: tuple) -> None:
Expand Down
130 changes: 0 additions & 130 deletions src/pymmcore_remote/server.py

This file was deleted.

21 changes: 21 additions & 0 deletions src/pymmcore_remote/server/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Server module for pymmcore_remote."""

from ._server import (
CORE_NAME,
DEFAULT_HOST,
DEFAULT_PORT,
DEFAULT_URI,
RemoteCMMCorePlus,
serve,
server_process,
)

__all__ = [
"DEFAULT_HOST",
"DEFAULT_PORT",
"DEFAULT_URI",
"serve",
"server_process",
"CORE_NAME",
"RemoteCMMCorePlus",
]
3 changes: 3 additions & 0 deletions src/pymmcore_remote/server/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from ._server import main

main()
Loading

0 comments on commit c8bf7e6

Please sign in to comment.