Source code for pyrolab.server

# Copyright © PyroLab Project Contributors
# Licensed under the terms of the GNU GPLv3+ License
# (see pyrolab/__init__.py for details)

"""
Daemon
======

Wrapped daemon functions that references PyroLab configuration settings.
"""

from __future__ import annotations

import inspect
import logging
from typing import TYPE_CHECKING, Callable, Optional, Type

import Pyro5
from Pyro5.core import URI
from Pyro5.server import expose

if TYPE_CHECKING:
    from Pyro5.socketutil import SocketConnection

    from pyrolab.drivers import Instrument
    from pyrolab.service import Service


log = logging.getLogger(__name__)


[docs] def change_behavior( cls: Type[Instrument], instance_mode: str = "session", instance_creator: Optional[Callable] = None, ): """ Dynamically add a behavior to a class. Equivalent to using the ``behavior`` decorator on the class, but can be used dynamically during runtime. Services that specify some default behavior in the source code can be overridden using this function. .. warning:: This function modifies the behavior of the class in place! It does not returning a new class object. Parameters ---------- cls : class The class to be change the behavior of. instance_mode : str One of "session", "single", or "percall" (see manual for differences). instance_creator : callable A callable that creates a new instance of the class (see manual for more details). Raises ------ ValueError If the ``instance_mode`` is not one of "session", "single", or "percall". SyntaxError If ``instance_mode`` is not a string, or is missing. TypeError If the first argument is not a class, or ``instance_creator`` is not a callable. """ if not isinstance(instance_mode, str): raise SyntaxError("behavior decorator is missing argument(s)") if not inspect.isclass(cls): raise TypeError("add_behavior can only be used on a class") if instance_mode not in ("single", "session", "percall"): raise ValueError("invalid instance mode: " + instance_mode) if instance_creator and not callable(instance_creator): raise TypeError("instance_creator must be a callable") cls._pyroInstancing = (instance_mode, instance_creator)
[docs] @expose class Lockable: """ The Lockable instrument mixin. Only works with LockableDaemon. Rejects new connections at the Daemon level when locked. Daemon stores the user who locked the device for reference. This mixin only makes sense in the context of a Daemon. It is not intended for use with local instruments. Additionally, any service registered with a :py:class:`LockableDaemon` will automatically have this mixin added to it. Examples -------- .. code-block:: python class MyCustomService(Service, Lockable): def __init__(self, *args, **kwargs): pass """
[docs] def lock(self, user: str = "") -> bool: """ Locks access to the object's attributes. Parameters ---------- user : str, optional The user who has locked the device. Useful when a device is locked and another user wants to know who is using it. """ # TODO: Consider making "user" a required parameter so we never have # to wonder who acquired the lock. daemon = getattr(self, "_pyroDaemon", None) if daemon: return daemon._lock(self._pyroId, daemon._last_requestor, user) return True
[docs] def unlock(self) -> bool: """ Releases the lock on the object. """ daemon = getattr(self, "_pyroDaemon", None) if daemon: return daemon._release(self._pyroId) return True
[docs] def islocked(self) -> bool: """ Returns the status of the lock. Returns ------- bool True if the lock is engaged, False otherwise. """ daemon = getattr(self, "_pyroDaemon", None) if daemon: return daemon._islocked(self._pyroId) return False
[docs] class Daemon(Pyro5.server.Daemon): """ The PyroLab server daemon. This class is based on the Pyro5.server.Daemon. Parameters ---------- host : str or None The hostname or IP address to bind the server on. Default is None which means it uses the configured default (which is localhost). It is necessary to set this argument to a visible hostname or ip address, if you want to access the daemon from other machines. port : int, optional Port to bind the server on. Defaults to 0, which means to pick a random port. unixsocket : str, optional The name of a Unix domain socket to use instead of a TCP/IP socket. Default is None (don't use). nathost : str, optional hostname to use in published addresses (useful when running behind a NAT firewall/router). Default is None which means to just use the normal host. For more details about NAT, see Pyro behind a NAT router/firewall. natport : int, optional Port to use in published addresses (useful when running behind a NAT firewall/router). If you use 0 here, Pyro will replace the NAT-port by the internal port number to facilitate one-to-one NAT port mappings. interface : DaemonObject, optional Optional alternative daemon object implementation (that provides the Pyro API of the daemon itself). connected_socket : SocketConnection, optional Pptional existing socket connection to use instead of creating a new server socket. """ # TODO: Implement methods that allow a client to view and forcibly close # connections to this Daemon. def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs)
[docs] def register(self, obj_or_class, objectId=None, force=False, weak=False): """ Register a Pyro object under the given id. Note that this object is now only known inside this daemon, it is not automatically available in a name server. This method returns a URI for the registered object. Pyro checks if an object is already registered, unless you set force=True. You can register a class or an object (instance) directly. For a class, Pyro will create instances of it to handle the remote calls according to the instance_mode (set via @expose on the class). The default there is one object per session (=proxy connection). If you register an object directly, Pyro will use that single object for all remote calls. With weak=True, only weak reference to the object will be stored, and the object will get unregistered from the daemon automatically when garbage-collected. """ obj_or_class = self._prepare_class(obj_or_class) return super().register(obj_or_class, objectId=objectId, force=force, weak=weak)
@staticmethod def _prepare_class(cls) -> Type[Service]: """ Performs any actions on the class required for the given Daemon. Some classes require mixins to be added in order for certain functionality to work. This method is called by :py:meth:`pyrolab.server.Daemon.register` before passing the registration on to the Pyro5 daemon. Parameters ---------- cls : class The class to prepare. Returns ------- class The prepared class. """ return cls
[docs] @expose def ping(self) -> bool: """ Returns a bool (True) to indicate that the Daemon is alive and can be communicated with. Returns ------- result : bool True, meaning communication was established. """ return True
[docs] @expose def pyrolab_version(self) -> str: """ Return the version of PyroLab running the device. """ from pyrolab import __version__ return __version__
[docs] class LockableDaemon(Daemon): """ A LockableDaemon supports lockable resources. Lockable resources are objects that can be locked by a client. This is useful for preventing multiple clients from accessing the same resource simultaneously. The lock can be released manually, or is automatically released when the client disconnects. Only objects with instance behavior "single" can be locked. Parameters ---------- host : str or None The hostname or IP address to bind the server on. Default is None which means it uses the configured default (which is localhost). It is necessary to set this argument to a visible hostname or ip address, if you want to access the daemon from other machines. port : int, optional Port to bind the server on. Defaults to 0, which means to pick a random port. unixsocket : str, optional The name of a Unix domain socket to use instead of a TCP/IP socket. Default is None (don’t use). nathost : str, optional hostname to use in published addresses (useful when running behind a NAT firewall/router). Default is None which means to just use the normal host. For more details about NAT, see Pyro behind a NAT router/firewall. natport : int, optional Port to use in published addresses (useful when running behind a NAT firewall/router). If you use 0 here, Pyro will replace the NAT-port by the internal port number to facilitate one-to-one NAT port mappings. interface : DaemonObject, optional Optional alternative daemon object implementation (that provides the Pyro API of the daemon itself). connected_socket : SocketConnection, optional Pptional existing socket connection to use instead of creating a new server socket. """ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.locked_instances = {} @staticmethod def _prepare_class(cls) -> Type[Service]: """ Dynamically create a new class that is also based on Lockable. Parameters ---------- cls : class The class to be used as a template while dynamically creating a new class. Returns ------- class A subclass that inherits from the original class and ``Lockable``. """ if issubclass(type(cls), Daemon): return cls DynamicLockable = type( cls.__name__ + "Lockable", ( cls, Lockable, ), {}, ) return DynamicLockable def _lock(self, pyroId: str, conn: SocketConnection, user: str = "") -> bool: """ Logs a "lock" action on a Pyro object. LockableDaemon tracks which connection owns the lock over a given Pyro object. Parameters ---------- pyroId : str The pyroId of the Pyro object. conn : SocketConnection The socket connection with the client that owns the lock. user : str, optional The user who has locked the device. Useful when a device is locked by a user and another user wants to know who is using it. Returns ------- bool A success status flag. """ if pyroId not in self.locked_instances: self.locked_instances[pyroId] = (conn, user) return True def _release(self, pyroId: str) -> bool: """ Logs a "lock release" action on a Pyro object. LockableDaemon tracks which connection owns the lock over a given Pyro object. In the case of a release action, it does not matter which connection makes the release; only lock owners can even access the release attribute. Parameters ---------- pyroId : str The object to be released. Returns ------- bool A success status flag. False if the instance wasn't locked to begin with. """ removed = self.locked_instances.pop(pyroId, None) return True if removed else False def _islocked(self, pyroId: str) -> bool: """ Checks if a Pyro object is locked. Parameters ---------- pyroId : str The pyroId of the object to check. Returns ------- bool A success status flag. """ return True if (pyroId in self.locked_instances) else False
[docs] @expose def release(self, uri: str) -> str: """ Provides a way to force unlock a resource via the Daemon itself. The Daemon must itself be registered to a Daemon and be assigned a URI. That Daemon can very well be itself. Parameters ---------- uri : str The Pyro URI of the object to be unlocked. Returns ------- result : bool True if the resource was successfully released, False otherwise. Raises ------ Pyro5.errors.PyroError If the URI is invalid. """ objId = URI(uri).object obj = self.objectsById[objId] # Only matters when instance mode is "single". instance = self._pyroInstances.get(obj) if instance: return instance.unlock() else: return True
def _getInstance(self, clazz, conn): """ Find or create a new instance of the class. If an instance already exists, but is locked, an exception is raised. Parameters ---------- clazz The Pyro object being accessed. conn The connection object the request is being made from. Returns ------- instance : clazz The instance of the Pyro object being accessed. Raises ------ ConnectionRefusedError If an instance exists but is locked by a different connection. """ self._last_requestor = conn obj = super()._getInstance(clazz, conn) if issubclass(obj.__class__, Lockable): lock_owner, username = self.locked_instances.get(obj._pyroId, (None, "")) if lock_owner is None or lock_owner == conn: return obj if lock_owner != conn: raise ConnectionRefusedError( f"Pyro object is locked (by '{username or lock_owner}')" ) return obj
[docs] def clientDisconnect(self, conn): """ Automatically releases any locked resources in the event of a client disconnect. Parameters ---------- conn : SocketConnection The SocketConnection object that was disconnected. """ for pyroId in list(self.locked_instances.keys()): owner, username = self.locked_instances[pyroId][0] if conn == owner: del self.locked_instances[pyroId] log.info(f"Client connection closed, releasing lock owned by '{username}'.")