# systemd_ctypes # # Copyright (C) 2022 Allison Karlitskaya # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . import asyncio import enum import logging import typing from typing import Any, Callable, Dict, Optional, Sequence, Tuple, Union from . import bustypes, introspection, libsystemd from .librarywrapper import WeakReference, byref logger = logging.getLogger(__name__) # Set of active async tasks to prevent garbage collection before completion _active_tasks: 'set[asyncio.Task]' = set() class BusError(Exception): """An exception corresponding to a D-Bus error message This exception is raised by the method call methods. You can also raise it from your own method handlers. It can also be passed directly to functions such as Message.reply_method_error(). :name: the 'code' of the error, like org.freedesktop.DBus.Error.UnknownMethod :message: a human-readable description of the error """ def __init__(self, name: str, message: str): super().__init__(f'{name}: {message}') self.name = name self.message = message class BusMessage(libsystemd.sd_bus_message): """A message, received from or to be sent over D-Bus This is the low-level interface to receiving and sending individual messages over D-Bus. You won't normally need to use it. A message is associated with a particular bus. You can create messages for a bus with Bus.message_new_method_call() or Bus.message_new_signal(). You can create replies to method calls with Message.new_method_return() or Message.new_method_error(). You can append arguments with Message.append() and send the message with Message.send(). """ def get_bus(self) -> 'Bus': """Get the bus that a message is associated with. This is the bus that a message came from or will be sent on. Every message has an associated bus, and it cannot be changed. :returns: the Bus """ return Bus.ref(self._get_bus()) def get_error(self) -> Optional[BusError]: """Get the BusError from a message. :returns: a BusError for an error message, or None for a non-error message """ error = self._get_error() if error: return BusError(*error.contents.get()) else: return None def new_method_return(self, signature: str = '', *args: Any) -> 'BusMessage': """Create a new (successful) return message as a reply to this message. This only makes sense when performed on a method call message. :signature: The signature of the result, as a string. :args: The values to send, conforming to the signature string. :returns: the reply message """ reply = BusMessage() self._new_method_return(byref(reply)) reply.append(signature, *args) return reply def new_method_error(self, error: Union[BusError, OSError]) -> 'BusMessage': """Create a new error message as a reply to this message. This only makes sense when performed on a method call message. :error: BusError or OSError of the error to send :returns: the reply message """ reply = BusMessage() if isinstance(error, BusError): self._new_method_errorf(byref(reply), error.name, "%s", error.message) else: assert isinstance(error, OSError) assert isinstance(error.errno, int), 'OSError must have an integer errno' self._new_method_errnof(byref(reply), error.errno, "%s", str(error)) return reply def append_arg(self, typestring: str, arg: Any) -> None: """Append a single argument to the message. :typestring: a single typestring, such as 's', or 'a{sv}' :arg: the argument to append, matching the typestring """ type_, = bustypes.from_signature(typestring) type_.writer(self, arg) def append(self, signature: str, *args: Any) -> None: """Append zero or more arguments to the message. :signature: concatenated typestrings, such 'a{sv}' (one arg), or 'ss' (two args) :args: one argument for each type string in the signature """ types = bustypes.from_signature(signature) assert len(types) == len(args), f'call args {args} have different length than signature {signature}' for type_, arg in zip(types, args): type_.writer(self, arg) def get_body(self) -> Tuple[object, ...]: """Gets the body of a message. Possible return values are (), ('single',), or ('x', 'y'). If you check the signature of the message using Message.has_signature() then you can use tuple unpacking. single, = message.get_body() x, y = other_message.get_body() :returns: an n-tuple containing one value per argument in the message """ self.rewind(True) types = bustypes.from_signature(self.get_signature(True)) return tuple(type_.reader(self) for type_ in types) def send(self) -> bool: # Literal[True] """Sends a message on the bus that it was created for. :returns: True """ self.get_bus().send(self, None) return True def reply_method_error(self, error: Union[BusError, OSError]) -> bool: # Literal[True] """Sends an error as a reply to a method call message. :error: A BusError or OSError :returns: True """ return self.new_method_error(error).send() def reply_method_return(self, signature: str = '', *args: Any) -> bool: # Literal[True] """Sends a return value as a reply to a method call message. :signature: The signature of the result, as a string. :args: The values to send, conforming to the signature string. :returns: True """ return self.new_method_return(signature, *args).send() def _coroutine_task_complete(self, out_type: bustypes.MessageType, task: asyncio.Task) -> None: try: self.reply_method_function_return_value(out_type, task.result()) except (BusError, OSError) as exc: self.reply_method_error(exc) def reply_method_function_return_value(self, out_type: bustypes.MessageType, return_value: Any) -> bool: # Literal[True]: """Sends the result of a function call as a reply to a method call message. This call does a bit of magic: it adapts from the usual Python return value conventions (where the return value is ``None``, a single value, or a tuple) to the normal D-Bus return value conventions (where the result is always a tuple). Additionally, if the value is found to be a coroutine, a task is created to run the coroutine to completion and return the result (including exception handling). :out_types: The types of the return values, as an iterable. :return_value: The return value of a Python function call. :returns: True """ if asyncio.coroutines.iscoroutine(return_value): task = asyncio.create_task(return_value) # Keep task alive to prevent garbage collection _active_tasks.add(task) def done_callback(task: asyncio.Task) -> None: _active_tasks.discard(task) self._coroutine_task_complete(out_type, task) task.add_done_callback(done_callback) return True reply = self.new_method_return() # In the general case, a function returns an n-tuple, but... if len(out_type) == 0: # Functions with no return value return None. assert return_value is None elif len(out_type) == 1: # Functions with a single return value return that value. out_type.write(reply, return_value) else: # (general case) n return values are handled as an n-tuple. assert len(out_type) == len(return_value) out_type.write(reply, *return_value) return reply.send() class Slot(libsystemd.sd_bus_slot): def __init__(self, callback: Callable[[BusMessage], bool]): def handler(message: WeakReference, _data: object, _err: object) -> int: return 1 if callback(BusMessage.ref(message)) else 0 self.trampoline = libsystemd.sd_bus_message_handler_t(handler) if typing.TYPE_CHECKING: FutureMessage = asyncio.Future[BusMessage] else: # Python 3.6 can't subscript asyncio.Future FutureMessage = asyncio.Future class PendingCall(Slot): future: FutureMessage def __init__(self) -> None: future = asyncio.get_running_loop().create_future() def done(message: BusMessage) -> bool: error = message.get_error() if future.cancelled(): return True if error is not None: future.set_exception(error) else: future.set_result(message) return True super().__init__(done) self.future = future class Bus(libsystemd.sd_bus): _default_system_instance = None _default_user_instance = None class NameFlags(enum.IntFlag): DEFAULT = 0 REPLACE_EXISTING = 1 << 0 ALLOW_REPLACEMENT = 1 << 1 QUEUE = 1 << 2 @staticmethod def new( fd: Optional[int] = None, address: Optional[str] = None, bus_client: bool = False, server: bool = False, start: bool = True, attach_event: bool = True ) -> 'Bus': bus = Bus() Bus._new(byref(bus)) if address is not None: bus.set_address(address) if fd is not None: bus.set_fd(fd, fd) if bus_client: bus.set_bus_client(True) if server: bus.set_server(True, libsystemd.sd_id128()) if address is not None or fd is not None: if start: bus.start() if attach_event: bus.attach_event(None, 0) return bus @staticmethod def default_system(attach_event: bool = True) -> 'Bus': if Bus._default_system_instance is None: Bus._default_system_instance = Bus() Bus._default_system(byref(Bus._default_system_instance)) if attach_event: Bus._default_system_instance.attach_event(None, 0) return Bus._default_system_instance @staticmethod def default_user(attach_event: bool = True) -> 'Bus': if Bus._default_user_instance is None: Bus._default_user_instance = Bus() Bus._default_user(byref(Bus._default_user_instance)) if attach_event: Bus._default_user_instance.attach_event(None, 0) return Bus._default_user_instance def message_new_method_call( self, destination: Optional[str], path: str, interface: str, member: str, types: str = '', *args: object ) -> BusMessage: message = BusMessage() self._message_new_method_call(byref(message), destination, path, interface, member) message.append(types, *args) return message def message_new_signal( self, path: str, interface: str, member: str, types: str = '', *args: object ) -> BusMessage: message = BusMessage() self._message_new_signal(byref(message), path, interface, member) message.append(types, *args) return message def call(self, message: BusMessage, timeout: Optional[int] = None) -> BusMessage: reply = BusMessage() error = libsystemd.sd_bus_error() try: self._call(message, timeout or 0, byref(error), byref(reply)) return reply except OSError as exc: raise BusError(*error.get()) from exc def call_method( self, destination: str, path: str, interface: str, member: str, types: str = '', *args: object, timeout: Optional[int] = None ) -> Tuple[object, ...]: logger.debug('Doing sync method call %s %s %s %s %s %s', destination, path, interface, member, types, args) message = self.message_new_method_call(destination, path, interface, member, types, *args) message = self.call(message, timeout) return message.get_body() async def call_async( self, message: BusMessage, timeout: Optional[int] = None ) -> BusMessage: pending = PendingCall() self._call_async(byref(pending), message, pending.trampoline, pending.userdata, timeout or 0) return await pending.future async def call_method_async( self, destination: Optional[str], path: str, interface: str, member: str, types: str = '', *args: object, timeout: Optional[int] = None ) -> Tuple[object, ...]: logger.debug('Doing async method call %s %s %s %s %s %s', destination, path, interface, member, types, args) message = self.message_new_method_call(destination, path, interface, member, types, *args) message = await self.call_async(message, timeout) return message.get_body() def add_match(self, rule: str, handler: Callable[[BusMessage], bool]) -> Slot: slot = Slot(handler) self._add_match(byref(slot), rule, slot.trampoline, slot.userdata) return slot def add_object(self, path: str, obj: 'BaseObject') -> Slot: slot = Slot(obj.message_received) self._add_object(byref(slot), path, slot.trampoline, slot.userdata) obj.registered_on_bus(self, path) return slot class BaseObject: """Base object type for exporting objects on the bus This is the lowest-level class that can be passed to Bus.add_object(). If you want to directly subclass this, you'll need to implement `message_received()`. Subclassing from `bus.Object` is probably a better choice. """ _dbus_bus: Optional[Bus] = None _dbus_path: Optional[str] = None def registered_on_bus(self, bus: Bus, path: str) -> None: """Report that an instance was exported on a given bus and path. This is used so that the instance knows where to send signals. Bus.add_object() calls this: you probably shouldn't call this on your own. """ self._dbus_bus = bus self._dbus_path = path self.registered() def registered(self) -> None: """Called after an object has been registered on the bus This is the correct method to implement to do some initial work that needs to be done after registration. The default implementation does nothing. """ pass def emit_signal( self, interface: str, name: str, signature: str, *args: Any ) -> bool: """Emit a D-Bus signal on this object The object must have been exported on the bus with Bus.add_object(). :interface: the interface of the signal :name: the 'member' name of the signal to emit :signature: the type signature, as a string :args: the arguments, according to the signature :returns: True """ assert self._dbus_bus is not None assert self._dbus_path is not None return self._dbus_bus.message_new_signal(self._dbus_path, interface, name, signature, *args).send() def message_received(self, message: BusMessage) -> bool: """Called when a message is received for this object This is the lowest level interface to the BaseObject. You need to handle method calls, properties, and introspection. You are expected to handle the message and return True. Normally this means that you send a reply. If you don't want to handle the message, return False and other handlers will have a chance to run. If no handler handles the message, systemd will generate a suitable error message and send that, instead. :message: the message that was received :returns: True if the message was handled """ raise NotImplementedError class Interface: """The high-level base class for defining D-Bus interfaces This class provides high-level APIs for defining methods, properties, and signals, as well as implementing introspection. On its own, this class doesn't provide a mechanism for exporting anything on the bus. The Object class does that, and you'll generally want to subclass from it, as it contains several built-in standard interfaces (introspection, properties, etc.). The name of your class will be interpreted as a D-Bus interface name. Underscores are converted to dots. No case conversion is performed. If the interface name can't be represented using this scheme, or if you'd like to name your class differently, you can provide an interface= kwarg to the class definition. class com_example_Interface(bus.Object): pass class MyInterface(bus.Object, interface='org.cockpit_project.Interface'): pass The methods, properties, and signals which are visible from D-Bus are defined using helper classes with the corresponding names (Method, Property, Signal). You should use normal Python snake_case conventions for the member names: they will automatically be converted to CamelCase by splitting on underscore and converting the first letter of each resulting word to uppercase. For example, `method_name` becomes `MethodName`. Each Method, Property, or Signal constructor takes an optional name= kwargs to override the automatic name conversion convention above. An example class might look like: class com_example_MyObject(bus.Object): created = bus.Interface.Signal('s', 'i') renames = bus.Interface.Property('u', value=0) name = bus.Interface.Property('s', 'undefined') @bus.Interface.Method(out_types=(), in_types='s') def rename(self, name): self.renames += 1 self.name = name def registered(self): self.created('Hello', 42) See the documentation for the Method, Property, and Signal classes for more information and examples. """ # Class variables _dbus_interfaces: Dict[str, Dict[str, Dict[str, Any]]] _dbus_members: Optional[Tuple[str, Dict[str, Dict[str, Any]]]] # Instance variables: stored in Python form _dbus_property_values: Optional[Dict[str, Any]] = None @classmethod def __init_subclass__(cls, interface: Optional[str] = None) -> None: if interface is None: assert '__' not in cls.__name__, 'Class name cannot contain sequential underscores' interface = cls.__name__.replace('_', '.') # This is the information for this subclass directly members: Dict[str, Dict[str, Interface._Member]] = {'methods': {}, 'properties': {}, 'signals': {}} for name, member in cls.__dict__.items(): if isinstance(member, Interface._Member): member.setup(interface, name, members) # We only store the information if something was actually defined if sum(len(category) for category in members.values()) > 0: cls._dbus_members = (interface, members) # This is the information for this subclass, with all its ancestors cls._dbus_interfaces = dict(ancestor.__dict__['_dbus_members'] for ancestor in cls.mro() if '_dbus_members' in ancestor.__dict__) @classmethod def _find_interface(cls, interface: str) -> Dict[str, Dict[str, '_Member']]: try: return cls._dbus_interfaces[interface] except KeyError as exc: raise Object.Method.Unhandled from exc @classmethod def _find_category(cls, interface: str, category: str) -> Dict[str, '_Member']: return cls._find_interface(interface)[category] @classmethod def _find_member(cls, interface: str, category: str, member: str) -> '_Member': members = cls._find_category(interface, category) try: return members[member] except KeyError as exc: raise Object.Method.Unhandled from exc class _Member: _category: str # filled in from subclasses _python_name: Optional[str] = None _name: Optional[str] = None _interface: Optional[str] = None _description: Optional[Dict[str, Any]] def __init__(self, name: Optional[str] = None) -> None: self._python_name = None self._interface = None self._name = name def setup(self, interface: str, name: str, members: Dict[str, Dict[str, 'Interface._Member']]) -> None: self._python_name = name # for error messages if self._name is None: self._name = ''.join(word.title() for word in name.split('_')) self._interface = interface self._description = self._describe() members[self._category][self._name] = self def _describe(self) -> Dict[str, Any]: raise NotImplementedError def __getitem__(self, key: str) -> Any: # Acts as an adaptor for dict accesses from introspection.to_xml() assert self._description is not None return self._description[key] class Property(_Member): """Defines a D-Bus property on an interface There are two main ways to define properties: with and without getters. If you define a property without a getter, then you must provide a value (via the value= kwarg). In this case, the property value is maintained internally and can be accessed from Python in the usual way. Change signals are sent automatically. class MyObject(bus.Object): counter = bus.Interface.Property('i', value=0) a = MyObject() a.counter = 5 a.counter += 1 print(a.counter) The other way to define properties is with a getter function. In this case, you can read from the property in the normal way, but not write to it. You are responsible for emitting change signals for yourself. You must not provide the value= kwarg. class MyObject(bus.Object): _counter = 0 counter = bus.Interface.Property('i') @counter.getter def get_counter(self): return self._counter @counter.setter def set_counter(self, value): self._counter = value self.property_changed('Counter') In either case, you can provide a setter function. This function has no impact on Python code, but makes the property writable from the view of D-Bus. Your setter will be called when a Properties.Set() call is made, and no other action will be performed. A trivial implementation might look like: class MyObject(bus.Object): counter = bus.Interface.Property('i', value=0) @counter.setter def set_counter(self, value): # we got a request to set the counter from D-Bus self.counter = value In all cases, the first (and only mandatory) argument to the constructor is the D-Bus type of the property. Your getter and setter functions can be provided by kwarg to the constructor. You can also give a name= kwarg to override the default name conversion scheme. """ _category = 'properties' _getter: Optional[Callable[[Any], Any]] _setter: Optional[Callable[[Any, Any], None]] _type: bustypes.Type _value: Any def __init__(self, type_string: str, value: Any = None, name: Optional[str] = None, getter: Optional[Callable[[Any], Any]] = None, setter: Optional[Callable[[Any, Any], None]] = None): assert value is None or getter is None, 'A property cannot have both a value and a getter' super().__init__(name=name) self._getter = getter self._setter = setter self._type, = bustypes.from_signature(type_string) self._value = value def _describe(self) -> Dict[str, Any]: return {'type': self._type.typestring, 'flags': 'r' if self._setter is None else 'w'} def __get__(self, obj: 'Object', cls: Optional[type] = None) -> Any: assert self._name is not None if obj is None: return self if self._getter is not None: return self._getter.__get__(obj, cls)() elif self._value is not None: if obj._dbus_property_values is not None: return obj._dbus_property_values.get(self._name, self._value) else: return self._value else: raise AttributeError(f"'{obj.__class__.__name__}' property '{self._python_name}' " f"was not properly initialised: use either the 'value=' kwarg or " f"the @'{self._python_name}.getter' decorator") def __set__(self, obj: 'Object', value: Any) -> None: assert self._name is not None if self._getter is not None: raise AttributeError(f"Cannot directly assign '{obj.__class__.__name__}' " "property '{self._python_name}' because it has a getter") if obj._dbus_property_values is None: obj._dbus_property_values = {} obj._dbus_property_values[self._name] = value if obj._dbus_bus is not None: obj.properties_changed(self._interface, {self._name: bustypes.Variant(value, self._type)}, []) def to_dbus(self, obj: 'Object') -> bustypes.Variant: return bustypes.Variant(self.__get__(obj), self._type) def from_dbus(self, obj: 'Object', value: bustypes.Variant) -> None: if self._setter is None or self._type != value.type: raise Object.Method.Unhandled self._setter.__get__(obj)(value.value) def getter(self, getter: Callable[[Any], Any]) -> Callable[[Any], Any]: if self._value is not None: raise ValueError('A property cannot have both a value and a getter') if self._getter is not None: raise ValueError('This property already has a getter') self._getter = getter return getter def setter(self, setter: Callable[[Any, Any], None]) -> Callable[[Any, Any], None]: self._setter = setter return setter class Signal(_Member): """Defines a D-Bus signal on an interface This is a callable which will result in the signal being emitted. The constructor takes the types of the arguments, each one as a separate parameter. For example: properties_changed = Interface.Signal('s', 'a{sv}', 'as') You can give a name= kwarg to override the default name conversion scheme. """ _category = 'signals' _type: bustypes.MessageType def __init__(self, *out_types: str, name: Optional[str] = None) -> None: super().__init__(name=name) self._type = bustypes.MessageType(out_types) def _describe(self) -> Dict[str, Any]: return {'in': self._type.typestrings} def __get__(self, obj: 'Object', cls: Optional[type] = None) -> Callable[..., None]: def emitter(obj: Object, *args: Any) -> None: assert self._interface is not None assert self._name is not None assert obj._dbus_bus is not None assert obj._dbus_path is not None message = obj._dbus_bus.message_new_signal(obj._dbus_path, self._interface, self._name) self._type.write(message, *args) message.send() return emitter.__get__(obj, cls) class Method(_Member): """Defines a D-Bus method on an interface This is a function decorator which marks a given method for export. The constructor takes two arguments: the type of the output arguments, and the type of the input arguments. Both should be given as a sequence. @Interface.Method(['a{sv}'], ['s']) def get_all(self, interface): ... You can give a name= kwarg to override the default name conversion scheme. """ _category = 'methods' class Unhandled(Exception): """Raised by a method to indicate that the message triggering that method call remains unhandled.""" pass def __init__(self, out_types: Sequence[str] = (), in_types: Sequence[str] = (), name: Optional[str] = None): super().__init__(name=name) self._out_type = bustypes.MessageType(out_types) self._in_type = bustypes.MessageType(in_types) self._func = None def __get__(self, obj, cls=None): return self._func.__get__(obj, cls) def __call__(self, *args, **kwargs): # decorator self._func, = args return self def _describe(self) -> Dict[str, Any]: return {'in': [item.typestring for item in self._in_type.item_types], 'out': [item.typestring for item in self._out_type.item_types]} def _invoke(self, obj, message): args = self._in_type.read(message) if args is None: return False try: result = self._func.__get__(obj)(*args) except (BusError, OSError) as error: return message.reply_method_error(error) return message.reply_method_function_return_value(self._out_type, result) class org_freedesktop_DBus_Peer(Interface): @Interface.Method() @staticmethod def ping() -> None: pass @Interface.Method('s') @staticmethod def get_machine_id() -> str: with open('/etc/machine-id', encoding='ascii') as file: return file.read().strip() class org_freedesktop_DBus_Introspectable(Interface): @Interface.Method('s') @classmethod def introspect(cls) -> str: return introspection.to_xml(cls._dbus_interfaces) class org_freedesktop_DBus_Properties(Interface): properties_changed = Interface.Signal('s', 'a{sv}', 'as') @Interface.Method('v', 'ss') def get(self, interface, name): return self._find_member(interface, 'properties', name).to_dbus(self) @Interface.Method(['a{sv}'], 's') def get_all(self, interface): properties = self._find_category(interface, 'properties') return {name: prop.to_dbus(self) for name, prop in properties.items()} @Interface.Method('', 'ssv') def set(self, interface, name, value): self._find_member(interface, 'properties', name).from_dbus(self, value) class Object(org_freedesktop_DBus_Introspectable, org_freedesktop_DBus_Peer, org_freedesktop_DBus_Properties, BaseObject, Interface): """High-level base class for exporting objects on D-Bus This is usually where you should start. This provides a base for exporting objects on the bus, implements the standard D-Bus interfaces, and allows you to add your own interfaces to the mix. See the documentation for Interface to find out how to define and implement your D-Bus interface. """ def message_received(self, message: BusMessage) -> bool: interface = message.get_interface() name = message.get_member() try: method = self._find_member(interface, 'methods', name) assert isinstance(method, Interface.Method) return method._invoke(self, message) except Object.Method.Unhandled: return False