From 95e443c3388efd78e1caea1e89f35065ceaf34f9 Mon Sep 17 00:00:00 2001 From: Ben Finney Date: Mar 02 2023 04:13:19 +0000 Subject: Document a `ServiceRunner` class as an example of using `DaemonContext`. --- diff --git a/ChangeLog b/ChangeLog index 93722d2..13b8d90 100644 --- a/ChangeLog +++ b/ChangeLog @@ -14,6 +14,10 @@ Version NEXT :Released: FUTURE :Maintainer: UNKNOWN +Added: + +* Document a `ServiceRunner` class as an example of using `DaemonContext`. + Changed: * Refactor calculation of file descriptor ranges to close. diff --git a/doc/examples/service-runner.txt b/doc/examples/service-runner.txt new file mode 100644 index 0000000..706926d --- /dev/null +++ b/doc/examples/service-runner.txt @@ -0,0 +1,324 @@ +DaemonContext example: Implementing a system service +#################################################### + +:Author: Ben Finney +:Updated: 2023-03-02 + +A daemon is not a service +========================= + +The `python-daemon` library addresses only Unix-style daemons, where a program +causes *itself* to continue running detached from any controlling terminal. + +There is a related concept in many systems, called a “service”. A service +differs from a daemon, in that rather than having the *current* program +continue to run detached, a service starts an *additional* process to run in +the background, and the current process communicates with that additional +process via some defined channels. + +ServiceRunner class +=================== + +The Unix-style daemon model in `python-daemon` can be used, among other things, +to implement the background-process part of a service. Here is a realistic +example: a `ServiceRunner` class. + +..code:: python + + import errno + import os + import signal + import sys + import warnings + + from daemon import pidfile + from daemon.daemon import DaemonContext + import lockfile + + + class ServiceRunnerError(Exception): + """ Abstract base class for errors from ServiceRunner. """ + + + class ServiceRunnerInvalidActionError(ServiceRunnerError, ValueError): + """ Raised when specified action for ServiceRunner is invalid. """ + + + class ServiceRunnerStartFailureError(ServiceRunnerError, RuntimeError): + """ Raised when failure starting ServiceRunner. """ + + + class ServiceRunnerStopFailureError(ServiceRunnerError, RuntimeError): + """ Raised when failure stopping ServiceRunner. """ + + + class ServiceRunner: + """ Controller for a callable running in a separate background process. + + The first command-line argument is the action to take: + + * 'start': Become a daemon and call `app.run()`. + * 'stop': Exit the daemon process specified in the PID file. + * 'restart': Stop, then start. + """ + + start_message = "started with pid {pid:d}" + + def __init__(self, app): + """ Set up the parameters of a new runner. + + :param app: The application instance; see below. + :return: ``None``. + + The `app` argument must have the following attributes: + + * `stdin_path`, `stdout_path`, `stderr_path`: Filesystem paths + to open and replace the existing `sys.stdin`, `sys.stdout`, + `sys.stderr`. + + * `pidfile_path`: Absolute filesystem path to a file that will + be used as the PID file for the daemon. If ``None``, no PID + file will be used. + + * `pidfile_timeout`: Used as the default acquisition timeout + value supplied to the runner's PID lock file. + + * `run`: Callable that will be invoked when the daemon is + started. + """ + self.parse_args() + self.app = app + self.daemon_context = DaemonContext() + self._open_streams_from_app_stream_paths(app) + + self.pidfile = None + if app.pidfile_path is not None: + self.pidfile = make_pidlockfile( + app.pidfile_path, app.pidfile_timeout) + self.daemon_context.pidfile = self.pidfile + + def _open_streams_from_app_stream_paths(self, app): + """ Open the `daemon_context` streams from the paths specified. + + :param app: The application instance. + + Open the `daemon_context` standard streams (`stdin`, + `stdout`, `stderr`) as stream objects of the appropriate + types, from each of the corresponding filesystem paths + from the `app`. + """ + self.daemon_context.stdin = open(app.stdin_path, 'rt') + self.daemon_context.stdout = open(app.stdout_path, 'w+t') + self.daemon_context.stderr = open( + app.stderr_path, 'w+t', buffering=0) + + def _usage_exit(self, argv): + """ Emit a usage message, then exit. + + :param argv: The command-line arguments used to invoke the + program, as a sequence of strings. + :return: ``None``. + """ + progname = os.path.basename(argv[0]) + usage_exit_code = 2 + action_usage = "|".join(self.action_funcs.keys()) + message = "usage: {progname} {usage}".format( + progname=progname, usage=action_usage) + emit_message(message) + sys.exit(usage_exit_code) + + def parse_args(self, argv=None): + """ Parse command-line arguments. + + :param argv: The command-line arguments used to invoke the + program, as a sequence of strings. + + :return: ``None``. + + The parser expects the first argument as the program name, the + second argument as the action to perform. + + If the parser fails to parse the arguments, emit a usage + message and exit the program. + """ + if argv is None: + argv = sys.argv + + min_args = 2 + if len(argv) < min_args: + self._usage_exit(argv) + + self.action = str(argv[1]) + if self.action not in self.action_funcs: + self._usage_exit(argv) + + def _start(self): + """ Open the daemon context and run the application. + + :return: ``None``. + :raises ServiceRunnerStartFailureError: If the PID file cannot + be locked by this process. + """ + if is_pidfile_stale(self.pidfile): + self.pidfile.break_lock() + + try: + self.daemon_context.open() + except lockfile.AlreadyLocked as exc: + error = ServiceRunnerStartFailureError( + "PID file {pidfile.path!r} already locked".format( + pidfile=self.pidfile)) + raise error from exc + + pid = os.getpid() + message = self.start_message.format(pid=pid) + emit_message(message) + + self.app.run() + + def _terminate_daemon_process(self): + """ Terminate the daemon process specified in the current PID file. + + :return: ``None``. + :raises ServiceRunnerStopFailureError: If terminating the daemon + fails with an OS error. + """ + pid = self.pidfile.read_pid() + try: + os.kill(pid, signal.SIGTERM) + except OSError as exc: + error = ServiceRunnerStopFailureError( + "Failed to terminate {pid:d}: {exc}".format( + pid=pid, exc=exc)) + raise error from exc + + def _stop(self): + """ Exit the daemon process specified in the current PID file. + + :return: ``None``. + :raises ServiceRunnerStopFailureError: If the PID file is not + already locked. + """ + if not self.pidfile.is_locked(): + error = ServiceRunnerStopFailureError( + "PID file {pidfile.path!r} not locked".format( + pidfile=self.pidfile)) + raise error + + if is_pidfile_stale(self.pidfile): + self.pidfile.break_lock() + else: + self._terminate_daemon_process() + + def _restart(self): + """ Stop, then start. """ + self._stop() + self._start() + + action_funcs = { + 'start': _start, + 'stop': _stop, + 'restart': _restart, + } + + def _get_action_func(self): + """ Get the function for the specified action. + + :return: The function object corresponding to the specified + action. + :raises ServiceRunnerInvalidActionError: if the action is + unknown. + + The action is specified by the `action` attribute, which is set + during `parse_args`. + """ + try: + func = self.action_funcs[self.action] + except KeyError: + error = ServiceRunnerInvalidActionError( + "Unknown action: {action!r}".format( + action=self.action)) + raise error + return func + + def do_action(self): + """ Perform the requested action. + + :return: ``None``. + + The action is specified by the `action` attribute, which is set + during `parse_args`. + """ + func = self._get_action_func() + func(self) + + + def emit_message(message, stream=None): + """ Emit a message to the specified stream (default `sys.stderr`). """ + if stream is None: + stream = sys.stderr + stream.write("{message}\n".format(message=message)) + stream.flush() + + + def make_pidlockfile(path, acquire_timeout): + """ Make a PIDLockFile instance with the given filesystem path. """ + if not isinstance(path, str): + error = ValueError("Not a filesystem path: {path!r}".format( + path=path)) + raise error + if not os.path.isabs(path): + error = ValueError("Not an absolute path: {path!r}".format( + path=path)) + raise error + lockfile = pidfile.TimeoutPIDLockFile(path, acquire_timeout) + + return lockfile + + + def is_pidfile_stale(pidfile): + """ Determine whether a PID file is stale. + + :return: ``True`` iff the PID file is stale; otherwise ``False``. + + The PID file is “stale” if its contents are valid but do not + match the PID of a currently-running process. + """ + result = False + + pidfile_pid = pidfile.read_pid() + if pidfile_pid is not None: + try: + os.kill(pidfile_pid, signal.SIG_DFL) + except ProcessLookupError: + # The specified PID does not exist. + result = True + + return result + + + +.. + This document is written using `reStructuredText`_ markup, and can + be rendered with `Docutils`_ to other formats. + + .. _Docutils: https://docutils.sourceforge.io/ + .. _reStructuredText: https://docutils.sourceforge.io/rst.html + +.. + This is free software: you may copy, modify, and/or distribute this work + under the terms of the Apache License version 2.0 as published by the + Apache Software Foundation. + No warranty expressed or implied. See the file ‘LICENSE.ASF-2’ for details. + +.. + Local variables: + coding: utf-8 + mode: rst + mode: text + time-stamp-format: "%:y-%02m-%02d" + time-stamp-start: "^:Updated:[ ]+" + time-stamp-end: "$" + time-stamp-line-limit: 20 + End: + vim: fileencoding=utf-8 filetype=rst :