meerschaum.utils.daemon.Daemon

Manage running daemons via the Daemon class.

   1#! /usr/bin/env python3
   2# -*- coding: utf-8 -*-
   3# vim:fenc=utf-8
   4
   5"""
   6Manage running daemons via the Daemon class.
   7"""
   8
   9from __future__ import annotations
  10import os
  11import importlib
  12import pathlib
  13import json
  14import shutil
  15import signal
  16import time
  17import traceback
  18from functools import partial
  19from datetime import datetime, timezone
  20
  21import meerschaum as mrsm
  22from meerschaum.utils.typing import (
  23    Optional, Dict, Any, SuccessTuple, Callable, List, Union,
  24    is_success_tuple, Tuple,
  25)
  26from meerschaum.config import get_config
  27from meerschaum._internal.static import STATIC_CONFIG
  28from meerschaum.config._patch import apply_patch_to_config
  29from meerschaum.utils.warnings import warn, error
  30from meerschaum.utils.packages import attempt_import
  31from meerschaum.utils.venv import venv_exec
  32from meerschaum.utils.daemon import _detached
  33from meerschaum.utils.daemon._names import get_new_daemon_name
  34from meerschaum.utils.daemon.RotatingFile import RotatingFile
  35from meerschaum.utils.daemon.StdinFile import StdinFile
  36from meerschaum.utils.threading import RepeatTimer
  37from meerschaum.__main__ import _close_pools
  38
  39_daemons = []
  40_results = {}
  41
  42class Daemon:
  43    """
  44    Daemonize Python functions into background processes.
  45
  46    Examples
  47    --------
  48    >>> import meerschaum as mrsm
  49    >>> from meerschaum.utils.daemons import Daemon
  50    >>> daemon = Daemon(print, ('hi',))
  51    >>> success, msg = daemon.run()
  52    >>> print(daemon.log_text)
  53
  54    2024-07-29 18:03 | hi
  55    2024-07-29 18:03 |
  56    >>> daemon.run(allow_dirty_run=True)
  57    >>> print(daemon.log_text)
  58
  59    2024-07-29 18:03 | hi
  60    2024-07-29 18:03 |
  61    2024-07-29 18:05 | hi
  62    2024-07-29 18:05 |
  63    >>> mrsm.pprint(daemon.properties)
  64    {
  65        'label': 'print',
  66        'target': {'name': 'print', 'module': 'builtins', 'args': ['hi'], 'kw': {}},
  67        'result': None,
  68        'process': {'ended': '2024-07-29T18:03:33.752806'}
  69    }
  70
  71    """
  72
  73    def __new__(
  74        cls,
  75        *args,
  76        daemon_id: Optional[str] = None,
  77        **kw
  78    ):
  79        """
  80        If a daemon_id is provided and already exists, read from its pickle file.
  81        """
  82        instance = super(Daemon, cls).__new__(cls)
  83        if daemon_id is not None:
  84            instance.daemon_id = daemon_id
  85            if instance.pickle_path.exists():
  86                instance = instance.read_pickle()
  87        return instance
  88
  89    @classmethod
  90    def from_properties_file(cls, daemon_id: str) -> Daemon:
  91        """
  92        Return a Daemon from a properties dictionary.
  93        """
  94        properties_path = cls._get_properties_path_from_daemon_id(daemon_id)
  95        if not properties_path.exists():
  96            raise OSError(f"Properties file '{properties_path}' does not exist.")
  97
  98        try:
  99            with open(properties_path, 'r', encoding='utf-8') as f:
 100                properties = json.load(f)
 101        except Exception:
 102            properties = {}
 103
 104        if not properties:
 105            raise ValueError(f"No properties could be read for daemon '{daemon_id}'.")
 106
 107        daemon_id = properties_path.parent.name
 108        target_cf = properties.get('target', {})
 109        target_module_name = target_cf.get('module', None)
 110        target_function_name = target_cf.get('name', None)
 111        target_args = target_cf.get('args', None)
 112        target_kw = target_cf.get('kw', None)
 113        label = properties.get('label', None)
 114
 115        if None in [
 116            target_module_name,
 117            target_function_name,
 118            target_args,
 119            target_kw,
 120        ]:
 121            raise ValueError("Missing target function information.")
 122
 123        target_module = importlib.import_module(target_module_name)
 124        target_function = getattr(target_module, target_function_name)
 125
 126        return Daemon(
 127            daemon_id=daemon_id,
 128            target=target_function,
 129            target_args=target_args,
 130            target_kw=target_kw,
 131            properties=properties,
 132            label=label,
 133        )
 134
 135
 136    def __init__(
 137        self,
 138        target: Optional[Callable[[Any], Any]] = None,
 139        target_args: Union[List[Any], Tuple[Any], None] = None,
 140        target_kw: Optional[Dict[str, Any]] = None,
 141        env: Optional[Dict[str, str]] = None,
 142        daemon_id: Optional[str] = None,
 143        label: Optional[str] = None,
 144        properties: Optional[Dict[str, Any]] = None,
 145        pickle: bool = True,
 146    ):
 147        """
 148        Parameters
 149        ----------
 150        target: Optional[Callable[[Any], Any]], default None,
 151            The function to execute in a child process.
 152
 153        target_args: Union[List[Any], Tuple[Any], None], default None
 154            Positional arguments to pass to the target function.
 155
 156        target_kw: Optional[Dict[str, Any]], default None
 157            Keyword arguments to pass to the target function.
 158
 159        env: Optional[Dict[str, str]], default None
 160            If provided, set these environment variables in the daemon process.
 161
 162        daemon_id: Optional[str], default None
 163            Build a `Daemon` from an existing `daemon_id`.
 164            If `daemon_id` is provided, other arguments are ignored and are derived
 165            from the existing pickled `Daemon`.
 166
 167        label: Optional[str], default None
 168            Label string to help identifiy a daemon.
 169            If `None`, use the function name instead.
 170
 171        properties: Optional[Dict[str, Any]], default None
 172            Override reading from the properties JSON by providing an existing dictionary.
 173        """
 174        _pickle = self.__dict__.get('_pickle', False)
 175        if daemon_id is not None:
 176            self.daemon_id = daemon_id
 177            if not self.pickle_path.exists() and not target and ('target' not in self.__dict__):
 178
 179                if not self.properties_path.exists():
 180                    raise Exception(
 181                        f"Daemon '{self.daemon_id}' does not exist. "
 182                        + "Pass a target to create a new Daemon."
 183                    )
 184
 185                try:
 186                    new_daemon = self.from_properties_file(daemon_id)
 187                except Exception:
 188                    new_daemon = None
 189
 190                if new_daemon is not None:
 191                    new_daemon.write_pickle()
 192                    target = new_daemon.target
 193                    target_args = new_daemon.target_args
 194                    target_kw = new_daemon.target_kw
 195                    label = new_daemon.label
 196                    self._properties = new_daemon.properties
 197                else:
 198                    try:
 199                        self.properties_path.unlink()
 200                    except Exception:
 201                        pass
 202
 203                    raise Exception(
 204                        f"Could not recover daemon '{self.daemon_id}' "
 205                        + "from its properties file."
 206                    )
 207
 208        if 'target' not in self.__dict__:
 209            if target is None:
 210                error("Cannot create a Daemon without a target.")
 211            self.target = target
 212
 213        self.pickle = pickle
 214
 215        ### NOTE: We have to check self.__dict__ in case we un-pickling.
 216        if '_target_args' not in self.__dict__:
 217            self._target_args = target_args
 218        if '_target_kw' not in self.__dict__:
 219            self._target_kw = target_kw
 220
 221        if 'label' not in self.__dict__:
 222            if label is None:
 223                label = (
 224                    self.target.__name__ if '__name__' in self.target.__dir__()
 225                        else str(self.target)
 226                )
 227            self.label = label
 228        elif label is not None:
 229            self.label = label
 230
 231        if 'daemon_id' not in self.__dict__:
 232            self.daemon_id = get_new_daemon_name()
 233        if '_properties' not in self.__dict__:
 234            self._properties = properties
 235        elif properties:
 236            if self._properties is None:
 237                self._properties = {}
 238            self._properties.update(properties)
 239        if self._properties is None:
 240            self._properties = {}
 241
 242        self._properties.update({'label': self.label})
 243        if env:
 244            self._properties.update({'env': env})
 245
 246        ### Instantiate the process and if it doesn't exist, make sure the stale PID file
 247        ### is removed. Without a PID file there is nothing to clean up,
 248        ### so don't go looking for a detached process while constructing.
 249        if self.pid_path.exists():
 250            _ = self.process
 251
 252
 253    def _run_exit(
 254        self,
 255        keep_daemon_output: bool = True,
 256        allow_dirty_run: bool = False,
 257    ) -> Any:
 258        """Run the daemon's target function.
 259        NOTE: This WILL EXIT the parent process!
 260
 261        Parameters
 262        ----------
 263        keep_daemon_output: bool, default True
 264            If `False`, delete the daemon's output directory upon exiting.
 265
 266        allow_dirty_run, bool, default False:
 267            If `True`, run the daemon, even if the `daemon_id` directory exists.
 268            This option is dangerous because if the same `daemon_id` runs twice,
 269            the last to finish will overwrite the output of the first.
 270
 271        Returns
 272        -------
 273        Nothing — this will exit the parent process.
 274        """
 275        import platform
 276        import sys
 277        import os
 278        import traceback
 279        from meerschaum.utils.warnings import warn
 280        from meerschaum.config import get_config
 281        daemon = attempt_import('daemon')
 282        lines = get_config('jobs', 'terminal', 'lines')
 283        columns = get_config('jobs', 'terminal', 'columns')
 284
 285        if platform.system() == 'Windows':
 286            return False, "Windows is no longer supported."
 287
 288        self._setup(allow_dirty_run)
 289
 290        _daemons.append(self)
 291
 292        logs_cf = self.properties.get('logs', {})
 293        log_refresh_seconds = logs_cf.get('refresh_files_seconds', None)
 294        if log_refresh_seconds is None:
 295            log_refresh_seconds = get_config('jobs', 'logs', 'refresh_files_seconds')
 296        write_timestamps = logs_cf.get('write_timestamps', None)
 297        if write_timestamps is None:
 298            write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled')
 299
 300        self._log_refresh_timer = RepeatTimer(
 301            log_refresh_seconds,
 302            partial(self.rotating_log.refresh_files, start_interception=write_timestamps),
 303        )
 304
 305        capture_stdin = logs_cf.get('stdin', True)
 306        cwd = self.properties.get('cwd', os.getcwd())
 307
 308        ### NOTE: The SIGINT handler has been removed so that child processes may handle
 309        ###       KeyboardInterrupts themselves.
 310        ###       The previous aggressive approach was redundant because of the SIGTERM handler.
 311        self._daemon_context = daemon.DaemonContext(
 312            pidfile=self.pid_lock,
 313            stdout=self.rotating_log,
 314            stderr=self.rotating_log,
 315            stdin=(self.stdin_file if capture_stdin else None),
 316            working_directory=cwd,
 317            detach_process=True,
 318            files_preserve=list(self.rotating_log.subfile_objects.values()),
 319            signal_map={
 320                signal.SIGTERM: self._handle_sigterm,
 321            },
 322        )
 323
 324        if capture_stdin and sys.stdin is None:
 325            raise OSError("Cannot daemonize without stdin.")
 326
 327        try:
 328            os.environ['LINES'], os.environ['COLUMNS'] = str(int(lines)), str(int(columns))
 329            with self._daemon_context:
 330                if capture_stdin:
 331                    sys.stdin = self.stdin_file
 332                _ = os.environ.pop(STATIC_CONFIG['environment']['systemd_stdin_path'], None)
 333                os.environ[STATIC_CONFIG['environment']['daemon_id']] = self.daemon_id
 334                os.environ['PYTHONUNBUFFERED'] = '1'
 335
 336                ### Allow the user to override environment variables.
 337                env = self.properties.get('env', {})
 338                if env and isinstance(env, dict):
 339                    os.environ.update({str(k): str(v) for k, v in env.items()})
 340
 341                self.rotating_log.refresh_files(start_interception=True)
 342                result = None
 343                try:
 344                    with open(self.pid_path, 'w+', encoding='utf-8') as f:
 345                        f.write(str(os.getpid()))
 346
 347                    ### NOTE: The timer fails to start for remote actions to localhost.
 348                    try:
 349                        if not self._log_refresh_timer.is_running():
 350                            self._log_refresh_timer.start()
 351                    except Exception:
 352                        pass
 353
 354                    self.properties['result'] = None
 355                    self._capture_process_timestamp('began')
 356                    result = self.target(*self.target_args, **self.target_kw)
 357                    self.properties['result'] = result
 358                except SystemExit as e:
 359                    result = (
 360                        (True, f"Stopped daemon '{self.daemon_id}'.")
 361                        if e.code is None or e.code == 0
 362                        else (False, traceback.format_exc())
 363                    )
 364                except (BrokenPipeError, KeyboardInterrupt):
 365                    result = False, traceback.format_exc()
 366                except Exception as e:
 367                    warn(
 368                        f"Exception in daemon target function: {traceback.format_exc()}",
 369                    )
 370                    result = False, str(e)
 371                finally:
 372                    _results[self.daemon_id] = result
 373                    self.properties['result'] = result
 374
 375                    if keep_daemon_output:
 376                        self._capture_process_timestamp('ended')
 377                    else:
 378                        self.cleanup()
 379
 380                    self._log_refresh_timer.cancel()
 381                    try:
 382                        if self.pid is None and self.pid_path.exists():
 383                            self.pid_path.unlink()
 384                    except Exception:
 385                        pass
 386
 387                    if is_success_tuple(result):
 388                        try:
 389                            mrsm.pprint(result)
 390                        except BrokenPipeError:
 391                            pass
 392
 393        except Exception:
 394            daemon_error = traceback.format_exc()
 395            import meerschaum.config.paths as paths
 396            with open(paths.DAEMON_ERROR_LOG_PATH, 'a+', encoding='utf-8') as f:
 397                f.write(
 398                    f"Error in Daemon '{self}':\n\n"
 399                    f"{sys.stdin=}\n"
 400                    f"{self.stdin_file_path=}\n"
 401                    f"{self.stdin_file_path.exists()=}\n\n"
 402                    f"{daemon_error}\n\n"
 403                )
 404            warn(f"Encountered an error while running the daemon '{self}':\n{daemon_error}")
 405
 406    def _capture_process_timestamp(
 407        self,
 408        process_key: str,
 409        write_properties: bool = True,
 410    ) -> None:
 411        """
 412        Record the current timestamp to the parameters `process:<process_key>`.
 413
 414        Parameters
 415        ----------
 416        process_key: str
 417            Under which key to store the timestamp.
 418
 419        write_properties: bool, default True
 420            If `True` persist the properties to disk immediately after capturing the timestamp.
 421        """
 422        if 'process' not in self.properties:
 423            self.properties['process'] = {}
 424
 425        if process_key not in ('began', 'ended', 'paused', 'stopped', 'launched'):
 426            raise ValueError(f"Invalid key '{process_key}'.")
 427
 428        self.properties['process'][process_key] = (
 429            datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
 430        )
 431        if write_properties:
 432            self.write_properties()
 433
 434    def run(
 435        self,
 436        keep_daemon_output: bool = True,
 437        allow_dirty_run: bool = False,
 438        wait: bool = False,
 439        timeout: Union[int, float] = 4,
 440        debug: bool = False,
 441    ) -> SuccessTuple:
 442        """Run the daemon as a child process and continue executing the parent.
 443
 444        Parameters
 445        ----------
 446        keep_daemon_output: bool, default True
 447            If `False`, delete the daemon's output directory upon exiting.
 448
 449        allow_dirty_run: bool, default False
 450            If `True`, run the daemon, even if the `daemon_id` directory exists.
 451            This option is dangerous because if the same `daemon_id` runs concurrently,
 452            the last to finish will overwrite the output of the first.
 453
 454        wait: bool, default True
 455            If `True`, block until `Daemon.status` is running (or the timeout expires).
 456
 457        timeout: Union[int, float], default 4
 458            If `wait` is `True`, block for up to `timeout` seconds before returning a failure.
 459
 460        Returns
 461        -------
 462        A SuccessTuple indicating success.
 463
 464        """
 465        import platform
 466        if platform.system() == 'Windows':
 467            return False, "Cannot run background jobs on Windows."
 468
 469        ### The daemon might exist and be paused.
 470        if self.status == 'paused':
 471            return self.resume()
 472
 473        self._remove_stop_file()
 474        if self.status == 'running':
 475            return True, f"Daemon '{self}' is already running."
 476
 477        self.mkdir_if_not_exists(allow_dirty_run)
 478        _write_pickle_success_tuple = self.write_pickle()
 479        if not _write_pickle_success_tuple[0]:
 480            return _write_pickle_success_tuple
 481
 482        ### The PID file does not exist until the new process has started up. Record the launch
 483        ### so that a job which completed earlier is not mistaken for a finished one meanwhile.
 484        _detached.invalidate(self.daemon_id)
 485        if self.properties_path.exists():
 486            self._capture_process_timestamp('launched')
 487
 488        _launch_daemon_code = (
 489            "from meerschaum.utils.daemon import Daemon, _daemons; "
 490            f"daemon = Daemon(daemon_id='{self.daemon_id}'); "
 491            f"_daemons['{self.daemon_id}'] = daemon; "
 492            f"daemon._run_exit(keep_daemon_output={keep_daemon_output}, "
 493            "allow_dirty_run=True)"
 494        )
 495        env = dict(os.environ)
 496        _launch_success_bool = venv_exec(_launch_daemon_code, debug=debug, venv=None, env=env)
 497        _detached.invalidate(self.daemon_id)
 498        msg = (
 499            "Success"
 500            if _launch_success_bool
 501            else f"Failed to start daemon '{self.daemon_id}'."
 502        )
 503        if not wait or not _launch_success_bool:
 504            return _launch_success_bool, msg
 505
 506        timeout = self.get_timeout_seconds(timeout)
 507        check_timeout_interval = self.get_check_timeout_interval_seconds()
 508
 509        if not timeout:
 510            success = self.status == 'running'
 511            msg = "Success" if success else f"Failed to run daemon '{self.daemon_id}'."
 512            if success:
 513                self._capture_process_timestamp('began')
 514            return success, msg
 515
 516        begin = time.perf_counter()
 517        while (time.perf_counter() - begin) < timeout:
 518            if self.status == 'running':
 519                self._capture_process_timestamp('began')
 520                return True, "Success"
 521            time.sleep(check_timeout_interval)
 522
 523        return False, (
 524            f"Failed to start daemon '{self.daemon_id}' within {timeout} second"
 525            + ('s' if timeout != 1 else '') + '.'
 526        )
 527
 528
 529    def kill(self, timeout: Union[int, float, None] = 8) -> SuccessTuple:
 530        """
 531        Forcibly terminate a running daemon.
 532        Sends a SIGTERM signal to the process.
 533
 534        Parameters
 535        ----------
 536        timeout: Optional[int], default 3
 537            How many seconds to wait for the process to terminate.
 538
 539        Returns
 540        -------
 541        A SuccessTuple indicating success.
 542        """
 543        _detached.invalidate(self.daemon_id)
 544
 545        ### A lost PID file means a detached/orphaned daemon — possibly SEVERAL processes
 546        ### sharing this daemon_id (e.g. accumulated across crashed restarts). Reap them
 547        ### ALL by daemon_id rather than reporting a false "stopped" and stranding them
 548        ### (which forces a manual `pkill meerschaum` or hunting the daemon_id in htop).
 549        if not self.pid_path.exists():
 550            reaped = self._kill_detached_processes(timeout)
 551            self._write_stop_file('kill')
 552            self.stdin_file.close()
 553            self._remove_blocking_stdin_file()
 554            return True, (
 555                (f"Reaped {reaped} detached process" + ('es' if reaped != 1 else '') + '.')
 556                if reaped
 557                else "Process has already stopped."
 558            )
 559
 560        if self.status != 'paused':
 561            success, msg = self._send_signal(signal.SIGTERM, timeout=timeout)
 562            if success:
 563                ### Sweep any sibling/detached processes left over for this daemon_id.
 564                self._kill_detached_processes(timeout)
 565                self._write_stop_file('kill')
 566                self.stdin_file.close()
 567                self._remove_blocking_stdin_file()
 568                return success, msg
 569
 570        if self.status == 'stopped':
 571            reaped = self._kill_detached_processes(timeout)
 572            self._write_stop_file('kill')
 573            self.stdin_file.close()
 574            self._remove_blocking_stdin_file()
 575            return True, (
 576                (f"Reaped {reaped} detached process" + ('es' if reaped != 1 else '') + '.')
 577                if reaped
 578                else "Process has already stopped."
 579            )
 580
 581        psutil = attempt_import('psutil')
 582        process = self.process
 583        try:
 584            process.terminate()
 585            process.kill()
 586            process.wait(timeout=timeout)
 587        except Exception as e:
 588            return False, f"Failed to kill job {self} ({process}) with exception: {e}"
 589
 590        try:
 591            if process.status():
 592                return False, "Failed to stop daemon '{self}' ({process})."
 593        except psutil.NoSuchProcess:
 594            pass
 595
 596        if self.pid_path.exists():
 597            try:
 598                self.pid_path.unlink()
 599            except Exception:
 600                pass
 601
 602        self._write_stop_file('kill')
 603        self.stdin_file.close()
 604        self._remove_blocking_stdin_file()
 605        return True, "Success"
 606
 607    def quit(self, timeout: Union[int, float, None] = None) -> SuccessTuple:
 608        """Gracefully quit a running daemon."""
 609        _detached.invalidate(self.daemon_id)
 610        if self.status == 'paused':
 611            return self.kill(timeout)
 612
 613        ### A lost PID file means a detached/orphaned daemon (possibly several processes);
 614        ### the normal signal path can't see them, so reap them all by daemon_id instead
 615        ### of returning a false "not running".
 616        if not self.pid_path.exists():
 617            reaped = self._kill_detached_processes(timeout)
 618            self._write_stop_file('quit')
 619            self.stdin_file.close()
 620            self._remove_blocking_stdin_file()
 621            return True, (
 622                (f"Reaped {reaped} detached process" + ('es' if reaped != 1 else '') + '.')
 623                if reaped
 624                else "Process is not running."
 625            )
 626
 627        signal_success, signal_msg = self._send_signal(signal.SIGINT, timeout=timeout)
 628        if signal_success:
 629            self._write_stop_file('quit')
 630            self.stdin_file.close()
 631            self._remove_blocking_stdin_file()
 632        return signal_success, signal_msg
 633
 634    def pause(
 635        self,
 636        timeout: Union[int, float, None] = None,
 637        check_timeout_interval: Union[float, int, None] = None,
 638    ) -> SuccessTuple:
 639        """
 640        Pause the daemon if it is running.
 641
 642        Parameters
 643        ----------
 644        timeout: Union[float, int, None], default None
 645            The maximum number of seconds to wait for a process to suspend.
 646
 647        check_timeout_interval: Union[float, int, None], default None
 648            The number of seconds to wait between checking if the process is still running.
 649
 650        Returns
 651        -------
 652        A `SuccessTuple` indicating whether the `Daemon` process was successfully suspended.
 653        """
 654        self._remove_blocking_stdin_file()
 655        _detached.invalidate(self.daemon_id)
 656
 657        if self.process is None:
 658            return False, f"Daemon '{self.daemon_id}' is not running and cannot be paused."
 659
 660        if self.status == 'paused':
 661            return True, f"Daemon '{self.daemon_id}' is already paused."
 662
 663        self._write_stop_file('pause')
 664        self.stdin_file.close()
 665        self._remove_blocking_stdin_file()
 666        try:
 667            self.process.suspend()
 668        except Exception as e:
 669            return False, f"Failed to pause daemon '{self.daemon_id}':\n{e}"
 670
 671        timeout = self.get_timeout_seconds(timeout)
 672        check_timeout_interval = self.get_check_timeout_interval_seconds(
 673            check_timeout_interval
 674        )
 675
 676        psutil = attempt_import('psutil')
 677
 678        if not timeout:
 679            try:
 680                success = self.process.status() == 'stopped'
 681            except psutil.NoSuchProcess:
 682                success = True
 683            msg = "Success" if success else f"Failed to suspend daemon '{self.daemon_id}'."
 684            if success:
 685                self._capture_process_timestamp('paused')
 686            return success, msg
 687
 688        begin = time.perf_counter()
 689        while (time.perf_counter() - begin) < timeout:
 690            try:
 691                if self.process.status() == 'stopped':
 692                    self._capture_process_timestamp('paused')
 693                    return True, "Success"
 694            except psutil.NoSuchProcess as e:
 695                return False, f"Process exited unexpectedly. Was it killed?\n{e}"
 696            time.sleep(check_timeout_interval)
 697
 698        return False, (
 699            f"Failed to pause daemon '{self.daemon_id}' within {timeout} second"
 700            + ('s' if timeout != 1 else '') + '.'
 701        )
 702
 703    def resume(
 704        self,
 705        timeout: Union[int, float, None] = None,
 706        check_timeout_interval: Union[float, int, None] = None,
 707    ) -> SuccessTuple:
 708        """
 709        Resume the daemon if it is paused.
 710
 711        Parameters
 712        ----------
 713        timeout: Union[float, int, None], default None
 714            The maximum number of seconds to wait for a process to resume.
 715
 716        check_timeout_interval: Union[float, int, None], default None
 717            The number of seconds to wait between checking if the process is still stopped.
 718
 719        Returns
 720        -------
 721        A `SuccessTuple` indicating whether the `Daemon` process was successfully resumed.
 722        """
 723        _detached.invalidate(self.daemon_id)
 724        if self.status == 'running':
 725            return True, f"Daemon '{self.daemon_id}' is already running."
 726
 727        if self.status == 'stopped':
 728            return False, f"Daemon '{self.daemon_id}' is stopped and cannot be resumed."
 729
 730        self._remove_stop_file()
 731        try:
 732            if self.process is None:
 733                return False, f"Cannot resume daemon '{self.daemon_id}'."
 734
 735            self.process.resume()
 736        except Exception as e:
 737            return False, f"Failed to resume daemon '{self.daemon_id}':\n{e}"
 738
 739        timeout = self.get_timeout_seconds(timeout)
 740        check_timeout_interval = self.get_check_timeout_interval_seconds(
 741            check_timeout_interval
 742        )
 743
 744        if not timeout:
 745            success = self.status == 'running'
 746            msg = "Success" if success else f"Failed to resume daemon '{self.daemon_id}'."
 747            if success:
 748                self._capture_process_timestamp('began')
 749            return success, msg
 750
 751        begin = time.perf_counter()
 752        while (time.perf_counter() - begin) < timeout:
 753            if self.status == 'running':
 754                self._capture_process_timestamp('began')
 755                return True, "Success"
 756            time.sleep(check_timeout_interval)
 757
 758        return False, (
 759            f"Failed to resume daemon '{self.daemon_id}' within {timeout} second"
 760            + ('s' if timeout != 1 else '') + '.'
 761        )
 762
 763    def _write_stop_file(self, action: str) -> SuccessTuple:
 764        """Write the stop file timestamp and action."""
 765        if action not in ('quit', 'kill', 'pause'):
 766            return False, f"Unsupported action '{action}'."
 767
 768        if not self.stop_path.parent.exists():
 769            self.stop_path.parent.mkdir(parents=True, exist_ok=True)
 770
 771        with open(self.stop_path, 'w+', encoding='utf-8') as f:
 772            json.dump(
 773                {
 774                    'stop_time': datetime.now(timezone.utc).isoformat(),
 775                    'action': action,
 776                },
 777                f
 778            )
 779
 780        return True, "Success"
 781
 782    def _remove_stop_file(self) -> SuccessTuple:
 783        """Remove the stop file"""
 784        if not self.stop_path.exists():
 785            return True, "Stop file does not exist."
 786
 787        try:
 788            self.stop_path.unlink()
 789        except Exception as e:
 790            return False, f"Failed to remove stop file:\n{e}"
 791
 792        return True, "Success"
 793
 794    def _read_stop_file(self) -> Dict[str, Any]:
 795        """
 796        Read the stop file if it exists.
 797        """
 798        if not self.stop_path.exists():
 799            return {}
 800
 801        try:
 802            with open(self.stop_path, 'r', encoding='utf-8') as f:
 803                data = json.load(f)
 804            return data
 805        except Exception:
 806            return {}
 807
 808    def _remove_blocking_stdin_file(self) -> mrsm.SuccessTuple:
 809        """
 810        Remove the blocking STDIN file if it exists.
 811        """
 812        try:
 813            if self.blocking_stdin_file_path.exists():
 814                self.blocking_stdin_file_path.unlink()
 815        except Exception as e:
 816            return False, str(e)
 817
 818        return True, "Success"
 819
 820    def _handle_sigterm(self, signal_number: int, stack_frame: 'frame') -> None:
 821        """
 822        Handle `SIGTERM` within the `Daemon` context.
 823        This method is injected into the `DaemonContext`.
 824        """
 825        from meerschaum.utils.process import signal_handler
 826        from meerschaum.utils.threading import request_stop, interrupt_threads
 827        signal_handler(signal_number, stack_frame)
 828
 829        ### Tell cooperative loops (e.g. `sync pipes`) to stop, then actively unwind
 830        ### any worker threads so they cannot keep the process alive as a zombie.
 831        request_stop()
 832        interrupt_threads(SystemExit)
 833
 834        timer = self.__dict__.get('_log_refresh_timer', None)
 835        if timer is not None:
 836            timer.cancel()
 837
 838        daemon_context = self.__dict__.get('_daemon_context', None)
 839        if daemon_context is not None:
 840            daemon_context.close()
 841
 842        _close_pools()
 843        raise SystemExit(0)
 844
 845    def _send_signal(
 846        self,
 847        signal_to_send,
 848        timeout: Union[float, int, None] = None,
 849        check_timeout_interval: Union[float, int, None] = None,
 850    ) -> SuccessTuple:
 851        """Send a signal to the daemon process.
 852
 853        Parameters
 854        ----------
 855        signal_to_send:
 856            The signal the send to the daemon, e.g. `signals.SIGINT`.
 857
 858        timeout: Union[float, int, None], default None
 859            The maximum number of seconds to wait for a process to terminate.
 860
 861        check_timeout_interval: Union[float, int, None], default None
 862            The number of seconds to wait between checking if the process is still running.
 863
 864        Returns
 865        -------
 866        A SuccessTuple indicating success.
 867        """
 868        try:
 869            pid = self.pid
 870            if pid is None:
 871                return (
 872                    False,
 873                    f"Daemon '{self.daemon_id}' is not running, "
 874                    + f"cannot send signal '{signal_to_send}'."
 875                )
 876            
 877            os.kill(pid, signal_to_send)
 878        except Exception:
 879            return False, f"Failed to send signal {signal_to_send}:\n{traceback.format_exc()}"
 880
 881        timeout = self.get_timeout_seconds(timeout)
 882        check_timeout_interval = self.get_check_timeout_interval_seconds(
 883            check_timeout_interval
 884        )
 885
 886        if not timeout:
 887            return True, f"Successfully sent '{signal_to_send}' to daemon '{self.daemon_id}'."
 888
 889        begin = time.perf_counter()
 890        while (time.perf_counter() - begin) < timeout:
 891            if not self.status == 'running':
 892                return True, "Success"
 893            time.sleep(check_timeout_interval)
 894
 895        return False, (
 896            f"Failed to stop daemon '{self.daemon_id}' (PID: {pid}) within {timeout} second"
 897            + ('s' if timeout != 1 else '') + '.'
 898        )
 899
 900    def _get_process_timestamps(self) -> Dict[str, Optional[datetime]]:
 901        """
 902        Return the `began`, `ended`, and `launched` timestamps recorded for this daemon,
 903        read from the properties file (so no process is inspected).
 904        """
 905        try:
 906            properties = self.read_properties()
 907        except Exception:
 908            properties = None
 909        if not properties:
 910            properties = self.__dict__.get('_properties', None) or {}
 911        process_properties = properties.get('process', None) or {}
 912        return {
 913            key: _detached.parse_timestamp(process_properties.get(key, None))
 914            for key in ('began', 'ended', 'launched')
 915        }
 916
 917    @staticmethod
 918    def _is_completed(timestamps: Dict[str, Optional[datetime]]) -> bool:
 919        """
 920        Return whether the timestamps show that the last run has ended.
 921        A job which was restarted has `began` or `launched` later than its stale `ended`.
 922        """
 923        ended = timestamps.get('ended', None)
 924        if ended is None:
 925            return False
 926        started = [
 927            moment
 928            for moment in (timestamps.get('began', None), timestamps.get('launched', None))
 929            if moment is not None
 930        ]
 931        return not started or ended >= max(started)
 932
 933    def _find_detached_pids(
 934        self,
 935        force: bool = False,
 936        verify_began: bool = False,
 937    ) -> List[int]:
 938        """
 939        Return the PIDs of any processes whose command line references this daemon_id.
 940
 941        A daemon is launched via `venv_exec` with `Daemon(daemon_id='<id>')` embedded in
 942        the executed code, so the daemon_id stays visible in the process's command line.
 943        When the PID file is lost (e.g. the launcher exited without cleanup, leaving an
 944        orphaned/detached daemon), this is the only reliable way to find the process —
 945        otherwise stopping the job reports a false "already stopped" and the user must
 946        resort to `pkill meerschaum` or hunting the daemon_id in `htop`.
 947
 948        Scanning the process table is expensive, so unless `force` is `True`
 949        no scan happens for a daemon which does not exist or whose last run has ended,
 950        one scan is shared by all daemons for a couple of seconds,
 951        and a daemon which was not found is not searched for again for a minute.
 952
 953        Parameters
 954        ----------
 955        force: bool, default False
 956            If `True`, search even if the daemon is recorded as completed or was not found
 957            recently (e.g. to stop it).
 958
 959        verify_began: bool, default False
 960            If `True`, ignore processes which started after the daemon recorded
 961            that it began (a reused PID).
 962        """
 963        timestamps = self._get_process_timestamps()
 964        fingerprint = tuple(
 965            moment.isoformat() if moment is not None else None
 966            for moment in (timestamps['began'], timestamps['ended'], timestamps['launched'])
 967        )
 968        began = timestamps['began']
 969        launched = timestamps['launched']
 970        in_launch_window = (
 971            launched is not None
 972            and (began is None or began < launched)
 973            and (
 974                (datetime.now(timezone.utc).replace(tzinfo=None) - launched).total_seconds()
 975                < _detached.LAUNCH_GRACE_SECONDS
 976            )
 977        )
 978        if not force:
 979            if not self.path.exists() or self._is_completed(timestamps):
 980                return []
 981
 982            if not in_launch_window and _detached.is_known_missing(
 983                self.daemon_id,
 984                fingerprint,
 985            ):
 986                return []
 987
 988        pids = _detached.find_pids(
 989            self.daemon_id,
 990            not_older_than=(launched if in_launch_window else None),
 991        )
 992
 993        if verify_began and pids:
 994            if began is not None and (launched is None or began >= launched):
 995                pids = [
 996                    pid
 997                    for pid in pids
 998                    if not _detached.is_stale_process(
 999                        pid,
1000                        began,
1001                        _detached.CREATE_TIME_SLACK_SECONDS,
1002                    )
1003                ]
1004
1005        if not pids and not force and not in_launch_window:
1006            _detached.remember_missing(self.daemon_id, fingerprint)
1007        return pids
1008
1009    def _restore_pid_file(self, pid: int) -> None:
1010        """
1011        Write the PID file again for a running daemon which lost it, so that later reads
1012        do not have to search for the process. This is only done once the daemon has
1013        recorded that it began, because before that the process found may be the launcher.
1014        """
1015        timestamps = self._get_process_timestamps()
1016        began, launched = timestamps['began'], timestamps['launched']
1017        if began is None or (launched is not None and began < launched):
1018            return
1019        try:
1020            if not self.pid_path.exists():
1021                with open(self.pid_path, 'w+', encoding='utf-8') as f:
1022                    f.write(str(pid))
1023        except Exception:
1024            pass
1025
1026    def _kill_detached_processes(self, timeout: Union[int, float, None] = None) -> int:
1027        """
1028        SIGTERM then SIGKILL any detached processes referencing this daemon_id.
1029
1030        Fallback for when the PID file is gone but the daemon (or its threads) are still
1031        alive. Returns the number of processes that were targeted.
1032        """
1033        psutil = attempt_import('psutil')
1034        pids = self._find_detached_pids(force=True)
1035        if not pids:
1036            return 0
1037
1038        procs = []
1039        for pid in pids:
1040            try:
1041                procs.append(psutil.Process(pid))
1042            except Exception:
1043                continue
1044        for proc in procs:
1045            try:
1046                proc.terminate()
1047            except Exception:
1048                pass
1049
1050        timeout = self.get_timeout_seconds(timeout)
1051        try:
1052            _, alive = psutil.wait_procs(procs, timeout=(timeout or 8))
1053        except Exception:
1054            alive = procs
1055        for proc in alive:
1056            try:
1057                proc.kill()
1058            except Exception:
1059                pass
1060        _detached.invalidate(self.daemon_id)
1061        return len(procs)
1062
1063    def mkdir_if_not_exists(self, allow_dirty_run: bool = False):
1064        """Create the Daemon's directory.
1065        If `allow_dirty_run` is `False` and the directory already exists,
1066        raise a `FileExistsError`.
1067        """
1068        try:
1069            self.path.mkdir(parents=True, exist_ok=True)
1070            _already_exists = any(os.scandir(self.path))
1071        except FileExistsError:
1072            _already_exists = True
1073
1074        if _already_exists and not allow_dirty_run:
1075            error(
1076                f"Daemon '{self.daemon_id}' already exists. " +
1077                "To allow this daemon to run, do one of the following:\n"
1078                + "  - Execute `daemon.cleanup()`.\n"
1079                + f"  - Delete the directory '{self.path}'.\n"
1080                + "  - Pass `allow_dirty_run=True` to `daemon.run()`.\n",
1081                FileExistsError,
1082            )
1083
1084    @property
1085    def process(self) -> Union['psutil.Process', None]:
1086        """
1087        Return the psutil process for the Daemon.
1088        """
1089        psutil = attempt_import('psutil')
1090        pid = self.pid
1091        if pid is None:
1092            return None
1093        if '_process' not in self.__dict__ or self.__dict__['_process'].pid != int(pid):
1094            try:
1095                self._process = psutil.Process(int(pid))
1096                process_exists = True
1097            except Exception:
1098                process_exists = False
1099            if not process_exists:
1100                _ = self.__dict__.pop('_process', None)
1101                try:
1102                    if self.pid_path.exists():
1103                        self.pid_path.unlink()
1104                except Exception:
1105                    pass
1106                return None
1107        return self._process
1108
1109    @property
1110    def status(self) -> str:
1111        """
1112        Return the running status of this Daemon.
1113        """
1114        process = self.process
1115        if process is None:
1116            return 'stopped'
1117
1118        psutil = attempt_import('psutil', lazy=False)
1119        try:
1120            process_status = process.status()
1121            if process_status == 'stopped':
1122                return 'paused'
1123            if process_status == 'zombie':
1124                raise psutil.NoSuchProcess(process.pid)
1125        except (psutil.NoSuchProcess, AttributeError):
1126            if self.pid_path.exists():
1127                try:
1128                    self.pid_path.unlink()
1129                except Exception:
1130                    pass
1131            return 'stopped'
1132
1133        return 'running'
1134
1135    @classmethod
1136    def _get_path_from_daemon_id(cls, daemon_id: str) -> pathlib.Path:
1137        """
1138        Return a Daemon's path from its `daemon_id`.
1139        """
1140        import meerschaum.config.paths as paths
1141        return paths.DAEMON_RESOURCES_PATH / daemon_id
1142
1143    @property
1144    def path(self) -> pathlib.Path:
1145        """
1146        Return the path for this Daemon's directory.
1147        """
1148        return self._get_path_from_daemon_id(self.daemon_id)
1149
1150    @classmethod
1151    def _get_properties_path_from_daemon_id(cls, daemon_id: str) -> pathlib.Path:
1152        """
1153        Return the `properties.json` path for a given `daemon_id`.
1154        """
1155        return cls._get_path_from_daemon_id(daemon_id) / 'properties.json'
1156
1157    @property
1158    def properties_path(self) -> pathlib.Path:
1159        """
1160        Return the `propterties.json` path for this Daemon.
1161        """
1162        return self._get_properties_path_from_daemon_id(self.daemon_id)
1163
1164    @property
1165    def stop_path(self) -> pathlib.Path:
1166        """
1167        Return the path for the stop file (created when manually stopped).
1168        """
1169        return self.path / '.stop.json'
1170
1171    @property
1172    def log_path(self) -> pathlib.Path:
1173        """
1174        Return the log path.
1175        """
1176        logs_cf = self.properties.get('logs', None) or {}
1177        if 'path' not in logs_cf:
1178            import meerschaum.config.paths as paths
1179            return paths.LOGS_RESOURCES_PATH / (self.daemon_id + '.log')
1180
1181        return pathlib.Path(logs_cf['path'])
1182
1183    @property
1184    def stdin_file_path(self) -> pathlib.Path:
1185        """
1186        Return the stdin file path.
1187        """
1188        return self.path / 'input.stdin'
1189
1190    @property
1191    def blocking_stdin_file_path(self) -> pathlib.Path:
1192        """
1193        Return the stdin file path.
1194        """
1195        if '_blocking_stdin_file_path' in self.__dict__:
1196            return self._blocking_stdin_file_path
1197
1198        return self.path / 'input.stdin.block'
1199
1200    @property
1201    def prompt_kwargs_file_path(self) -> pathlib.Path:
1202        """
1203        Return the file path to the kwargs for the invoking `prompt()`.
1204        """
1205        return self.path / 'prompt_kwargs.json'
1206
1207    @property
1208    def log_offset_path(self) -> pathlib.Path:
1209        """
1210        Return the log offset file path.
1211        """
1212        import meerschaum.config.paths as paths
1213        return paths.LOGS_RESOURCES_PATH / ('.' + self.daemon_id + '.log.offset')
1214
1215    @property
1216    def log_offset_lock(self) -> 'InterProcessLock':
1217        """
1218        Return the process lock context manager.
1219        """
1220        if '_log_offset_lock' in self.__dict__:
1221            return self._log_offset_lock
1222
1223        from meerschaum.utils.locks import InterProcessLock
1224        self._log_offset_lock = InterProcessLock(self.log_offset_path)
1225        return self._log_offset_lock
1226
1227    @property
1228    def rotating_log(self) -> RotatingFile:
1229        """
1230        The rotating log file for the daemon's output.
1231        """
1232        if '_rotating_log' in self.__dict__:
1233            return self._rotating_log
1234
1235        logs_cf = self.properties.get('logs', None) or {}
1236        write_timestamps = logs_cf.get('write_timestamps', None)
1237        if write_timestamps is None:
1238            write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled')
1239
1240        timestamp_format = logs_cf.get('timestamp_format', None)
1241        if timestamp_format is None:
1242            timestamp_format = get_config('jobs', 'logs', 'timestamps', 'format')
1243
1244        num_files_to_keep = logs_cf.get('num_files_to_keep', None)
1245        if num_files_to_keep is None:
1246            num_files_to_keep = get_config('jobs', 'logs', 'num_files_to_keep')
1247
1248        max_file_size = logs_cf.get('max_file_size', None)
1249        if max_file_size is None:
1250            max_file_size = get_config('jobs', 'logs', 'max_file_size')
1251
1252        redirect_streams = logs_cf.get('redirect_streams', True)
1253
1254        self._rotating_log = RotatingFile(
1255            self.log_path,
1256            redirect_streams=redirect_streams,
1257            write_timestamps=write_timestamps,
1258            timestamp_format=timestamp_format,
1259            num_files_to_keep=num_files_to_keep,
1260            max_file_size=max_file_size,
1261        )
1262        return self._rotating_log
1263
1264    @property
1265    def stdin_file(self):
1266        """
1267        Return the file handler for the stdin file.
1268        """
1269        if (_stdin_file := self.__dict__.get('_stdin_file', None)):
1270            return _stdin_file
1271
1272        self._stdin_file = StdinFile(
1273            self.stdin_file_path,
1274            lock_file_path=self.blocking_stdin_file_path,
1275        )
1276        return self._stdin_file
1277
1278    @property
1279    def log_text(self) -> Union[str, None]:
1280        """
1281        Read the log files and return their contents.
1282        Returns `None` if the log file does not exist.
1283        """
1284        logs_cf = self.properties.get('logs', None) or {}
1285        write_timestamps = logs_cf.get('write_timestamps', None)
1286        if write_timestamps is None:
1287            write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled')
1288
1289        timestamp_format = logs_cf.get('timestamp_format', None)
1290        if timestamp_format is None:
1291            timestamp_format = get_config('jobs', 'logs', 'timestamps', 'format')
1292
1293        num_files_to_keep = logs_cf.get('num_files_to_keep', None)
1294        if num_files_to_keep is None:
1295            num_files_to_keep = get_config('jobs', 'logs', 'num_files_to_keep')
1296
1297        max_file_size = logs_cf.get('max_file_size', None)
1298        if max_file_size is None:
1299            max_file_size = get_config('jobs', 'logs', 'max_file_size')
1300
1301        new_rotating_log = RotatingFile(
1302            self.rotating_log.file_path,
1303            num_files_to_keep=num_files_to_keep,
1304            max_file_size=max_file_size,
1305            write_timestamps=write_timestamps,
1306            timestamp_format=timestamp_format,
1307        )
1308        return new_rotating_log.read()
1309
1310    def readlines(self) -> List[str]:
1311        """
1312        Read the next log lines, persisting the cursor for later use.
1313        Note this will alter the cursor of `self.rotating_log`.
1314        """
1315        self.rotating_log._cursor = self._read_log_offset()
1316        lines = self.rotating_log.readlines()
1317        self._write_log_offset()
1318        return lines
1319
1320    def _read_log_offset(self) -> Tuple[int, int]:
1321        """
1322        Return the current log offset cursor.
1323
1324        Returns
1325        -------
1326        A tuple of the form (`subfile_index`, `position`).
1327        """
1328        if not self.log_offset_path.exists():
1329            return 0, 0
1330
1331        try:
1332            with open(self.log_offset_path, 'r', encoding='utf-8') as f:
1333                cursor_text = f.read()
1334            cursor_parts = cursor_text.split(' ')
1335            subfile_index, subfile_position = int(cursor_parts[0]), int(cursor_parts[1])
1336            return subfile_index, subfile_position
1337        except Exception as e:
1338            warn(f"Failed to read cursor:\n{e}")
1339        return 0, 0
1340
1341    def _write_log_offset(self) -> None:
1342        """
1343        Write the current log offset file.
1344        """
1345        with self.log_offset_lock:
1346            with open(self.log_offset_path, 'w+', encoding='utf-8') as f:
1347                subfile_index = self.rotating_log._cursor[0]
1348                subfile_position = self.rotating_log._cursor[1]
1349                f.write(f"{subfile_index} {subfile_position}")
1350
1351    @property
1352    def pid(self) -> Union[int, None]:
1353        """
1354        Read the PID file and return its contents.
1355        Returns `None` if the PID file does not exist.
1356        """
1357        if not self.pid_path.exists():
1358            ### The PID file can be lost while a detached daemon keeps running (e.g. the
1359            ### launcher exited without cleanup). Recover the PID by finding the process
1360            ### whose command line embeds this daemon_id, so `status`/`stop`/`kill` see the
1361            ### live process instead of reporting a false "stopped" (which would strand the
1362            ### orphan, forcing a manual `pkill meerschaum`).
1363            detached_pids = self._find_detached_pids(verify_began=True)
1364            if not detached_pids:
1365                return None
1366            self._restore_pid_file(detached_pids[0])
1367            return detached_pids[0]
1368        try:
1369            with open(self.pid_path, 'r', encoding='utf-8') as f:
1370                text = f.read()
1371            if len(text) == 0:
1372                return None
1373            pid = int(text.rstrip())
1374        except Exception as e:
1375            warn(e)
1376            text = None
1377            pid = None
1378        return pid
1379
1380    @property
1381    def pid_path(self) -> pathlib.Path:
1382        """
1383        Return the path to a file containing the PID for this Daemon.
1384        """
1385        return self.path / 'process.pid'
1386
1387    @property
1388    def pid_lock(self) -> 'InterProcessLock':
1389        """
1390        Return the process lock context manager.
1391        """
1392        if '_pid_lock' in self.__dict__:
1393            return self._pid_lock
1394
1395        from meerschaum.utils.locks import InterProcessLock
1396        self._pid_lock = InterProcessLock(self.pid_path)
1397        return self._pid_lock
1398
1399    @property
1400    def pickle_path(self) -> pathlib.Path:
1401        """
1402        Return the path for the pickle file.
1403        """
1404        return self.path / 'pickle.pkl'
1405
1406    def read_properties(self) -> Optional[Dict[str, Any]]:
1407        """Read the properties JSON file and return the dictionary."""
1408        if not self.properties_path.exists():
1409            return None
1410        try:
1411            with open(self.properties_path, 'r', encoding='utf-8') as file:
1412                properties = json.load(file)
1413        except Exception:
1414            properties = {}
1415        
1416        return properties or {}
1417
1418    def read_pickle(self) -> Daemon:
1419        """Read a Daemon's pickle file and return the `Daemon`."""
1420        import pickle
1421        import traceback
1422        if not self.pickle_path.exists():
1423            error(f"Pickle file does not exist for daemon '{self.daemon_id}'.")
1424
1425        if self.pickle_path.stat().st_size == 0:
1426            error(f"Pickle was empty for daemon '{self.daemon_id}'.")
1427
1428        try:
1429            with open(self.pickle_path, 'rb') as pickle_file:
1430                daemon = pickle.load(pickle_file)
1431            success, msg = True, 'Success'
1432        except Exception as e:
1433            success, msg = False, str(e)
1434            daemon = None
1435            traceback.print_exception(type(e), e, e.__traceback__)
1436        if not success:
1437            error(msg)
1438        return daemon
1439
1440    @property
1441    def properties(self) -> Dict[str, Any]:
1442        """
1443        Return the contents of the properties JSON file.
1444        """
1445        try:
1446            _file_properties = self.read_properties() or {}
1447        except Exception:
1448            traceback.print_exc()
1449            _file_properties = {}
1450
1451        if not self._properties:
1452            self._properties = _file_properties
1453
1454        if self._properties is None:
1455            self._properties = {}
1456
1457        if (
1458            self._properties.get('result', None) is None
1459            and _file_properties.get('result', None) is not None
1460        ):
1461            _ = self._properties.pop('result', None)
1462
1463        if _file_properties is not None:
1464            self._properties = apply_patch_to_config(
1465                _file_properties,
1466                self._properties,
1467            )
1468
1469        return self._properties
1470
1471    @property
1472    def hidden(self) -> bool:
1473        """
1474        Return a bool indicating whether this Daemon should be displayed.
1475        """
1476        return self.daemon_id.startswith('_') or self.daemon_id.startswith('.')
1477
1478    def write_properties(self) -> SuccessTuple:
1479        """Write the properties dictionary to the properties JSON file
1480        (only if self.properties exists).
1481        """
1482        from meerschaum.utils.misc import generate_password
1483        success, msg = (
1484            False,
1485            f"No properties to write for daemon '{self.daemon_id}'."
1486        )
1487        backup_path = self.properties_path.parent / (generate_password(8) + '.json')
1488        props = self.properties
1489        if props is not None:
1490            try:
1491                self.path.mkdir(parents=True, exist_ok=True)
1492                if self.properties_path.exists():
1493                    self.properties_path.rename(backup_path)
1494                with open(self.properties_path, 'w+', encoding='utf-8') as properties_file:
1495                    json.dump(props, properties_file)
1496                success, msg = True, 'Success'
1497            except Exception as e:
1498                success, msg = False, str(e)
1499
1500        try:
1501            if backup_path.exists():
1502                if not success:
1503                    backup_path.rename(self.properties_path)
1504                else:
1505                    backup_path.unlink()
1506        except Exception as e:
1507            success, msg = False, str(e)
1508
1509        return success, msg
1510
1511    def write_pickle(self) -> SuccessTuple:
1512        """Write the pickle file for the daemon."""
1513        import pickle
1514        import traceback
1515        from meerschaum.utils.misc import generate_password
1516
1517        if not self.pickle:
1518            return True, "Success"
1519
1520        from meerschaum._internal.entry import _shells
1521        if _shells:
1522            from meerschaum._internal.shell.Shell import revert_input
1523            revert_input()
1524
1525        backup_path = self.pickle_path.parent / (generate_password(7) + '.pkl')
1526        try:
1527            self.path.mkdir(parents=True, exist_ok=True)
1528            if self.pickle_path.exists():
1529                self.pickle_path.rename(backup_path)
1530            with open(self.pickle_path, 'wb+') as pickle_file:
1531                pickle.dump(self, pickle_file)
1532            success, msg = True, "Success"
1533        except Exception as e:
1534            success, msg = False, str(e)
1535            traceback.print_exception(type(e), e, e.__traceback__)
1536        try:
1537            if backup_path.exists():
1538                if not success:
1539                    backup_path.rename(self.pickle_path)
1540                else:
1541                    backup_path.unlink()
1542        except Exception as e:
1543            success, msg = False, str(e)
1544        return success, msg
1545
1546
1547    def _setup(
1548        self,
1549        allow_dirty_run: bool = False,
1550    ) -> None:
1551        """
1552        Update properties before starting the Daemon.
1553        """
1554        if self.properties is None:
1555            self._properties = {}
1556
1557        self._properties.update({
1558            'target': {
1559                'name': self.target.__name__,
1560                'module': self.target.__module__,
1561                'args': self.target_args,
1562                'kw': self.target_kw,
1563            },
1564        })
1565        self.mkdir_if_not_exists(allow_dirty_run)
1566        _write_properties_success_tuple = self.write_properties()
1567        if not _write_properties_success_tuple[0]:
1568            error(_write_properties_success_tuple[1])
1569
1570        _write_pickle_success_tuple = self.write_pickle()
1571        if not _write_pickle_success_tuple[0]:
1572            error(_write_pickle_success_tuple[1])
1573
1574    def cleanup(self, keep_logs: bool = False) -> SuccessTuple:
1575        """
1576        Remove a daemon's directory after execution.
1577
1578        Parameters
1579        ----------
1580        keep_logs: bool, default False
1581            If `True`, skip deleting the daemon's log files.
1582
1583        Returns
1584        -------
1585        A `SuccessTuple` indicating success.
1586        """
1587        _detached.invalidate(self.daemon_id)
1588        if self.path.exists():
1589            try:
1590                shutil.rmtree(self.path)
1591            except Exception as e:
1592                msg = f"Failed to clean up '{self.daemon_id}':\n{e}"
1593                warn(msg)
1594                return False, msg
1595        if not keep_logs:
1596            self.rotating_log.delete()
1597            try:
1598                if self.log_offset_path.exists():
1599                    self.log_offset_path.unlink()
1600            except Exception as e:
1601                msg = f"Failed to remove offset file for '{self.daemon_id}':\n{e}"
1602                warn(msg)
1603                return False, msg
1604        return True, "Success"
1605
1606
1607    def get_timeout_seconds(self, timeout: Union[int, float, None] = None) -> Union[int, float]:
1608        """
1609        Return the timeout value to use. Use `--timeout-seconds` if provided,
1610        else the configured default (8).
1611        """
1612        if isinstance(timeout, (int, float)):
1613            return timeout
1614        return get_config('jobs', 'timeout_seconds')
1615
1616
1617    def get_check_timeout_interval_seconds(
1618        self,
1619        check_timeout_interval: Union[int, float, None] = None,
1620    ) -> Union[int, float]:
1621        """
1622        Return the interval value to check the status of timeouts.
1623        """
1624        if isinstance(check_timeout_interval, (int, float)):
1625            return check_timeout_interval
1626        return get_config('jobs', 'check_timeout_interval_seconds')
1627
1628    @property
1629    def target_args(self) -> Union[Tuple[Any], None]:
1630        """
1631        Return the positional arguments to pass to the target function.
1632        """
1633        target_args = (
1634            self.__dict__.get('_target_args', None)
1635            or self.properties.get('target', {}).get('args', None)
1636        )
1637        if target_args is None:
1638            return tuple([])
1639
1640        return tuple(target_args)
1641
1642    @property
1643    def target_kw(self) -> Union[Dict[str, Any], None]:
1644        """
1645        Return the keyword arguments to pass to the target function.
1646        """
1647        target_kw = (
1648            self.__dict__.get('_target_kw', None)
1649            or self.properties.get('target', {}).get('kw', None)
1650        )
1651        if target_kw is None:
1652            return {}
1653
1654        return {key: val for key, val in target_kw.items()}
1655
1656    @staticmethod
1657    def _get_target_reference(target) -> Union[Dict[str, str], None]:
1658        """
1659        If `target` is an importable, top-level function (e.g. `entry`), return a
1660        ``{'module': ..., 'qualname': ...}`` reference so it can be re-imported in the
1661        daemon process instead of pickled by value.
1662
1663        Pickling such a target by value serializes its entire global graph, which can
1664        reach unpicklable live state (e.g. a connector caching an `_asyncio.Task`) and
1665        raise `TypeError: cannot pickle '_asyncio.Task' object`. dill also falls back to
1666        by-value pickling whenever it cannot match the function by identity — which
1667        happens when two copies of a package are importable (a stale install shadowing
1668        a venv), so `byref=True` alone is not enough. Returns `None` for closures,
1669        lambdas, and anything not importable, which fall back to dill.
1670        """
1671        module = getattr(target, '__module__', None)
1672        qualname = getattr(target, '__qualname__', None)
1673        if not module or not qualname:
1674            return None
1675        if '<locals>' in qualname or '<lambda>' in qualname:
1676            return None
1677        return {'module': module, 'qualname': qualname}
1678
1679    @staticmethod
1680    def _load_target_reference(target_ref: Dict[str, str]):
1681        """
1682        Re-import a target from a `{'module': ..., 'qualname': ...}` reference.
1683        """
1684        import importlib
1685        obj = importlib.import_module(target_ref['module'])
1686        for part in target_ref['qualname'].split('.'):
1687            obj = getattr(obj, part)
1688        return obj
1689
1690    def __getstate__(self):
1691        """
1692        Pickle this Daemon.
1693        """
1694        dill = attempt_import('dill')
1695        state = {
1696            'target_args': self.target_args,
1697            'target_kw': self.target_kw,
1698            'daemon_id': self.daemon_id,
1699            'label': self.label,
1700            'properties': self.properties,
1701        }
1702        target_ref = self._get_target_reference(self.target)
1703        if target_ref is not None:
1704            ### Store by reference (re-imported in the daemon process), not by value.
1705            state['target'] = None
1706            state['target_ref'] = target_ref
1707        else:
1708            state['target'] = dill.dumps(self.target, byref=True)
1709        return state
1710
1711    def __setstate__(self, _state: Dict[str, Any]):
1712        """
1713        Restore this Daemon from a pickled state.
1714        If the properties file exists, skip the old pickled version.
1715        """
1716        dill = attempt_import('dill')
1717        target_ref = _state.pop('target_ref', None)
1718        if target_ref is not None and _state.get('target', None) is None:
1719            _state['target'] = self._load_target_reference(target_ref)
1720        else:
1721            _state['target'] = dill.loads(_state['target'])
1722        self._pickle = True
1723        daemon_id = _state.get('daemon_id', None)
1724        if not daemon_id:
1725            raise ValueError("Need a daemon_id to un-pickle a Daemon.")
1726
1727        properties_path = self._get_properties_path_from_daemon_id(daemon_id)
1728        ignore_properties = properties_path.exists()
1729        if ignore_properties:
1730            _state = {
1731                key: val
1732                for key, val in _state.items()
1733                if key != 'properties'
1734            }
1735        self.__init__(**_state)
1736
1737
1738    def __repr__(self):
1739        return str(self)
1740
1741    def __str__(self):
1742        return self.daemon_id
1743
1744    def __eq__(self, other):
1745        if not isinstance(other, Daemon):
1746            return False
1747        return self.daemon_id == other.daemon_id
1748
1749    def __hash__(self):
1750        return hash(self.daemon_id)
class Daemon:
  43class Daemon:
  44    """
  45    Daemonize Python functions into background processes.
  46
  47    Examples
  48    --------
  49    >>> import meerschaum as mrsm
  50    >>> from meerschaum.utils.daemons import Daemon
  51    >>> daemon = Daemon(print, ('hi',))
  52    >>> success, msg = daemon.run()
  53    >>> print(daemon.log_text)
  54
  55    2024-07-29 18:03 | hi
  56    2024-07-29 18:03 |
  57    >>> daemon.run(allow_dirty_run=True)
  58    >>> print(daemon.log_text)
  59
  60    2024-07-29 18:03 | hi
  61    2024-07-29 18:03 |
  62    2024-07-29 18:05 | hi
  63    2024-07-29 18:05 |
  64    >>> mrsm.pprint(daemon.properties)
  65    {
  66        'label': 'print',
  67        'target': {'name': 'print', 'module': 'builtins', 'args': ['hi'], 'kw': {}},
  68        'result': None,
  69        'process': {'ended': '2024-07-29T18:03:33.752806'}
  70    }
  71
  72    """
  73
  74    def __new__(
  75        cls,
  76        *args,
  77        daemon_id: Optional[str] = None,
  78        **kw
  79    ):
  80        """
  81        If a daemon_id is provided and already exists, read from its pickle file.
  82        """
  83        instance = super(Daemon, cls).__new__(cls)
  84        if daemon_id is not None:
  85            instance.daemon_id = daemon_id
  86            if instance.pickle_path.exists():
  87                instance = instance.read_pickle()
  88        return instance
  89
  90    @classmethod
  91    def from_properties_file(cls, daemon_id: str) -> Daemon:
  92        """
  93        Return a Daemon from a properties dictionary.
  94        """
  95        properties_path = cls._get_properties_path_from_daemon_id(daemon_id)
  96        if not properties_path.exists():
  97            raise OSError(f"Properties file '{properties_path}' does not exist.")
  98
  99        try:
 100            with open(properties_path, 'r', encoding='utf-8') as f:
 101                properties = json.load(f)
 102        except Exception:
 103            properties = {}
 104
 105        if not properties:
 106            raise ValueError(f"No properties could be read for daemon '{daemon_id}'.")
 107
 108        daemon_id = properties_path.parent.name
 109        target_cf = properties.get('target', {})
 110        target_module_name = target_cf.get('module', None)
 111        target_function_name = target_cf.get('name', None)
 112        target_args = target_cf.get('args', None)
 113        target_kw = target_cf.get('kw', None)
 114        label = properties.get('label', None)
 115
 116        if None in [
 117            target_module_name,
 118            target_function_name,
 119            target_args,
 120            target_kw,
 121        ]:
 122            raise ValueError("Missing target function information.")
 123
 124        target_module = importlib.import_module(target_module_name)
 125        target_function = getattr(target_module, target_function_name)
 126
 127        return Daemon(
 128            daemon_id=daemon_id,
 129            target=target_function,
 130            target_args=target_args,
 131            target_kw=target_kw,
 132            properties=properties,
 133            label=label,
 134        )
 135
 136
 137    def __init__(
 138        self,
 139        target: Optional[Callable[[Any], Any]] = None,
 140        target_args: Union[List[Any], Tuple[Any], None] = None,
 141        target_kw: Optional[Dict[str, Any]] = None,
 142        env: Optional[Dict[str, str]] = None,
 143        daemon_id: Optional[str] = None,
 144        label: Optional[str] = None,
 145        properties: Optional[Dict[str, Any]] = None,
 146        pickle: bool = True,
 147    ):
 148        """
 149        Parameters
 150        ----------
 151        target: Optional[Callable[[Any], Any]], default None,
 152            The function to execute in a child process.
 153
 154        target_args: Union[List[Any], Tuple[Any], None], default None
 155            Positional arguments to pass to the target function.
 156
 157        target_kw: Optional[Dict[str, Any]], default None
 158            Keyword arguments to pass to the target function.
 159
 160        env: Optional[Dict[str, str]], default None
 161            If provided, set these environment variables in the daemon process.
 162
 163        daemon_id: Optional[str], default None
 164            Build a `Daemon` from an existing `daemon_id`.
 165            If `daemon_id` is provided, other arguments are ignored and are derived
 166            from the existing pickled `Daemon`.
 167
 168        label: Optional[str], default None
 169            Label string to help identifiy a daemon.
 170            If `None`, use the function name instead.
 171
 172        properties: Optional[Dict[str, Any]], default None
 173            Override reading from the properties JSON by providing an existing dictionary.
 174        """
 175        _pickle = self.__dict__.get('_pickle', False)
 176        if daemon_id is not None:
 177            self.daemon_id = daemon_id
 178            if not self.pickle_path.exists() and not target and ('target' not in self.__dict__):
 179
 180                if not self.properties_path.exists():
 181                    raise Exception(
 182                        f"Daemon '{self.daemon_id}' does not exist. "
 183                        + "Pass a target to create a new Daemon."
 184                    )
 185
 186                try:
 187                    new_daemon = self.from_properties_file(daemon_id)
 188                except Exception:
 189                    new_daemon = None
 190
 191                if new_daemon is not None:
 192                    new_daemon.write_pickle()
 193                    target = new_daemon.target
 194                    target_args = new_daemon.target_args
 195                    target_kw = new_daemon.target_kw
 196                    label = new_daemon.label
 197                    self._properties = new_daemon.properties
 198                else:
 199                    try:
 200                        self.properties_path.unlink()
 201                    except Exception:
 202                        pass
 203
 204                    raise Exception(
 205                        f"Could not recover daemon '{self.daemon_id}' "
 206                        + "from its properties file."
 207                    )
 208
 209        if 'target' not in self.__dict__:
 210            if target is None:
 211                error("Cannot create a Daemon without a target.")
 212            self.target = target
 213
 214        self.pickle = pickle
 215
 216        ### NOTE: We have to check self.__dict__ in case we un-pickling.
 217        if '_target_args' not in self.__dict__:
 218            self._target_args = target_args
 219        if '_target_kw' not in self.__dict__:
 220            self._target_kw = target_kw
 221
 222        if 'label' not in self.__dict__:
 223            if label is None:
 224                label = (
 225                    self.target.__name__ if '__name__' in self.target.__dir__()
 226                        else str(self.target)
 227                )
 228            self.label = label
 229        elif label is not None:
 230            self.label = label
 231
 232        if 'daemon_id' not in self.__dict__:
 233            self.daemon_id = get_new_daemon_name()
 234        if '_properties' not in self.__dict__:
 235            self._properties = properties
 236        elif properties:
 237            if self._properties is None:
 238                self._properties = {}
 239            self._properties.update(properties)
 240        if self._properties is None:
 241            self._properties = {}
 242
 243        self._properties.update({'label': self.label})
 244        if env:
 245            self._properties.update({'env': env})
 246
 247        ### Instantiate the process and if it doesn't exist, make sure the stale PID file
 248        ### is removed. Without a PID file there is nothing to clean up,
 249        ### so don't go looking for a detached process while constructing.
 250        if self.pid_path.exists():
 251            _ = self.process
 252
 253
 254    def _run_exit(
 255        self,
 256        keep_daemon_output: bool = True,
 257        allow_dirty_run: bool = False,
 258    ) -> Any:
 259        """Run the daemon's target function.
 260        NOTE: This WILL EXIT the parent process!
 261
 262        Parameters
 263        ----------
 264        keep_daemon_output: bool, default True
 265            If `False`, delete the daemon's output directory upon exiting.
 266
 267        allow_dirty_run, bool, default False:
 268            If `True`, run the daemon, even if the `daemon_id` directory exists.
 269            This option is dangerous because if the same `daemon_id` runs twice,
 270            the last to finish will overwrite the output of the first.
 271
 272        Returns
 273        -------
 274        Nothing — this will exit the parent process.
 275        """
 276        import platform
 277        import sys
 278        import os
 279        import traceback
 280        from meerschaum.utils.warnings import warn
 281        from meerschaum.config import get_config
 282        daemon = attempt_import('daemon')
 283        lines = get_config('jobs', 'terminal', 'lines')
 284        columns = get_config('jobs', 'terminal', 'columns')
 285
 286        if platform.system() == 'Windows':
 287            return False, "Windows is no longer supported."
 288
 289        self._setup(allow_dirty_run)
 290
 291        _daemons.append(self)
 292
 293        logs_cf = self.properties.get('logs', {})
 294        log_refresh_seconds = logs_cf.get('refresh_files_seconds', None)
 295        if log_refresh_seconds is None:
 296            log_refresh_seconds = get_config('jobs', 'logs', 'refresh_files_seconds')
 297        write_timestamps = logs_cf.get('write_timestamps', None)
 298        if write_timestamps is None:
 299            write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled')
 300
 301        self._log_refresh_timer = RepeatTimer(
 302            log_refresh_seconds,
 303            partial(self.rotating_log.refresh_files, start_interception=write_timestamps),
 304        )
 305
 306        capture_stdin = logs_cf.get('stdin', True)
 307        cwd = self.properties.get('cwd', os.getcwd())
 308
 309        ### NOTE: The SIGINT handler has been removed so that child processes may handle
 310        ###       KeyboardInterrupts themselves.
 311        ###       The previous aggressive approach was redundant because of the SIGTERM handler.
 312        self._daemon_context = daemon.DaemonContext(
 313            pidfile=self.pid_lock,
 314            stdout=self.rotating_log,
 315            stderr=self.rotating_log,
 316            stdin=(self.stdin_file if capture_stdin else None),
 317            working_directory=cwd,
 318            detach_process=True,
 319            files_preserve=list(self.rotating_log.subfile_objects.values()),
 320            signal_map={
 321                signal.SIGTERM: self._handle_sigterm,
 322            },
 323        )
 324
 325        if capture_stdin and sys.stdin is None:
 326            raise OSError("Cannot daemonize without stdin.")
 327
 328        try:
 329            os.environ['LINES'], os.environ['COLUMNS'] = str(int(lines)), str(int(columns))
 330            with self._daemon_context:
 331                if capture_stdin:
 332                    sys.stdin = self.stdin_file
 333                _ = os.environ.pop(STATIC_CONFIG['environment']['systemd_stdin_path'], None)
 334                os.environ[STATIC_CONFIG['environment']['daemon_id']] = self.daemon_id
 335                os.environ['PYTHONUNBUFFERED'] = '1'
 336
 337                ### Allow the user to override environment variables.
 338                env = self.properties.get('env', {})
 339                if env and isinstance(env, dict):
 340                    os.environ.update({str(k): str(v) for k, v in env.items()})
 341
 342                self.rotating_log.refresh_files(start_interception=True)
 343                result = None
 344                try:
 345                    with open(self.pid_path, 'w+', encoding='utf-8') as f:
 346                        f.write(str(os.getpid()))
 347
 348                    ### NOTE: The timer fails to start for remote actions to localhost.
 349                    try:
 350                        if not self._log_refresh_timer.is_running():
 351                            self._log_refresh_timer.start()
 352                    except Exception:
 353                        pass
 354
 355                    self.properties['result'] = None
 356                    self._capture_process_timestamp('began')
 357                    result = self.target(*self.target_args, **self.target_kw)
 358                    self.properties['result'] = result
 359                except SystemExit as e:
 360                    result = (
 361                        (True, f"Stopped daemon '{self.daemon_id}'.")
 362                        if e.code is None or e.code == 0
 363                        else (False, traceback.format_exc())
 364                    )
 365                except (BrokenPipeError, KeyboardInterrupt):
 366                    result = False, traceback.format_exc()
 367                except Exception as e:
 368                    warn(
 369                        f"Exception in daemon target function: {traceback.format_exc()}",
 370                    )
 371                    result = False, str(e)
 372                finally:
 373                    _results[self.daemon_id] = result
 374                    self.properties['result'] = result
 375
 376                    if keep_daemon_output:
 377                        self._capture_process_timestamp('ended')
 378                    else:
 379                        self.cleanup()
 380
 381                    self._log_refresh_timer.cancel()
 382                    try:
 383                        if self.pid is None and self.pid_path.exists():
 384                            self.pid_path.unlink()
 385                    except Exception:
 386                        pass
 387
 388                    if is_success_tuple(result):
 389                        try:
 390                            mrsm.pprint(result)
 391                        except BrokenPipeError:
 392                            pass
 393
 394        except Exception:
 395            daemon_error = traceback.format_exc()
 396            import meerschaum.config.paths as paths
 397            with open(paths.DAEMON_ERROR_LOG_PATH, 'a+', encoding='utf-8') as f:
 398                f.write(
 399                    f"Error in Daemon '{self}':\n\n"
 400                    f"{sys.stdin=}\n"
 401                    f"{self.stdin_file_path=}\n"
 402                    f"{self.stdin_file_path.exists()=}\n\n"
 403                    f"{daemon_error}\n\n"
 404                )
 405            warn(f"Encountered an error while running the daemon '{self}':\n{daemon_error}")
 406
 407    def _capture_process_timestamp(
 408        self,
 409        process_key: str,
 410        write_properties: bool = True,
 411    ) -> None:
 412        """
 413        Record the current timestamp to the parameters `process:<process_key>`.
 414
 415        Parameters
 416        ----------
 417        process_key: str
 418            Under which key to store the timestamp.
 419
 420        write_properties: bool, default True
 421            If `True` persist the properties to disk immediately after capturing the timestamp.
 422        """
 423        if 'process' not in self.properties:
 424            self.properties['process'] = {}
 425
 426        if process_key not in ('began', 'ended', 'paused', 'stopped', 'launched'):
 427            raise ValueError(f"Invalid key '{process_key}'.")
 428
 429        self.properties['process'][process_key] = (
 430            datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
 431        )
 432        if write_properties:
 433            self.write_properties()
 434
 435    def run(
 436        self,
 437        keep_daemon_output: bool = True,
 438        allow_dirty_run: bool = False,
 439        wait: bool = False,
 440        timeout: Union[int, float] = 4,
 441        debug: bool = False,
 442    ) -> SuccessTuple:
 443        """Run the daemon as a child process and continue executing the parent.
 444
 445        Parameters
 446        ----------
 447        keep_daemon_output: bool, default True
 448            If `False`, delete the daemon's output directory upon exiting.
 449
 450        allow_dirty_run: bool, default False
 451            If `True`, run the daemon, even if the `daemon_id` directory exists.
 452            This option is dangerous because if the same `daemon_id` runs concurrently,
 453            the last to finish will overwrite the output of the first.
 454
 455        wait: bool, default True
 456            If `True`, block until `Daemon.status` is running (or the timeout expires).
 457
 458        timeout: Union[int, float], default 4
 459            If `wait` is `True`, block for up to `timeout` seconds before returning a failure.
 460
 461        Returns
 462        -------
 463        A SuccessTuple indicating success.
 464
 465        """
 466        import platform
 467        if platform.system() == 'Windows':
 468            return False, "Cannot run background jobs on Windows."
 469
 470        ### The daemon might exist and be paused.
 471        if self.status == 'paused':
 472            return self.resume()
 473
 474        self._remove_stop_file()
 475        if self.status == 'running':
 476            return True, f"Daemon '{self}' is already running."
 477
 478        self.mkdir_if_not_exists(allow_dirty_run)
 479        _write_pickle_success_tuple = self.write_pickle()
 480        if not _write_pickle_success_tuple[0]:
 481            return _write_pickle_success_tuple
 482
 483        ### The PID file does not exist until the new process has started up. Record the launch
 484        ### so that a job which completed earlier is not mistaken for a finished one meanwhile.
 485        _detached.invalidate(self.daemon_id)
 486        if self.properties_path.exists():
 487            self._capture_process_timestamp('launched')
 488
 489        _launch_daemon_code = (
 490            "from meerschaum.utils.daemon import Daemon, _daemons; "
 491            f"daemon = Daemon(daemon_id='{self.daemon_id}'); "
 492            f"_daemons['{self.daemon_id}'] = daemon; "
 493            f"daemon._run_exit(keep_daemon_output={keep_daemon_output}, "
 494            "allow_dirty_run=True)"
 495        )
 496        env = dict(os.environ)
 497        _launch_success_bool = venv_exec(_launch_daemon_code, debug=debug, venv=None, env=env)
 498        _detached.invalidate(self.daemon_id)
 499        msg = (
 500            "Success"
 501            if _launch_success_bool
 502            else f"Failed to start daemon '{self.daemon_id}'."
 503        )
 504        if not wait or not _launch_success_bool:
 505            return _launch_success_bool, msg
 506
 507        timeout = self.get_timeout_seconds(timeout)
 508        check_timeout_interval = self.get_check_timeout_interval_seconds()
 509
 510        if not timeout:
 511            success = self.status == 'running'
 512            msg = "Success" if success else f"Failed to run daemon '{self.daemon_id}'."
 513            if success:
 514                self._capture_process_timestamp('began')
 515            return success, msg
 516
 517        begin = time.perf_counter()
 518        while (time.perf_counter() - begin) < timeout:
 519            if self.status == 'running':
 520                self._capture_process_timestamp('began')
 521                return True, "Success"
 522            time.sleep(check_timeout_interval)
 523
 524        return False, (
 525            f"Failed to start daemon '{self.daemon_id}' within {timeout} second"
 526            + ('s' if timeout != 1 else '') + '.'
 527        )
 528
 529
 530    def kill(self, timeout: Union[int, float, None] = 8) -> SuccessTuple:
 531        """
 532        Forcibly terminate a running daemon.
 533        Sends a SIGTERM signal to the process.
 534
 535        Parameters
 536        ----------
 537        timeout: Optional[int], default 3
 538            How many seconds to wait for the process to terminate.
 539
 540        Returns
 541        -------
 542        A SuccessTuple indicating success.
 543        """
 544        _detached.invalidate(self.daemon_id)
 545
 546        ### A lost PID file means a detached/orphaned daemon — possibly SEVERAL processes
 547        ### sharing this daemon_id (e.g. accumulated across crashed restarts). Reap them
 548        ### ALL by daemon_id rather than reporting a false "stopped" and stranding them
 549        ### (which forces a manual `pkill meerschaum` or hunting the daemon_id in htop).
 550        if not self.pid_path.exists():
 551            reaped = self._kill_detached_processes(timeout)
 552            self._write_stop_file('kill')
 553            self.stdin_file.close()
 554            self._remove_blocking_stdin_file()
 555            return True, (
 556                (f"Reaped {reaped} detached process" + ('es' if reaped != 1 else '') + '.')
 557                if reaped
 558                else "Process has already stopped."
 559            )
 560
 561        if self.status != 'paused':
 562            success, msg = self._send_signal(signal.SIGTERM, timeout=timeout)
 563            if success:
 564                ### Sweep any sibling/detached processes left over for this daemon_id.
 565                self._kill_detached_processes(timeout)
 566                self._write_stop_file('kill')
 567                self.stdin_file.close()
 568                self._remove_blocking_stdin_file()
 569                return success, msg
 570
 571        if self.status == 'stopped':
 572            reaped = self._kill_detached_processes(timeout)
 573            self._write_stop_file('kill')
 574            self.stdin_file.close()
 575            self._remove_blocking_stdin_file()
 576            return True, (
 577                (f"Reaped {reaped} detached process" + ('es' if reaped != 1 else '') + '.')
 578                if reaped
 579                else "Process has already stopped."
 580            )
 581
 582        psutil = attempt_import('psutil')
 583        process = self.process
 584        try:
 585            process.terminate()
 586            process.kill()
 587            process.wait(timeout=timeout)
 588        except Exception as e:
 589            return False, f"Failed to kill job {self} ({process}) with exception: {e}"
 590
 591        try:
 592            if process.status():
 593                return False, "Failed to stop daemon '{self}' ({process})."
 594        except psutil.NoSuchProcess:
 595            pass
 596
 597        if self.pid_path.exists():
 598            try:
 599                self.pid_path.unlink()
 600            except Exception:
 601                pass
 602
 603        self._write_stop_file('kill')
 604        self.stdin_file.close()
 605        self._remove_blocking_stdin_file()
 606        return True, "Success"
 607
 608    def quit(self, timeout: Union[int, float, None] = None) -> SuccessTuple:
 609        """Gracefully quit a running daemon."""
 610        _detached.invalidate(self.daemon_id)
 611        if self.status == 'paused':
 612            return self.kill(timeout)
 613
 614        ### A lost PID file means a detached/orphaned daemon (possibly several processes);
 615        ### the normal signal path can't see them, so reap them all by daemon_id instead
 616        ### of returning a false "not running".
 617        if not self.pid_path.exists():
 618            reaped = self._kill_detached_processes(timeout)
 619            self._write_stop_file('quit')
 620            self.stdin_file.close()
 621            self._remove_blocking_stdin_file()
 622            return True, (
 623                (f"Reaped {reaped} detached process" + ('es' if reaped != 1 else '') + '.')
 624                if reaped
 625                else "Process is not running."
 626            )
 627
 628        signal_success, signal_msg = self._send_signal(signal.SIGINT, timeout=timeout)
 629        if signal_success:
 630            self._write_stop_file('quit')
 631            self.stdin_file.close()
 632            self._remove_blocking_stdin_file()
 633        return signal_success, signal_msg
 634
 635    def pause(
 636        self,
 637        timeout: Union[int, float, None] = None,
 638        check_timeout_interval: Union[float, int, None] = None,
 639    ) -> SuccessTuple:
 640        """
 641        Pause the daemon if it is running.
 642
 643        Parameters
 644        ----------
 645        timeout: Union[float, int, None], default None
 646            The maximum number of seconds to wait for a process to suspend.
 647
 648        check_timeout_interval: Union[float, int, None], default None
 649            The number of seconds to wait between checking if the process is still running.
 650
 651        Returns
 652        -------
 653        A `SuccessTuple` indicating whether the `Daemon` process was successfully suspended.
 654        """
 655        self._remove_blocking_stdin_file()
 656        _detached.invalidate(self.daemon_id)
 657
 658        if self.process is None:
 659            return False, f"Daemon '{self.daemon_id}' is not running and cannot be paused."
 660
 661        if self.status == 'paused':
 662            return True, f"Daemon '{self.daemon_id}' is already paused."
 663
 664        self._write_stop_file('pause')
 665        self.stdin_file.close()
 666        self._remove_blocking_stdin_file()
 667        try:
 668            self.process.suspend()
 669        except Exception as e:
 670            return False, f"Failed to pause daemon '{self.daemon_id}':\n{e}"
 671
 672        timeout = self.get_timeout_seconds(timeout)
 673        check_timeout_interval = self.get_check_timeout_interval_seconds(
 674            check_timeout_interval
 675        )
 676
 677        psutil = attempt_import('psutil')
 678
 679        if not timeout:
 680            try:
 681                success = self.process.status() == 'stopped'
 682            except psutil.NoSuchProcess:
 683                success = True
 684            msg = "Success" if success else f"Failed to suspend daemon '{self.daemon_id}'."
 685            if success:
 686                self._capture_process_timestamp('paused')
 687            return success, msg
 688
 689        begin = time.perf_counter()
 690        while (time.perf_counter() - begin) < timeout:
 691            try:
 692                if self.process.status() == 'stopped':
 693                    self._capture_process_timestamp('paused')
 694                    return True, "Success"
 695            except psutil.NoSuchProcess as e:
 696                return False, f"Process exited unexpectedly. Was it killed?\n{e}"
 697            time.sleep(check_timeout_interval)
 698
 699        return False, (
 700            f"Failed to pause daemon '{self.daemon_id}' within {timeout} second"
 701            + ('s' if timeout != 1 else '') + '.'
 702        )
 703
 704    def resume(
 705        self,
 706        timeout: Union[int, float, None] = None,
 707        check_timeout_interval: Union[float, int, None] = None,
 708    ) -> SuccessTuple:
 709        """
 710        Resume the daemon if it is paused.
 711
 712        Parameters
 713        ----------
 714        timeout: Union[float, int, None], default None
 715            The maximum number of seconds to wait for a process to resume.
 716
 717        check_timeout_interval: Union[float, int, None], default None
 718            The number of seconds to wait between checking if the process is still stopped.
 719
 720        Returns
 721        -------
 722        A `SuccessTuple` indicating whether the `Daemon` process was successfully resumed.
 723        """
 724        _detached.invalidate(self.daemon_id)
 725        if self.status == 'running':
 726            return True, f"Daemon '{self.daemon_id}' is already running."
 727
 728        if self.status == 'stopped':
 729            return False, f"Daemon '{self.daemon_id}' is stopped and cannot be resumed."
 730
 731        self._remove_stop_file()
 732        try:
 733            if self.process is None:
 734                return False, f"Cannot resume daemon '{self.daemon_id}'."
 735
 736            self.process.resume()
 737        except Exception as e:
 738            return False, f"Failed to resume daemon '{self.daemon_id}':\n{e}"
 739
 740        timeout = self.get_timeout_seconds(timeout)
 741        check_timeout_interval = self.get_check_timeout_interval_seconds(
 742            check_timeout_interval
 743        )
 744
 745        if not timeout:
 746            success = self.status == 'running'
 747            msg = "Success" if success else f"Failed to resume daemon '{self.daemon_id}'."
 748            if success:
 749                self._capture_process_timestamp('began')
 750            return success, msg
 751
 752        begin = time.perf_counter()
 753        while (time.perf_counter() - begin) < timeout:
 754            if self.status == 'running':
 755                self._capture_process_timestamp('began')
 756                return True, "Success"
 757            time.sleep(check_timeout_interval)
 758
 759        return False, (
 760            f"Failed to resume daemon '{self.daemon_id}' within {timeout} second"
 761            + ('s' if timeout != 1 else '') + '.'
 762        )
 763
 764    def _write_stop_file(self, action: str) -> SuccessTuple:
 765        """Write the stop file timestamp and action."""
 766        if action not in ('quit', 'kill', 'pause'):
 767            return False, f"Unsupported action '{action}'."
 768
 769        if not self.stop_path.parent.exists():
 770            self.stop_path.parent.mkdir(parents=True, exist_ok=True)
 771
 772        with open(self.stop_path, 'w+', encoding='utf-8') as f:
 773            json.dump(
 774                {
 775                    'stop_time': datetime.now(timezone.utc).isoformat(),
 776                    'action': action,
 777                },
 778                f
 779            )
 780
 781        return True, "Success"
 782
 783    def _remove_stop_file(self) -> SuccessTuple:
 784        """Remove the stop file"""
 785        if not self.stop_path.exists():
 786            return True, "Stop file does not exist."
 787
 788        try:
 789            self.stop_path.unlink()
 790        except Exception as e:
 791            return False, f"Failed to remove stop file:\n{e}"
 792
 793        return True, "Success"
 794
 795    def _read_stop_file(self) -> Dict[str, Any]:
 796        """
 797        Read the stop file if it exists.
 798        """
 799        if not self.stop_path.exists():
 800            return {}
 801
 802        try:
 803            with open(self.stop_path, 'r', encoding='utf-8') as f:
 804                data = json.load(f)
 805            return data
 806        except Exception:
 807            return {}
 808
 809    def _remove_blocking_stdin_file(self) -> mrsm.SuccessTuple:
 810        """
 811        Remove the blocking STDIN file if it exists.
 812        """
 813        try:
 814            if self.blocking_stdin_file_path.exists():
 815                self.blocking_stdin_file_path.unlink()
 816        except Exception as e:
 817            return False, str(e)
 818
 819        return True, "Success"
 820
 821    def _handle_sigterm(self, signal_number: int, stack_frame: 'frame') -> None:
 822        """
 823        Handle `SIGTERM` within the `Daemon` context.
 824        This method is injected into the `DaemonContext`.
 825        """
 826        from meerschaum.utils.process import signal_handler
 827        from meerschaum.utils.threading import request_stop, interrupt_threads
 828        signal_handler(signal_number, stack_frame)
 829
 830        ### Tell cooperative loops (e.g. `sync pipes`) to stop, then actively unwind
 831        ### any worker threads so they cannot keep the process alive as a zombie.
 832        request_stop()
 833        interrupt_threads(SystemExit)
 834
 835        timer = self.__dict__.get('_log_refresh_timer', None)
 836        if timer is not None:
 837            timer.cancel()
 838
 839        daemon_context = self.__dict__.get('_daemon_context', None)
 840        if daemon_context is not None:
 841            daemon_context.close()
 842
 843        _close_pools()
 844        raise SystemExit(0)
 845
 846    def _send_signal(
 847        self,
 848        signal_to_send,
 849        timeout: Union[float, int, None] = None,
 850        check_timeout_interval: Union[float, int, None] = None,
 851    ) -> SuccessTuple:
 852        """Send a signal to the daemon process.
 853
 854        Parameters
 855        ----------
 856        signal_to_send:
 857            The signal the send to the daemon, e.g. `signals.SIGINT`.
 858
 859        timeout: Union[float, int, None], default None
 860            The maximum number of seconds to wait for a process to terminate.
 861
 862        check_timeout_interval: Union[float, int, None], default None
 863            The number of seconds to wait between checking if the process is still running.
 864
 865        Returns
 866        -------
 867        A SuccessTuple indicating success.
 868        """
 869        try:
 870            pid = self.pid
 871            if pid is None:
 872                return (
 873                    False,
 874                    f"Daemon '{self.daemon_id}' is not running, "
 875                    + f"cannot send signal '{signal_to_send}'."
 876                )
 877            
 878            os.kill(pid, signal_to_send)
 879        except Exception:
 880            return False, f"Failed to send signal {signal_to_send}:\n{traceback.format_exc()}"
 881
 882        timeout = self.get_timeout_seconds(timeout)
 883        check_timeout_interval = self.get_check_timeout_interval_seconds(
 884            check_timeout_interval
 885        )
 886
 887        if not timeout:
 888            return True, f"Successfully sent '{signal_to_send}' to daemon '{self.daemon_id}'."
 889
 890        begin = time.perf_counter()
 891        while (time.perf_counter() - begin) < timeout:
 892            if not self.status == 'running':
 893                return True, "Success"
 894            time.sleep(check_timeout_interval)
 895
 896        return False, (
 897            f"Failed to stop daemon '{self.daemon_id}' (PID: {pid}) within {timeout} second"
 898            + ('s' if timeout != 1 else '') + '.'
 899        )
 900
 901    def _get_process_timestamps(self) -> Dict[str, Optional[datetime]]:
 902        """
 903        Return the `began`, `ended`, and `launched` timestamps recorded for this daemon,
 904        read from the properties file (so no process is inspected).
 905        """
 906        try:
 907            properties = self.read_properties()
 908        except Exception:
 909            properties = None
 910        if not properties:
 911            properties = self.__dict__.get('_properties', None) or {}
 912        process_properties = properties.get('process', None) or {}
 913        return {
 914            key: _detached.parse_timestamp(process_properties.get(key, None))
 915            for key in ('began', 'ended', 'launched')
 916        }
 917
 918    @staticmethod
 919    def _is_completed(timestamps: Dict[str, Optional[datetime]]) -> bool:
 920        """
 921        Return whether the timestamps show that the last run has ended.
 922        A job which was restarted has `began` or `launched` later than its stale `ended`.
 923        """
 924        ended = timestamps.get('ended', None)
 925        if ended is None:
 926            return False
 927        started = [
 928            moment
 929            for moment in (timestamps.get('began', None), timestamps.get('launched', None))
 930            if moment is not None
 931        ]
 932        return not started or ended >= max(started)
 933
 934    def _find_detached_pids(
 935        self,
 936        force: bool = False,
 937        verify_began: bool = False,
 938    ) -> List[int]:
 939        """
 940        Return the PIDs of any processes whose command line references this daemon_id.
 941
 942        A daemon is launched via `venv_exec` with `Daemon(daemon_id='<id>')` embedded in
 943        the executed code, so the daemon_id stays visible in the process's command line.
 944        When the PID file is lost (e.g. the launcher exited without cleanup, leaving an
 945        orphaned/detached daemon), this is the only reliable way to find the process —
 946        otherwise stopping the job reports a false "already stopped" and the user must
 947        resort to `pkill meerschaum` or hunting the daemon_id in `htop`.
 948
 949        Scanning the process table is expensive, so unless `force` is `True`
 950        no scan happens for a daemon which does not exist or whose last run has ended,
 951        one scan is shared by all daemons for a couple of seconds,
 952        and a daemon which was not found is not searched for again for a minute.
 953
 954        Parameters
 955        ----------
 956        force: bool, default False
 957            If `True`, search even if the daemon is recorded as completed or was not found
 958            recently (e.g. to stop it).
 959
 960        verify_began: bool, default False
 961            If `True`, ignore processes which started after the daemon recorded
 962            that it began (a reused PID).
 963        """
 964        timestamps = self._get_process_timestamps()
 965        fingerprint = tuple(
 966            moment.isoformat() if moment is not None else None
 967            for moment in (timestamps['began'], timestamps['ended'], timestamps['launched'])
 968        )
 969        began = timestamps['began']
 970        launched = timestamps['launched']
 971        in_launch_window = (
 972            launched is not None
 973            and (began is None or began < launched)
 974            and (
 975                (datetime.now(timezone.utc).replace(tzinfo=None) - launched).total_seconds()
 976                < _detached.LAUNCH_GRACE_SECONDS
 977            )
 978        )
 979        if not force:
 980            if not self.path.exists() or self._is_completed(timestamps):
 981                return []
 982
 983            if not in_launch_window and _detached.is_known_missing(
 984                self.daemon_id,
 985                fingerprint,
 986            ):
 987                return []
 988
 989        pids = _detached.find_pids(
 990            self.daemon_id,
 991            not_older_than=(launched if in_launch_window else None),
 992        )
 993
 994        if verify_began and pids:
 995            if began is not None and (launched is None or began >= launched):
 996                pids = [
 997                    pid
 998                    for pid in pids
 999                    if not _detached.is_stale_process(
1000                        pid,
1001                        began,
1002                        _detached.CREATE_TIME_SLACK_SECONDS,
1003                    )
1004                ]
1005
1006        if not pids and not force and not in_launch_window:
1007            _detached.remember_missing(self.daemon_id, fingerprint)
1008        return pids
1009
1010    def _restore_pid_file(self, pid: int) -> None:
1011        """
1012        Write the PID file again for a running daemon which lost it, so that later reads
1013        do not have to search for the process. This is only done once the daemon has
1014        recorded that it began, because before that the process found may be the launcher.
1015        """
1016        timestamps = self._get_process_timestamps()
1017        began, launched = timestamps['began'], timestamps['launched']
1018        if began is None or (launched is not None and began < launched):
1019            return
1020        try:
1021            if not self.pid_path.exists():
1022                with open(self.pid_path, 'w+', encoding='utf-8') as f:
1023                    f.write(str(pid))
1024        except Exception:
1025            pass
1026
1027    def _kill_detached_processes(self, timeout: Union[int, float, None] = None) -> int:
1028        """
1029        SIGTERM then SIGKILL any detached processes referencing this daemon_id.
1030
1031        Fallback for when the PID file is gone but the daemon (or its threads) are still
1032        alive. Returns the number of processes that were targeted.
1033        """
1034        psutil = attempt_import('psutil')
1035        pids = self._find_detached_pids(force=True)
1036        if not pids:
1037            return 0
1038
1039        procs = []
1040        for pid in pids:
1041            try:
1042                procs.append(psutil.Process(pid))
1043            except Exception:
1044                continue
1045        for proc in procs:
1046            try:
1047                proc.terminate()
1048            except Exception:
1049                pass
1050
1051        timeout = self.get_timeout_seconds(timeout)
1052        try:
1053            _, alive = psutil.wait_procs(procs, timeout=(timeout or 8))
1054        except Exception:
1055            alive = procs
1056        for proc in alive:
1057            try:
1058                proc.kill()
1059            except Exception:
1060                pass
1061        _detached.invalidate(self.daemon_id)
1062        return len(procs)
1063
1064    def mkdir_if_not_exists(self, allow_dirty_run: bool = False):
1065        """Create the Daemon's directory.
1066        If `allow_dirty_run` is `False` and the directory already exists,
1067        raise a `FileExistsError`.
1068        """
1069        try:
1070            self.path.mkdir(parents=True, exist_ok=True)
1071            _already_exists = any(os.scandir(self.path))
1072        except FileExistsError:
1073            _already_exists = True
1074
1075        if _already_exists and not allow_dirty_run:
1076            error(
1077                f"Daemon '{self.daemon_id}' already exists. " +
1078                "To allow this daemon to run, do one of the following:\n"
1079                + "  - Execute `daemon.cleanup()`.\n"
1080                + f"  - Delete the directory '{self.path}'.\n"
1081                + "  - Pass `allow_dirty_run=True` to `daemon.run()`.\n",
1082                FileExistsError,
1083            )
1084
1085    @property
1086    def process(self) -> Union['psutil.Process', None]:
1087        """
1088        Return the psutil process for the Daemon.
1089        """
1090        psutil = attempt_import('psutil')
1091        pid = self.pid
1092        if pid is None:
1093            return None
1094        if '_process' not in self.__dict__ or self.__dict__['_process'].pid != int(pid):
1095            try:
1096                self._process = psutil.Process(int(pid))
1097                process_exists = True
1098            except Exception:
1099                process_exists = False
1100            if not process_exists:
1101                _ = self.__dict__.pop('_process', None)
1102                try:
1103                    if self.pid_path.exists():
1104                        self.pid_path.unlink()
1105                except Exception:
1106                    pass
1107                return None
1108        return self._process
1109
1110    @property
1111    def status(self) -> str:
1112        """
1113        Return the running status of this Daemon.
1114        """
1115        process = self.process
1116        if process is None:
1117            return 'stopped'
1118
1119        psutil = attempt_import('psutil', lazy=False)
1120        try:
1121            process_status = process.status()
1122            if process_status == 'stopped':
1123                return 'paused'
1124            if process_status == 'zombie':
1125                raise psutil.NoSuchProcess(process.pid)
1126        except (psutil.NoSuchProcess, AttributeError):
1127            if self.pid_path.exists():
1128                try:
1129                    self.pid_path.unlink()
1130                except Exception:
1131                    pass
1132            return 'stopped'
1133
1134        return 'running'
1135
1136    @classmethod
1137    def _get_path_from_daemon_id(cls, daemon_id: str) -> pathlib.Path:
1138        """
1139        Return a Daemon's path from its `daemon_id`.
1140        """
1141        import meerschaum.config.paths as paths
1142        return paths.DAEMON_RESOURCES_PATH / daemon_id
1143
1144    @property
1145    def path(self) -> pathlib.Path:
1146        """
1147        Return the path for this Daemon's directory.
1148        """
1149        return self._get_path_from_daemon_id(self.daemon_id)
1150
1151    @classmethod
1152    def _get_properties_path_from_daemon_id(cls, daemon_id: str) -> pathlib.Path:
1153        """
1154        Return the `properties.json` path for a given `daemon_id`.
1155        """
1156        return cls._get_path_from_daemon_id(daemon_id) / 'properties.json'
1157
1158    @property
1159    def properties_path(self) -> pathlib.Path:
1160        """
1161        Return the `propterties.json` path for this Daemon.
1162        """
1163        return self._get_properties_path_from_daemon_id(self.daemon_id)
1164
1165    @property
1166    def stop_path(self) -> pathlib.Path:
1167        """
1168        Return the path for the stop file (created when manually stopped).
1169        """
1170        return self.path / '.stop.json'
1171
1172    @property
1173    def log_path(self) -> pathlib.Path:
1174        """
1175        Return the log path.
1176        """
1177        logs_cf = self.properties.get('logs', None) or {}
1178        if 'path' not in logs_cf:
1179            import meerschaum.config.paths as paths
1180            return paths.LOGS_RESOURCES_PATH / (self.daemon_id + '.log')
1181
1182        return pathlib.Path(logs_cf['path'])
1183
1184    @property
1185    def stdin_file_path(self) -> pathlib.Path:
1186        """
1187        Return the stdin file path.
1188        """
1189        return self.path / 'input.stdin'
1190
1191    @property
1192    def blocking_stdin_file_path(self) -> pathlib.Path:
1193        """
1194        Return the stdin file path.
1195        """
1196        if '_blocking_stdin_file_path' in self.__dict__:
1197            return self._blocking_stdin_file_path
1198
1199        return self.path / 'input.stdin.block'
1200
1201    @property
1202    def prompt_kwargs_file_path(self) -> pathlib.Path:
1203        """
1204        Return the file path to the kwargs for the invoking `prompt()`.
1205        """
1206        return self.path / 'prompt_kwargs.json'
1207
1208    @property
1209    def log_offset_path(self) -> pathlib.Path:
1210        """
1211        Return the log offset file path.
1212        """
1213        import meerschaum.config.paths as paths
1214        return paths.LOGS_RESOURCES_PATH / ('.' + self.daemon_id + '.log.offset')
1215
1216    @property
1217    def log_offset_lock(self) -> 'InterProcessLock':
1218        """
1219        Return the process lock context manager.
1220        """
1221        if '_log_offset_lock' in self.__dict__:
1222            return self._log_offset_lock
1223
1224        from meerschaum.utils.locks import InterProcessLock
1225        self._log_offset_lock = InterProcessLock(self.log_offset_path)
1226        return self._log_offset_lock
1227
1228    @property
1229    def rotating_log(self) -> RotatingFile:
1230        """
1231        The rotating log file for the daemon's output.
1232        """
1233        if '_rotating_log' in self.__dict__:
1234            return self._rotating_log
1235
1236        logs_cf = self.properties.get('logs', None) or {}
1237        write_timestamps = logs_cf.get('write_timestamps', None)
1238        if write_timestamps is None:
1239            write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled')
1240
1241        timestamp_format = logs_cf.get('timestamp_format', None)
1242        if timestamp_format is None:
1243            timestamp_format = get_config('jobs', 'logs', 'timestamps', 'format')
1244
1245        num_files_to_keep = logs_cf.get('num_files_to_keep', None)
1246        if num_files_to_keep is None:
1247            num_files_to_keep = get_config('jobs', 'logs', 'num_files_to_keep')
1248
1249        max_file_size = logs_cf.get('max_file_size', None)
1250        if max_file_size is None:
1251            max_file_size = get_config('jobs', 'logs', 'max_file_size')
1252
1253        redirect_streams = logs_cf.get('redirect_streams', True)
1254
1255        self._rotating_log = RotatingFile(
1256            self.log_path,
1257            redirect_streams=redirect_streams,
1258            write_timestamps=write_timestamps,
1259            timestamp_format=timestamp_format,
1260            num_files_to_keep=num_files_to_keep,
1261            max_file_size=max_file_size,
1262        )
1263        return self._rotating_log
1264
1265    @property
1266    def stdin_file(self):
1267        """
1268        Return the file handler for the stdin file.
1269        """
1270        if (_stdin_file := self.__dict__.get('_stdin_file', None)):
1271            return _stdin_file
1272
1273        self._stdin_file = StdinFile(
1274            self.stdin_file_path,
1275            lock_file_path=self.blocking_stdin_file_path,
1276        )
1277        return self._stdin_file
1278
1279    @property
1280    def log_text(self) -> Union[str, None]:
1281        """
1282        Read the log files and return their contents.
1283        Returns `None` if the log file does not exist.
1284        """
1285        logs_cf = self.properties.get('logs', None) or {}
1286        write_timestamps = logs_cf.get('write_timestamps', None)
1287        if write_timestamps is None:
1288            write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled')
1289
1290        timestamp_format = logs_cf.get('timestamp_format', None)
1291        if timestamp_format is None:
1292            timestamp_format = get_config('jobs', 'logs', 'timestamps', 'format')
1293
1294        num_files_to_keep = logs_cf.get('num_files_to_keep', None)
1295        if num_files_to_keep is None:
1296            num_files_to_keep = get_config('jobs', 'logs', 'num_files_to_keep')
1297
1298        max_file_size = logs_cf.get('max_file_size', None)
1299        if max_file_size is None:
1300            max_file_size = get_config('jobs', 'logs', 'max_file_size')
1301
1302        new_rotating_log = RotatingFile(
1303            self.rotating_log.file_path,
1304            num_files_to_keep=num_files_to_keep,
1305            max_file_size=max_file_size,
1306            write_timestamps=write_timestamps,
1307            timestamp_format=timestamp_format,
1308        )
1309        return new_rotating_log.read()
1310
1311    def readlines(self) -> List[str]:
1312        """
1313        Read the next log lines, persisting the cursor for later use.
1314        Note this will alter the cursor of `self.rotating_log`.
1315        """
1316        self.rotating_log._cursor = self._read_log_offset()
1317        lines = self.rotating_log.readlines()
1318        self._write_log_offset()
1319        return lines
1320
1321    def _read_log_offset(self) -> Tuple[int, int]:
1322        """
1323        Return the current log offset cursor.
1324
1325        Returns
1326        -------
1327        A tuple of the form (`subfile_index`, `position`).
1328        """
1329        if not self.log_offset_path.exists():
1330            return 0, 0
1331
1332        try:
1333            with open(self.log_offset_path, 'r', encoding='utf-8') as f:
1334                cursor_text = f.read()
1335            cursor_parts = cursor_text.split(' ')
1336            subfile_index, subfile_position = int(cursor_parts[0]), int(cursor_parts[1])
1337            return subfile_index, subfile_position
1338        except Exception as e:
1339            warn(f"Failed to read cursor:\n{e}")
1340        return 0, 0
1341
1342    def _write_log_offset(self) -> None:
1343        """
1344        Write the current log offset file.
1345        """
1346        with self.log_offset_lock:
1347            with open(self.log_offset_path, 'w+', encoding='utf-8') as f:
1348                subfile_index = self.rotating_log._cursor[0]
1349                subfile_position = self.rotating_log._cursor[1]
1350                f.write(f"{subfile_index} {subfile_position}")
1351
1352    @property
1353    def pid(self) -> Union[int, None]:
1354        """
1355        Read the PID file and return its contents.
1356        Returns `None` if the PID file does not exist.
1357        """
1358        if not self.pid_path.exists():
1359            ### The PID file can be lost while a detached daemon keeps running (e.g. the
1360            ### launcher exited without cleanup). Recover the PID by finding the process
1361            ### whose command line embeds this daemon_id, so `status`/`stop`/`kill` see the
1362            ### live process instead of reporting a false "stopped" (which would strand the
1363            ### orphan, forcing a manual `pkill meerschaum`).
1364            detached_pids = self._find_detached_pids(verify_began=True)
1365            if not detached_pids:
1366                return None
1367            self._restore_pid_file(detached_pids[0])
1368            return detached_pids[0]
1369        try:
1370            with open(self.pid_path, 'r', encoding='utf-8') as f:
1371                text = f.read()
1372            if len(text) == 0:
1373                return None
1374            pid = int(text.rstrip())
1375        except Exception as e:
1376            warn(e)
1377            text = None
1378            pid = None
1379        return pid
1380
1381    @property
1382    def pid_path(self) -> pathlib.Path:
1383        """
1384        Return the path to a file containing the PID for this Daemon.
1385        """
1386        return self.path / 'process.pid'
1387
1388    @property
1389    def pid_lock(self) -> 'InterProcessLock':
1390        """
1391        Return the process lock context manager.
1392        """
1393        if '_pid_lock' in self.__dict__:
1394            return self._pid_lock
1395
1396        from meerschaum.utils.locks import InterProcessLock
1397        self._pid_lock = InterProcessLock(self.pid_path)
1398        return self._pid_lock
1399
1400    @property
1401    def pickle_path(self) -> pathlib.Path:
1402        """
1403        Return the path for the pickle file.
1404        """
1405        return self.path / 'pickle.pkl'
1406
1407    def read_properties(self) -> Optional[Dict[str, Any]]:
1408        """Read the properties JSON file and return the dictionary."""
1409        if not self.properties_path.exists():
1410            return None
1411        try:
1412            with open(self.properties_path, 'r', encoding='utf-8') as file:
1413                properties = json.load(file)
1414        except Exception:
1415            properties = {}
1416        
1417        return properties or {}
1418
1419    def read_pickle(self) -> Daemon:
1420        """Read a Daemon's pickle file and return the `Daemon`."""
1421        import pickle
1422        import traceback
1423        if not self.pickle_path.exists():
1424            error(f"Pickle file does not exist for daemon '{self.daemon_id}'.")
1425
1426        if self.pickle_path.stat().st_size == 0:
1427            error(f"Pickle was empty for daemon '{self.daemon_id}'.")
1428
1429        try:
1430            with open(self.pickle_path, 'rb') as pickle_file:
1431                daemon = pickle.load(pickle_file)
1432            success, msg = True, 'Success'
1433        except Exception as e:
1434            success, msg = False, str(e)
1435            daemon = None
1436            traceback.print_exception(type(e), e, e.__traceback__)
1437        if not success:
1438            error(msg)
1439        return daemon
1440
1441    @property
1442    def properties(self) -> Dict[str, Any]:
1443        """
1444        Return the contents of the properties JSON file.
1445        """
1446        try:
1447            _file_properties = self.read_properties() or {}
1448        except Exception:
1449            traceback.print_exc()
1450            _file_properties = {}
1451
1452        if not self._properties:
1453            self._properties = _file_properties
1454
1455        if self._properties is None:
1456            self._properties = {}
1457
1458        if (
1459            self._properties.get('result', None) is None
1460            and _file_properties.get('result', None) is not None
1461        ):
1462            _ = self._properties.pop('result', None)
1463
1464        if _file_properties is not None:
1465            self._properties = apply_patch_to_config(
1466                _file_properties,
1467                self._properties,
1468            )
1469
1470        return self._properties
1471
1472    @property
1473    def hidden(self) -> bool:
1474        """
1475        Return a bool indicating whether this Daemon should be displayed.
1476        """
1477        return self.daemon_id.startswith('_') or self.daemon_id.startswith('.')
1478
1479    def write_properties(self) -> SuccessTuple:
1480        """Write the properties dictionary to the properties JSON file
1481        (only if self.properties exists).
1482        """
1483        from meerschaum.utils.misc import generate_password
1484        success, msg = (
1485            False,
1486            f"No properties to write for daemon '{self.daemon_id}'."
1487        )
1488        backup_path = self.properties_path.parent / (generate_password(8) + '.json')
1489        props = self.properties
1490        if props is not None:
1491            try:
1492                self.path.mkdir(parents=True, exist_ok=True)
1493                if self.properties_path.exists():
1494                    self.properties_path.rename(backup_path)
1495                with open(self.properties_path, 'w+', encoding='utf-8') as properties_file:
1496                    json.dump(props, properties_file)
1497                success, msg = True, 'Success'
1498            except Exception as e:
1499                success, msg = False, str(e)
1500
1501        try:
1502            if backup_path.exists():
1503                if not success:
1504                    backup_path.rename(self.properties_path)
1505                else:
1506                    backup_path.unlink()
1507        except Exception as e:
1508            success, msg = False, str(e)
1509
1510        return success, msg
1511
1512    def write_pickle(self) -> SuccessTuple:
1513        """Write the pickle file for the daemon."""
1514        import pickle
1515        import traceback
1516        from meerschaum.utils.misc import generate_password
1517
1518        if not self.pickle:
1519            return True, "Success"
1520
1521        from meerschaum._internal.entry import _shells
1522        if _shells:
1523            from meerschaum._internal.shell.Shell import revert_input
1524            revert_input()
1525
1526        backup_path = self.pickle_path.parent / (generate_password(7) + '.pkl')
1527        try:
1528            self.path.mkdir(parents=True, exist_ok=True)
1529            if self.pickle_path.exists():
1530                self.pickle_path.rename(backup_path)
1531            with open(self.pickle_path, 'wb+') as pickle_file:
1532                pickle.dump(self, pickle_file)
1533            success, msg = True, "Success"
1534        except Exception as e:
1535            success, msg = False, str(e)
1536            traceback.print_exception(type(e), e, e.__traceback__)
1537        try:
1538            if backup_path.exists():
1539                if not success:
1540                    backup_path.rename(self.pickle_path)
1541                else:
1542                    backup_path.unlink()
1543        except Exception as e:
1544            success, msg = False, str(e)
1545        return success, msg
1546
1547
1548    def _setup(
1549        self,
1550        allow_dirty_run: bool = False,
1551    ) -> None:
1552        """
1553        Update properties before starting the Daemon.
1554        """
1555        if self.properties is None:
1556            self._properties = {}
1557
1558        self._properties.update({
1559            'target': {
1560                'name': self.target.__name__,
1561                'module': self.target.__module__,
1562                'args': self.target_args,
1563                'kw': self.target_kw,
1564            },
1565        })
1566        self.mkdir_if_not_exists(allow_dirty_run)
1567        _write_properties_success_tuple = self.write_properties()
1568        if not _write_properties_success_tuple[0]:
1569            error(_write_properties_success_tuple[1])
1570
1571        _write_pickle_success_tuple = self.write_pickle()
1572        if not _write_pickle_success_tuple[0]:
1573            error(_write_pickle_success_tuple[1])
1574
1575    def cleanup(self, keep_logs: bool = False) -> SuccessTuple:
1576        """
1577        Remove a daemon's directory after execution.
1578
1579        Parameters
1580        ----------
1581        keep_logs: bool, default False
1582            If `True`, skip deleting the daemon's log files.
1583
1584        Returns
1585        -------
1586        A `SuccessTuple` indicating success.
1587        """
1588        _detached.invalidate(self.daemon_id)
1589        if self.path.exists():
1590            try:
1591                shutil.rmtree(self.path)
1592            except Exception as e:
1593                msg = f"Failed to clean up '{self.daemon_id}':\n{e}"
1594                warn(msg)
1595                return False, msg
1596        if not keep_logs:
1597            self.rotating_log.delete()
1598            try:
1599                if self.log_offset_path.exists():
1600                    self.log_offset_path.unlink()
1601            except Exception as e:
1602                msg = f"Failed to remove offset file for '{self.daemon_id}':\n{e}"
1603                warn(msg)
1604                return False, msg
1605        return True, "Success"
1606
1607
1608    def get_timeout_seconds(self, timeout: Union[int, float, None] = None) -> Union[int, float]:
1609        """
1610        Return the timeout value to use. Use `--timeout-seconds` if provided,
1611        else the configured default (8).
1612        """
1613        if isinstance(timeout, (int, float)):
1614            return timeout
1615        return get_config('jobs', 'timeout_seconds')
1616
1617
1618    def get_check_timeout_interval_seconds(
1619        self,
1620        check_timeout_interval: Union[int, float, None] = None,
1621    ) -> Union[int, float]:
1622        """
1623        Return the interval value to check the status of timeouts.
1624        """
1625        if isinstance(check_timeout_interval, (int, float)):
1626            return check_timeout_interval
1627        return get_config('jobs', 'check_timeout_interval_seconds')
1628
1629    @property
1630    def target_args(self) -> Union[Tuple[Any], None]:
1631        """
1632        Return the positional arguments to pass to the target function.
1633        """
1634        target_args = (
1635            self.__dict__.get('_target_args', None)
1636            or self.properties.get('target', {}).get('args', None)
1637        )
1638        if target_args is None:
1639            return tuple([])
1640
1641        return tuple(target_args)
1642
1643    @property
1644    def target_kw(self) -> Union[Dict[str, Any], None]:
1645        """
1646        Return the keyword arguments to pass to the target function.
1647        """
1648        target_kw = (
1649            self.__dict__.get('_target_kw', None)
1650            or self.properties.get('target', {}).get('kw', None)
1651        )
1652        if target_kw is None:
1653            return {}
1654
1655        return {key: val for key, val in target_kw.items()}
1656
1657    @staticmethod
1658    def _get_target_reference(target) -> Union[Dict[str, str], None]:
1659        """
1660        If `target` is an importable, top-level function (e.g. `entry`), return a
1661        ``{'module': ..., 'qualname': ...}`` reference so it can be re-imported in the
1662        daemon process instead of pickled by value.
1663
1664        Pickling such a target by value serializes its entire global graph, which can
1665        reach unpicklable live state (e.g. a connector caching an `_asyncio.Task`) and
1666        raise `TypeError: cannot pickle '_asyncio.Task' object`. dill also falls back to
1667        by-value pickling whenever it cannot match the function by identity — which
1668        happens when two copies of a package are importable (a stale install shadowing
1669        a venv), so `byref=True` alone is not enough. Returns `None` for closures,
1670        lambdas, and anything not importable, which fall back to dill.
1671        """
1672        module = getattr(target, '__module__', None)
1673        qualname = getattr(target, '__qualname__', None)
1674        if not module or not qualname:
1675            return None
1676        if '<locals>' in qualname or '<lambda>' in qualname:
1677            return None
1678        return {'module': module, 'qualname': qualname}
1679
1680    @staticmethod
1681    def _load_target_reference(target_ref: Dict[str, str]):
1682        """
1683        Re-import a target from a `{'module': ..., 'qualname': ...}` reference.
1684        """
1685        import importlib
1686        obj = importlib.import_module(target_ref['module'])
1687        for part in target_ref['qualname'].split('.'):
1688            obj = getattr(obj, part)
1689        return obj
1690
1691    def __getstate__(self):
1692        """
1693        Pickle this Daemon.
1694        """
1695        dill = attempt_import('dill')
1696        state = {
1697            'target_args': self.target_args,
1698            'target_kw': self.target_kw,
1699            'daemon_id': self.daemon_id,
1700            'label': self.label,
1701            'properties': self.properties,
1702        }
1703        target_ref = self._get_target_reference(self.target)
1704        if target_ref is not None:
1705            ### Store by reference (re-imported in the daemon process), not by value.
1706            state['target'] = None
1707            state['target_ref'] = target_ref
1708        else:
1709            state['target'] = dill.dumps(self.target, byref=True)
1710        return state
1711
1712    def __setstate__(self, _state: Dict[str, Any]):
1713        """
1714        Restore this Daemon from a pickled state.
1715        If the properties file exists, skip the old pickled version.
1716        """
1717        dill = attempt_import('dill')
1718        target_ref = _state.pop('target_ref', None)
1719        if target_ref is not None and _state.get('target', None) is None:
1720            _state['target'] = self._load_target_reference(target_ref)
1721        else:
1722            _state['target'] = dill.loads(_state['target'])
1723        self._pickle = True
1724        daemon_id = _state.get('daemon_id', None)
1725        if not daemon_id:
1726            raise ValueError("Need a daemon_id to un-pickle a Daemon.")
1727
1728        properties_path = self._get_properties_path_from_daemon_id(daemon_id)
1729        ignore_properties = properties_path.exists()
1730        if ignore_properties:
1731            _state = {
1732                key: val
1733                for key, val in _state.items()
1734                if key != 'properties'
1735            }
1736        self.__init__(**_state)
1737
1738
1739    def __repr__(self):
1740        return str(self)
1741
1742    def __str__(self):
1743        return self.daemon_id
1744
1745    def __eq__(self, other):
1746        if not isinstance(other, Daemon):
1747            return False
1748        return self.daemon_id == other.daemon_id
1749
1750    def __hash__(self):
1751        return hash(self.daemon_id)

Daemonize Python functions into background processes.

Examples
>>> import meerschaum as mrsm
>>> from meerschaum.utils.daemons import Daemon
>>> daemon = Daemon(print, ('hi',))
>>> success, msg = daemon.run()
>>> print(daemon.log_text)

2024-07-29 18:03 | hi 2024-07-29 18:03 |

>>> daemon.run(allow_dirty_run=True)
>>> print(daemon.log_text)

2024-07-29 18:03 | hi 2024-07-29 18:03 | 2024-07-29 18:05 | hi 2024-07-29 18:05 |

>>> mrsm.pprint(daemon.properties)
{
    'label': 'print',
    'target': {'name': 'print', 'module': 'builtins', 'args': ['hi'], 'kw': {}},
    'result': None,
    'process': {'ended': '2024-07-29T18:03:33.752806'}
}
Daemon( target: Optional[Callable[[Any], Any]] = None, target_args: Union[List[Any], Tuple[Any], NoneType] = None, target_kw: Optional[Dict[str, Any]] = None, env: Optional[Dict[str, str]] = None, daemon_id: Optional[str] = None, label: Optional[str] = None, properties: Optional[Dict[str, Any]] = None, pickle: bool = True)
137    def __init__(
138        self,
139        target: Optional[Callable[[Any], Any]] = None,
140        target_args: Union[List[Any], Tuple[Any], None] = None,
141        target_kw: Optional[Dict[str, Any]] = None,
142        env: Optional[Dict[str, str]] = None,
143        daemon_id: Optional[str] = None,
144        label: Optional[str] = None,
145        properties: Optional[Dict[str, Any]] = None,
146        pickle: bool = True,
147    ):
148        """
149        Parameters
150        ----------
151        target: Optional[Callable[[Any], Any]], default None,
152            The function to execute in a child process.
153
154        target_args: Union[List[Any], Tuple[Any], None], default None
155            Positional arguments to pass to the target function.
156
157        target_kw: Optional[Dict[str, Any]], default None
158            Keyword arguments to pass to the target function.
159
160        env: Optional[Dict[str, str]], default None
161            If provided, set these environment variables in the daemon process.
162
163        daemon_id: Optional[str], default None
164            Build a `Daemon` from an existing `daemon_id`.
165            If `daemon_id` is provided, other arguments are ignored and are derived
166            from the existing pickled `Daemon`.
167
168        label: Optional[str], default None
169            Label string to help identifiy a daemon.
170            If `None`, use the function name instead.
171
172        properties: Optional[Dict[str, Any]], default None
173            Override reading from the properties JSON by providing an existing dictionary.
174        """
175        _pickle = self.__dict__.get('_pickle', False)
176        if daemon_id is not None:
177            self.daemon_id = daemon_id
178            if not self.pickle_path.exists() and not target and ('target' not in self.__dict__):
179
180                if not self.properties_path.exists():
181                    raise Exception(
182                        f"Daemon '{self.daemon_id}' does not exist. "
183                        + "Pass a target to create a new Daemon."
184                    )
185
186                try:
187                    new_daemon = self.from_properties_file(daemon_id)
188                except Exception:
189                    new_daemon = None
190
191                if new_daemon is not None:
192                    new_daemon.write_pickle()
193                    target = new_daemon.target
194                    target_args = new_daemon.target_args
195                    target_kw = new_daemon.target_kw
196                    label = new_daemon.label
197                    self._properties = new_daemon.properties
198                else:
199                    try:
200                        self.properties_path.unlink()
201                    except Exception:
202                        pass
203
204                    raise Exception(
205                        f"Could not recover daemon '{self.daemon_id}' "
206                        + "from its properties file."
207                    )
208
209        if 'target' not in self.__dict__:
210            if target is None:
211                error("Cannot create a Daemon without a target.")
212            self.target = target
213
214        self.pickle = pickle
215
216        ### NOTE: We have to check self.__dict__ in case we un-pickling.
217        if '_target_args' not in self.__dict__:
218            self._target_args = target_args
219        if '_target_kw' not in self.__dict__:
220            self._target_kw = target_kw
221
222        if 'label' not in self.__dict__:
223            if label is None:
224                label = (
225                    self.target.__name__ if '__name__' in self.target.__dir__()
226                        else str(self.target)
227                )
228            self.label = label
229        elif label is not None:
230            self.label = label
231
232        if 'daemon_id' not in self.__dict__:
233            self.daemon_id = get_new_daemon_name()
234        if '_properties' not in self.__dict__:
235            self._properties = properties
236        elif properties:
237            if self._properties is None:
238                self._properties = {}
239            self._properties.update(properties)
240        if self._properties is None:
241            self._properties = {}
242
243        self._properties.update({'label': self.label})
244        if env:
245            self._properties.update({'env': env})
246
247        ### Instantiate the process and if it doesn't exist, make sure the stale PID file
248        ### is removed. Without a PID file there is nothing to clean up,
249        ### so don't go looking for a detached process while constructing.
250        if self.pid_path.exists():
251            _ = self.process
Parameters
  • target (Optional[Callable[[Any], Any]], default None,): The function to execute in a child process.
  • target_args (Union[List[Any], Tuple[Any], None], default None): Positional arguments to pass to the target function.
  • target_kw (Optional[Dict[str, Any]], default None): Keyword arguments to pass to the target function.
  • env (Optional[Dict[str, str]], default None): If provided, set these environment variables in the daemon process.
  • daemon_id (Optional[str], default None): Build a Daemon from an existing daemon_id. If daemon_id is provided, other arguments are ignored and are derived from the existing pickled Daemon.
  • label (Optional[str], default None): Label string to help identifiy a daemon. If None, use the function name instead.
  • properties (Optional[Dict[str, Any]], default None): Override reading from the properties JSON by providing an existing dictionary.
@classmethod
def from_properties_file(cls, daemon_id: str) -> Daemon:
 90    @classmethod
 91    def from_properties_file(cls, daemon_id: str) -> Daemon:
 92        """
 93        Return a Daemon from a properties dictionary.
 94        """
 95        properties_path = cls._get_properties_path_from_daemon_id(daemon_id)
 96        if not properties_path.exists():
 97            raise OSError(f"Properties file '{properties_path}' does not exist.")
 98
 99        try:
100            with open(properties_path, 'r', encoding='utf-8') as f:
101                properties = json.load(f)
102        except Exception:
103            properties = {}
104
105        if not properties:
106            raise ValueError(f"No properties could be read for daemon '{daemon_id}'.")
107
108        daemon_id = properties_path.parent.name
109        target_cf = properties.get('target', {})
110        target_module_name = target_cf.get('module', None)
111        target_function_name = target_cf.get('name', None)
112        target_args = target_cf.get('args', None)
113        target_kw = target_cf.get('kw', None)
114        label = properties.get('label', None)
115
116        if None in [
117            target_module_name,
118            target_function_name,
119            target_args,
120            target_kw,
121        ]:
122            raise ValueError("Missing target function information.")
123
124        target_module = importlib.import_module(target_module_name)
125        target_function = getattr(target_module, target_function_name)
126
127        return Daemon(
128            daemon_id=daemon_id,
129            target=target_function,
130            target_args=target_args,
131            target_kw=target_kw,
132            properties=properties,
133            label=label,
134        )

Return a Daemon from a properties dictionary.

pickle
def run( self, keep_daemon_output: bool = True, allow_dirty_run: bool = False, wait: bool = False, timeout: Union[int, float] = 4, debug: bool = False) -> Tuple[bool, str]:
435    def run(
436        self,
437        keep_daemon_output: bool = True,
438        allow_dirty_run: bool = False,
439        wait: bool = False,
440        timeout: Union[int, float] = 4,
441        debug: bool = False,
442    ) -> SuccessTuple:
443        """Run the daemon as a child process and continue executing the parent.
444
445        Parameters
446        ----------
447        keep_daemon_output: bool, default True
448            If `False`, delete the daemon's output directory upon exiting.
449
450        allow_dirty_run: bool, default False
451            If `True`, run the daemon, even if the `daemon_id` directory exists.
452            This option is dangerous because if the same `daemon_id` runs concurrently,
453            the last to finish will overwrite the output of the first.
454
455        wait: bool, default True
456            If `True`, block until `Daemon.status` is running (or the timeout expires).
457
458        timeout: Union[int, float], default 4
459            If `wait` is `True`, block for up to `timeout` seconds before returning a failure.
460
461        Returns
462        -------
463        A SuccessTuple indicating success.
464
465        """
466        import platform
467        if platform.system() == 'Windows':
468            return False, "Cannot run background jobs on Windows."
469
470        ### The daemon might exist and be paused.
471        if self.status == 'paused':
472            return self.resume()
473
474        self._remove_stop_file()
475        if self.status == 'running':
476            return True, f"Daemon '{self}' is already running."
477
478        self.mkdir_if_not_exists(allow_dirty_run)
479        _write_pickle_success_tuple = self.write_pickle()
480        if not _write_pickle_success_tuple[0]:
481            return _write_pickle_success_tuple
482
483        ### The PID file does not exist until the new process has started up. Record the launch
484        ### so that a job which completed earlier is not mistaken for a finished one meanwhile.
485        _detached.invalidate(self.daemon_id)
486        if self.properties_path.exists():
487            self._capture_process_timestamp('launched')
488
489        _launch_daemon_code = (
490            "from meerschaum.utils.daemon import Daemon, _daemons; "
491            f"daemon = Daemon(daemon_id='{self.daemon_id}'); "
492            f"_daemons['{self.daemon_id}'] = daemon; "
493            f"daemon._run_exit(keep_daemon_output={keep_daemon_output}, "
494            "allow_dirty_run=True)"
495        )
496        env = dict(os.environ)
497        _launch_success_bool = venv_exec(_launch_daemon_code, debug=debug, venv=None, env=env)
498        _detached.invalidate(self.daemon_id)
499        msg = (
500            "Success"
501            if _launch_success_bool
502            else f"Failed to start daemon '{self.daemon_id}'."
503        )
504        if not wait or not _launch_success_bool:
505            return _launch_success_bool, msg
506
507        timeout = self.get_timeout_seconds(timeout)
508        check_timeout_interval = self.get_check_timeout_interval_seconds()
509
510        if not timeout:
511            success = self.status == 'running'
512            msg = "Success" if success else f"Failed to run daemon '{self.daemon_id}'."
513            if success:
514                self._capture_process_timestamp('began')
515            return success, msg
516
517        begin = time.perf_counter()
518        while (time.perf_counter() - begin) < timeout:
519            if self.status == 'running':
520                self._capture_process_timestamp('began')
521                return True, "Success"
522            time.sleep(check_timeout_interval)
523
524        return False, (
525            f"Failed to start daemon '{self.daemon_id}' within {timeout} second"
526            + ('s' if timeout != 1 else '') + '.'
527        )

Run the daemon as a child process and continue executing the parent.

Parameters
  • keep_daemon_output (bool, default True): If False, delete the daemon's output directory upon exiting.
  • allow_dirty_run (bool, default False): If True, run the daemon, even if the daemon_id directory exists. This option is dangerous because if the same daemon_id runs concurrently, the last to finish will overwrite the output of the first.
  • wait (bool, default True): If True, block until Daemon.status is running (or the timeout expires).
  • timeout (Union[int, float], default 4): If wait is True, block for up to timeout seconds before returning a failure.
Returns
  • A SuccessTuple indicating success.
def kill(self, timeout: Union[int, float, NoneType] = 8) -> Tuple[bool, str]:
530    def kill(self, timeout: Union[int, float, None] = 8) -> SuccessTuple:
531        """
532        Forcibly terminate a running daemon.
533        Sends a SIGTERM signal to the process.
534
535        Parameters
536        ----------
537        timeout: Optional[int], default 3
538            How many seconds to wait for the process to terminate.
539
540        Returns
541        -------
542        A SuccessTuple indicating success.
543        """
544        _detached.invalidate(self.daemon_id)
545
546        ### A lost PID file means a detached/orphaned daemon — possibly SEVERAL processes
547        ### sharing this daemon_id (e.g. accumulated across crashed restarts). Reap them
548        ### ALL by daemon_id rather than reporting a false "stopped" and stranding them
549        ### (which forces a manual `pkill meerschaum` or hunting the daemon_id in htop).
550        if not self.pid_path.exists():
551            reaped = self._kill_detached_processes(timeout)
552            self._write_stop_file('kill')
553            self.stdin_file.close()
554            self._remove_blocking_stdin_file()
555            return True, (
556                (f"Reaped {reaped} detached process" + ('es' if reaped != 1 else '') + '.')
557                if reaped
558                else "Process has already stopped."
559            )
560
561        if self.status != 'paused':
562            success, msg = self._send_signal(signal.SIGTERM, timeout=timeout)
563            if success:
564                ### Sweep any sibling/detached processes left over for this daemon_id.
565                self._kill_detached_processes(timeout)
566                self._write_stop_file('kill')
567                self.stdin_file.close()
568                self._remove_blocking_stdin_file()
569                return success, msg
570
571        if self.status == 'stopped':
572            reaped = self._kill_detached_processes(timeout)
573            self._write_stop_file('kill')
574            self.stdin_file.close()
575            self._remove_blocking_stdin_file()
576            return True, (
577                (f"Reaped {reaped} detached process" + ('es' if reaped != 1 else '') + '.')
578                if reaped
579                else "Process has already stopped."
580            )
581
582        psutil = attempt_import('psutil')
583        process = self.process
584        try:
585            process.terminate()
586            process.kill()
587            process.wait(timeout=timeout)
588        except Exception as e:
589            return False, f"Failed to kill job {self} ({process}) with exception: {e}"
590
591        try:
592            if process.status():
593                return False, "Failed to stop daemon '{self}' ({process})."
594        except psutil.NoSuchProcess:
595            pass
596
597        if self.pid_path.exists():
598            try:
599                self.pid_path.unlink()
600            except Exception:
601                pass
602
603        self._write_stop_file('kill')
604        self.stdin_file.close()
605        self._remove_blocking_stdin_file()
606        return True, "Success"

Forcibly terminate a running daemon. Sends a SIGTERM signal to the process.

Parameters
  • timeout (Optional[int], default 3): How many seconds to wait for the process to terminate.
Returns
  • A SuccessTuple indicating success.
def quit(self, timeout: Union[int, float, NoneType] = None) -> Tuple[bool, str]:
608    def quit(self, timeout: Union[int, float, None] = None) -> SuccessTuple:
609        """Gracefully quit a running daemon."""
610        _detached.invalidate(self.daemon_id)
611        if self.status == 'paused':
612            return self.kill(timeout)
613
614        ### A lost PID file means a detached/orphaned daemon (possibly several processes);
615        ### the normal signal path can't see them, so reap them all by daemon_id instead
616        ### of returning a false "not running".
617        if not self.pid_path.exists():
618            reaped = self._kill_detached_processes(timeout)
619            self._write_stop_file('quit')
620            self.stdin_file.close()
621            self._remove_blocking_stdin_file()
622            return True, (
623                (f"Reaped {reaped} detached process" + ('es' if reaped != 1 else '') + '.')
624                if reaped
625                else "Process is not running."
626            )
627
628        signal_success, signal_msg = self._send_signal(signal.SIGINT, timeout=timeout)
629        if signal_success:
630            self._write_stop_file('quit')
631            self.stdin_file.close()
632            self._remove_blocking_stdin_file()
633        return signal_success, signal_msg

Gracefully quit a running daemon.

def pause( self, timeout: Union[int, float, NoneType] = None, check_timeout_interval: Union[float, int, NoneType] = None) -> Tuple[bool, str]:
635    def pause(
636        self,
637        timeout: Union[int, float, None] = None,
638        check_timeout_interval: Union[float, int, None] = None,
639    ) -> SuccessTuple:
640        """
641        Pause the daemon if it is running.
642
643        Parameters
644        ----------
645        timeout: Union[float, int, None], default None
646            The maximum number of seconds to wait for a process to suspend.
647
648        check_timeout_interval: Union[float, int, None], default None
649            The number of seconds to wait between checking if the process is still running.
650
651        Returns
652        -------
653        A `SuccessTuple` indicating whether the `Daemon` process was successfully suspended.
654        """
655        self._remove_blocking_stdin_file()
656        _detached.invalidate(self.daemon_id)
657
658        if self.process is None:
659            return False, f"Daemon '{self.daemon_id}' is not running and cannot be paused."
660
661        if self.status == 'paused':
662            return True, f"Daemon '{self.daemon_id}' is already paused."
663
664        self._write_stop_file('pause')
665        self.stdin_file.close()
666        self._remove_blocking_stdin_file()
667        try:
668            self.process.suspend()
669        except Exception as e:
670            return False, f"Failed to pause daemon '{self.daemon_id}':\n{e}"
671
672        timeout = self.get_timeout_seconds(timeout)
673        check_timeout_interval = self.get_check_timeout_interval_seconds(
674            check_timeout_interval
675        )
676
677        psutil = attempt_import('psutil')
678
679        if not timeout:
680            try:
681                success = self.process.status() == 'stopped'
682            except psutil.NoSuchProcess:
683                success = True
684            msg = "Success" if success else f"Failed to suspend daemon '{self.daemon_id}'."
685            if success:
686                self._capture_process_timestamp('paused')
687            return success, msg
688
689        begin = time.perf_counter()
690        while (time.perf_counter() - begin) < timeout:
691            try:
692                if self.process.status() == 'stopped':
693                    self._capture_process_timestamp('paused')
694                    return True, "Success"
695            except psutil.NoSuchProcess as e:
696                return False, f"Process exited unexpectedly. Was it killed?\n{e}"
697            time.sleep(check_timeout_interval)
698
699        return False, (
700            f"Failed to pause daemon '{self.daemon_id}' within {timeout} second"
701            + ('s' if timeout != 1 else '') + '.'
702        )

Pause the daemon if it is running.

Parameters
  • timeout (Union[float, int, None], default None): The maximum number of seconds to wait for a process to suspend.
  • check_timeout_interval (Union[float, int, None], default None): The number of seconds to wait between checking if the process is still running.
Returns
  • A SuccessTuple indicating whether the Daemon process was successfully suspended.
def resume( self, timeout: Union[int, float, NoneType] = None, check_timeout_interval: Union[float, int, NoneType] = None) -> Tuple[bool, str]:
704    def resume(
705        self,
706        timeout: Union[int, float, None] = None,
707        check_timeout_interval: Union[float, int, None] = None,
708    ) -> SuccessTuple:
709        """
710        Resume the daemon if it is paused.
711
712        Parameters
713        ----------
714        timeout: Union[float, int, None], default None
715            The maximum number of seconds to wait for a process to resume.
716
717        check_timeout_interval: Union[float, int, None], default None
718            The number of seconds to wait between checking if the process is still stopped.
719
720        Returns
721        -------
722        A `SuccessTuple` indicating whether the `Daemon` process was successfully resumed.
723        """
724        _detached.invalidate(self.daemon_id)
725        if self.status == 'running':
726            return True, f"Daemon '{self.daemon_id}' is already running."
727
728        if self.status == 'stopped':
729            return False, f"Daemon '{self.daemon_id}' is stopped and cannot be resumed."
730
731        self._remove_stop_file()
732        try:
733            if self.process is None:
734                return False, f"Cannot resume daemon '{self.daemon_id}'."
735
736            self.process.resume()
737        except Exception as e:
738            return False, f"Failed to resume daemon '{self.daemon_id}':\n{e}"
739
740        timeout = self.get_timeout_seconds(timeout)
741        check_timeout_interval = self.get_check_timeout_interval_seconds(
742            check_timeout_interval
743        )
744
745        if not timeout:
746            success = self.status == 'running'
747            msg = "Success" if success else f"Failed to resume daemon '{self.daemon_id}'."
748            if success:
749                self._capture_process_timestamp('began')
750            return success, msg
751
752        begin = time.perf_counter()
753        while (time.perf_counter() - begin) < timeout:
754            if self.status == 'running':
755                self._capture_process_timestamp('began')
756                return True, "Success"
757            time.sleep(check_timeout_interval)
758
759        return False, (
760            f"Failed to resume daemon '{self.daemon_id}' within {timeout} second"
761            + ('s' if timeout != 1 else '') + '.'
762        )

Resume the daemon if it is paused.

Parameters
  • timeout (Union[float, int, None], default None): The maximum number of seconds to wait for a process to resume.
  • check_timeout_interval (Union[float, int, None], default None): The number of seconds to wait between checking if the process is still stopped.
Returns
  • A SuccessTuple indicating whether the Daemon process was successfully resumed.
def mkdir_if_not_exists(self, allow_dirty_run: bool = False):
1064    def mkdir_if_not_exists(self, allow_dirty_run: bool = False):
1065        """Create the Daemon's directory.
1066        If `allow_dirty_run` is `False` and the directory already exists,
1067        raise a `FileExistsError`.
1068        """
1069        try:
1070            self.path.mkdir(parents=True, exist_ok=True)
1071            _already_exists = any(os.scandir(self.path))
1072        except FileExistsError:
1073            _already_exists = True
1074
1075        if _already_exists and not allow_dirty_run:
1076            error(
1077                f"Daemon '{self.daemon_id}' already exists. " +
1078                "To allow this daemon to run, do one of the following:\n"
1079                + "  - Execute `daemon.cleanup()`.\n"
1080                + f"  - Delete the directory '{self.path}'.\n"
1081                + "  - Pass `allow_dirty_run=True` to `daemon.run()`.\n",
1082                FileExistsError,
1083            )

Create the Daemon's directory. If allow_dirty_run is False and the directory already exists, raise a FileExistsError.

process: Optional[psutil.Process]
1085    @property
1086    def process(self) -> Union['psutil.Process', None]:
1087        """
1088        Return the psutil process for the Daemon.
1089        """
1090        psutil = attempt_import('psutil')
1091        pid = self.pid
1092        if pid is None:
1093            return None
1094        if '_process' not in self.__dict__ or self.__dict__['_process'].pid != int(pid):
1095            try:
1096                self._process = psutil.Process(int(pid))
1097                process_exists = True
1098            except Exception:
1099                process_exists = False
1100            if not process_exists:
1101                _ = self.__dict__.pop('_process', None)
1102                try:
1103                    if self.pid_path.exists():
1104                        self.pid_path.unlink()
1105                except Exception:
1106                    pass
1107                return None
1108        return self._process

Return the psutil process for the Daemon.

status: str
1110    @property
1111    def status(self) -> str:
1112        """
1113        Return the running status of this Daemon.
1114        """
1115        process = self.process
1116        if process is None:
1117            return 'stopped'
1118
1119        psutil = attempt_import('psutil', lazy=False)
1120        try:
1121            process_status = process.status()
1122            if process_status == 'stopped':
1123                return 'paused'
1124            if process_status == 'zombie':
1125                raise psutil.NoSuchProcess(process.pid)
1126        except (psutil.NoSuchProcess, AttributeError):
1127            if self.pid_path.exists():
1128                try:
1129                    self.pid_path.unlink()
1130                except Exception:
1131                    pass
1132            return 'stopped'
1133
1134        return 'running'

Return the running status of this Daemon.

path: pathlib.Path
1144    @property
1145    def path(self) -> pathlib.Path:
1146        """
1147        Return the path for this Daemon's directory.
1148        """
1149        return self._get_path_from_daemon_id(self.daemon_id)

Return the path for this Daemon's directory.

properties_path: pathlib.Path
1158    @property
1159    def properties_path(self) -> pathlib.Path:
1160        """
1161        Return the `propterties.json` path for this Daemon.
1162        """
1163        return self._get_properties_path_from_daemon_id(self.daemon_id)

Return the propterties.json path for this Daemon.

stop_path: pathlib.Path
1165    @property
1166    def stop_path(self) -> pathlib.Path:
1167        """
1168        Return the path for the stop file (created when manually stopped).
1169        """
1170        return self.path / '.stop.json'

Return the path for the stop file (created when manually stopped).

log_path: pathlib.Path
1172    @property
1173    def log_path(self) -> pathlib.Path:
1174        """
1175        Return the log path.
1176        """
1177        logs_cf = self.properties.get('logs', None) or {}
1178        if 'path' not in logs_cf:
1179            import meerschaum.config.paths as paths
1180            return paths.LOGS_RESOURCES_PATH / (self.daemon_id + '.log')
1181
1182        return pathlib.Path(logs_cf['path'])

Return the log path.

stdin_file_path: pathlib.Path
1184    @property
1185    def stdin_file_path(self) -> pathlib.Path:
1186        """
1187        Return the stdin file path.
1188        """
1189        return self.path / 'input.stdin'

Return the stdin file path.

blocking_stdin_file_path: pathlib.Path
1191    @property
1192    def blocking_stdin_file_path(self) -> pathlib.Path:
1193        """
1194        Return the stdin file path.
1195        """
1196        if '_blocking_stdin_file_path' in self.__dict__:
1197            return self._blocking_stdin_file_path
1198
1199        return self.path / 'input.stdin.block'

Return the stdin file path.

prompt_kwargs_file_path: pathlib.Path
1201    @property
1202    def prompt_kwargs_file_path(self) -> pathlib.Path:
1203        """
1204        Return the file path to the kwargs for the invoking `prompt()`.
1205        """
1206        return self.path / 'prompt_kwargs.json'

Return the file path to the kwargs for the invoking prompt().

log_offset_path: pathlib.Path
1208    @property
1209    def log_offset_path(self) -> pathlib.Path:
1210        """
1211        Return the log offset file path.
1212        """
1213        import meerschaum.config.paths as paths
1214        return paths.LOGS_RESOURCES_PATH / ('.' + self.daemon_id + '.log.offset')

Return the log offset file path.

log_offset_lock: "'InterProcessLock'"
1216    @property
1217    def log_offset_lock(self) -> 'InterProcessLock':
1218        """
1219        Return the process lock context manager.
1220        """
1221        if '_log_offset_lock' in self.__dict__:
1222            return self._log_offset_lock
1223
1224        from meerschaum.utils.locks import InterProcessLock
1225        self._log_offset_lock = InterProcessLock(self.log_offset_path)
1226        return self._log_offset_lock

Return the process lock context manager.

rotating_log: meerschaum.utils.daemon.RotatingFile
1228    @property
1229    def rotating_log(self) -> RotatingFile:
1230        """
1231        The rotating log file for the daemon's output.
1232        """
1233        if '_rotating_log' in self.__dict__:
1234            return self._rotating_log
1235
1236        logs_cf = self.properties.get('logs', None) or {}
1237        write_timestamps = logs_cf.get('write_timestamps', None)
1238        if write_timestamps is None:
1239            write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled')
1240
1241        timestamp_format = logs_cf.get('timestamp_format', None)
1242        if timestamp_format is None:
1243            timestamp_format = get_config('jobs', 'logs', 'timestamps', 'format')
1244
1245        num_files_to_keep = logs_cf.get('num_files_to_keep', None)
1246        if num_files_to_keep is None:
1247            num_files_to_keep = get_config('jobs', 'logs', 'num_files_to_keep')
1248
1249        max_file_size = logs_cf.get('max_file_size', None)
1250        if max_file_size is None:
1251            max_file_size = get_config('jobs', 'logs', 'max_file_size')
1252
1253        redirect_streams = logs_cf.get('redirect_streams', True)
1254
1255        self._rotating_log = RotatingFile(
1256            self.log_path,
1257            redirect_streams=redirect_streams,
1258            write_timestamps=write_timestamps,
1259            timestamp_format=timestamp_format,
1260            num_files_to_keep=num_files_to_keep,
1261            max_file_size=max_file_size,
1262        )
1263        return self._rotating_log

The rotating log file for the daemon's output.

stdin_file
1265    @property
1266    def stdin_file(self):
1267        """
1268        Return the file handler for the stdin file.
1269        """
1270        if (_stdin_file := self.__dict__.get('_stdin_file', None)):
1271            return _stdin_file
1272
1273        self._stdin_file = StdinFile(
1274            self.stdin_file_path,
1275            lock_file_path=self.blocking_stdin_file_path,
1276        )
1277        return self._stdin_file

Return the file handler for the stdin file.

log_text: Optional[str]
1279    @property
1280    def log_text(self) -> Union[str, None]:
1281        """
1282        Read the log files and return their contents.
1283        Returns `None` if the log file does not exist.
1284        """
1285        logs_cf = self.properties.get('logs', None) or {}
1286        write_timestamps = logs_cf.get('write_timestamps', None)
1287        if write_timestamps is None:
1288            write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled')
1289
1290        timestamp_format = logs_cf.get('timestamp_format', None)
1291        if timestamp_format is None:
1292            timestamp_format = get_config('jobs', 'logs', 'timestamps', 'format')
1293
1294        num_files_to_keep = logs_cf.get('num_files_to_keep', None)
1295        if num_files_to_keep is None:
1296            num_files_to_keep = get_config('jobs', 'logs', 'num_files_to_keep')
1297
1298        max_file_size = logs_cf.get('max_file_size', None)
1299        if max_file_size is None:
1300            max_file_size = get_config('jobs', 'logs', 'max_file_size')
1301
1302        new_rotating_log = RotatingFile(
1303            self.rotating_log.file_path,
1304            num_files_to_keep=num_files_to_keep,
1305            max_file_size=max_file_size,
1306            write_timestamps=write_timestamps,
1307            timestamp_format=timestamp_format,
1308        )
1309        return new_rotating_log.read()

Read the log files and return their contents. Returns None if the log file does not exist.

def readlines(self) -> List[str]:
1311    def readlines(self) -> List[str]:
1312        """
1313        Read the next log lines, persisting the cursor for later use.
1314        Note this will alter the cursor of `self.rotating_log`.
1315        """
1316        self.rotating_log._cursor = self._read_log_offset()
1317        lines = self.rotating_log.readlines()
1318        self._write_log_offset()
1319        return lines

Read the next log lines, persisting the cursor for later use. Note this will alter the cursor of self.rotating_log.

pid: Optional[int]
1352    @property
1353    def pid(self) -> Union[int, None]:
1354        """
1355        Read the PID file and return its contents.
1356        Returns `None` if the PID file does not exist.
1357        """
1358        if not self.pid_path.exists():
1359            ### The PID file can be lost while a detached daemon keeps running (e.g. the
1360            ### launcher exited without cleanup). Recover the PID by finding the process
1361            ### whose command line embeds this daemon_id, so `status`/`stop`/`kill` see the
1362            ### live process instead of reporting a false "stopped" (which would strand the
1363            ### orphan, forcing a manual `pkill meerschaum`).
1364            detached_pids = self._find_detached_pids(verify_began=True)
1365            if not detached_pids:
1366                return None
1367            self._restore_pid_file(detached_pids[0])
1368            return detached_pids[0]
1369        try:
1370            with open(self.pid_path, 'r', encoding='utf-8') as f:
1371                text = f.read()
1372            if len(text) == 0:
1373                return None
1374            pid = int(text.rstrip())
1375        except Exception as e:
1376            warn(e)
1377            text = None
1378            pid = None
1379        return pid

Read the PID file and return its contents. Returns None if the PID file does not exist.

pid_path: pathlib.Path
1381    @property
1382    def pid_path(self) -> pathlib.Path:
1383        """
1384        Return the path to a file containing the PID for this Daemon.
1385        """
1386        return self.path / 'process.pid'

Return the path to a file containing the PID for this Daemon.

pid_lock: "'InterProcessLock'"
1388    @property
1389    def pid_lock(self) -> 'InterProcessLock':
1390        """
1391        Return the process lock context manager.
1392        """
1393        if '_pid_lock' in self.__dict__:
1394            return self._pid_lock
1395
1396        from meerschaum.utils.locks import InterProcessLock
1397        self._pid_lock = InterProcessLock(self.pid_path)
1398        return self._pid_lock

Return the process lock context manager.

pickle_path: pathlib.Path
1400    @property
1401    def pickle_path(self) -> pathlib.Path:
1402        """
1403        Return the path for the pickle file.
1404        """
1405        return self.path / 'pickle.pkl'

Return the path for the pickle file.

def read_properties(self) -> Optional[Dict[str, Any]]:
1407    def read_properties(self) -> Optional[Dict[str, Any]]:
1408        """Read the properties JSON file and return the dictionary."""
1409        if not self.properties_path.exists():
1410            return None
1411        try:
1412            with open(self.properties_path, 'r', encoding='utf-8') as file:
1413                properties = json.load(file)
1414        except Exception:
1415            properties = {}
1416        
1417        return properties or {}

Read the properties JSON file and return the dictionary.

def read_pickle(self) -> Daemon:
1419    def read_pickle(self) -> Daemon:
1420        """Read a Daemon's pickle file and return the `Daemon`."""
1421        import pickle
1422        import traceback
1423        if not self.pickle_path.exists():
1424            error(f"Pickle file does not exist for daemon '{self.daemon_id}'.")
1425
1426        if self.pickle_path.stat().st_size == 0:
1427            error(f"Pickle was empty for daemon '{self.daemon_id}'.")
1428
1429        try:
1430            with open(self.pickle_path, 'rb') as pickle_file:
1431                daemon = pickle.load(pickle_file)
1432            success, msg = True, 'Success'
1433        except Exception as e:
1434            success, msg = False, str(e)
1435            daemon = None
1436            traceback.print_exception(type(e), e, e.__traceback__)
1437        if not success:
1438            error(msg)
1439        return daemon

Read a Daemon's pickle file and return the Daemon.

properties: Dict[str, Any]
1441    @property
1442    def properties(self) -> Dict[str, Any]:
1443        """
1444        Return the contents of the properties JSON file.
1445        """
1446        try:
1447            _file_properties = self.read_properties() or {}
1448        except Exception:
1449            traceback.print_exc()
1450            _file_properties = {}
1451
1452        if not self._properties:
1453            self._properties = _file_properties
1454
1455        if self._properties is None:
1456            self._properties = {}
1457
1458        if (
1459            self._properties.get('result', None) is None
1460            and _file_properties.get('result', None) is not None
1461        ):
1462            _ = self._properties.pop('result', None)
1463
1464        if _file_properties is not None:
1465            self._properties = apply_patch_to_config(
1466                _file_properties,
1467                self._properties,
1468            )
1469
1470        return self._properties

Return the contents of the properties JSON file.

hidden: bool
1472    @property
1473    def hidden(self) -> bool:
1474        """
1475        Return a bool indicating whether this Daemon should be displayed.
1476        """
1477        return self.daemon_id.startswith('_') or self.daemon_id.startswith('.')

Return a bool indicating whether this Daemon should be displayed.

def write_properties(self) -> Tuple[bool, str]:
1479    def write_properties(self) -> SuccessTuple:
1480        """Write the properties dictionary to the properties JSON file
1481        (only if self.properties exists).
1482        """
1483        from meerschaum.utils.misc import generate_password
1484        success, msg = (
1485            False,
1486            f"No properties to write for daemon '{self.daemon_id}'."
1487        )
1488        backup_path = self.properties_path.parent / (generate_password(8) + '.json')
1489        props = self.properties
1490        if props is not None:
1491            try:
1492                self.path.mkdir(parents=True, exist_ok=True)
1493                if self.properties_path.exists():
1494                    self.properties_path.rename(backup_path)
1495                with open(self.properties_path, 'w+', encoding='utf-8') as properties_file:
1496                    json.dump(props, properties_file)
1497                success, msg = True, 'Success'
1498            except Exception as e:
1499                success, msg = False, str(e)
1500
1501        try:
1502            if backup_path.exists():
1503                if not success:
1504                    backup_path.rename(self.properties_path)
1505                else:
1506                    backup_path.unlink()
1507        except Exception as e:
1508            success, msg = False, str(e)
1509
1510        return success, msg

Write the properties dictionary to the properties JSON file (only if self.properties exists).

def write_pickle(self) -> Tuple[bool, str]:
1512    def write_pickle(self) -> SuccessTuple:
1513        """Write the pickle file for the daemon."""
1514        import pickle
1515        import traceback
1516        from meerschaum.utils.misc import generate_password
1517
1518        if not self.pickle:
1519            return True, "Success"
1520
1521        from meerschaum._internal.entry import _shells
1522        if _shells:
1523            from meerschaum._internal.shell.Shell import revert_input
1524            revert_input()
1525
1526        backup_path = self.pickle_path.parent / (generate_password(7) + '.pkl')
1527        try:
1528            self.path.mkdir(parents=True, exist_ok=True)
1529            if self.pickle_path.exists():
1530                self.pickle_path.rename(backup_path)
1531            with open(self.pickle_path, 'wb+') as pickle_file:
1532                pickle.dump(self, pickle_file)
1533            success, msg = True, "Success"
1534        except Exception as e:
1535            success, msg = False, str(e)
1536            traceback.print_exception(type(e), e, e.__traceback__)
1537        try:
1538            if backup_path.exists():
1539                if not success:
1540                    backup_path.rename(self.pickle_path)
1541                else:
1542                    backup_path.unlink()
1543        except Exception as e:
1544            success, msg = False, str(e)
1545        return success, msg

Write the pickle file for the daemon.

def cleanup(self, keep_logs: bool = False) -> Tuple[bool, str]:
1575    def cleanup(self, keep_logs: bool = False) -> SuccessTuple:
1576        """
1577        Remove a daemon's directory after execution.
1578
1579        Parameters
1580        ----------
1581        keep_logs: bool, default False
1582            If `True`, skip deleting the daemon's log files.
1583
1584        Returns
1585        -------
1586        A `SuccessTuple` indicating success.
1587        """
1588        _detached.invalidate(self.daemon_id)
1589        if self.path.exists():
1590            try:
1591                shutil.rmtree(self.path)
1592            except Exception as e:
1593                msg = f"Failed to clean up '{self.daemon_id}':\n{e}"
1594                warn(msg)
1595                return False, msg
1596        if not keep_logs:
1597            self.rotating_log.delete()
1598            try:
1599                if self.log_offset_path.exists():
1600                    self.log_offset_path.unlink()
1601            except Exception as e:
1602                msg = f"Failed to remove offset file for '{self.daemon_id}':\n{e}"
1603                warn(msg)
1604                return False, msg
1605        return True, "Success"

Remove a daemon's directory after execution.

Parameters
  • keep_logs (bool, default False): If True, skip deleting the daemon's log files.
Returns
  • A SuccessTuple indicating success.
def get_timeout_seconds(self, timeout: Union[int, float, NoneType] = None) -> Union[int, float]:
1608    def get_timeout_seconds(self, timeout: Union[int, float, None] = None) -> Union[int, float]:
1609        """
1610        Return the timeout value to use. Use `--timeout-seconds` if provided,
1611        else the configured default (8).
1612        """
1613        if isinstance(timeout, (int, float)):
1614            return timeout
1615        return get_config('jobs', 'timeout_seconds')

Return the timeout value to use. Use --timeout-seconds if provided, else the configured default (8).

def get_check_timeout_interval_seconds( self, check_timeout_interval: Union[int, float, NoneType] = None) -> Union[int, float]:
1618    def get_check_timeout_interval_seconds(
1619        self,
1620        check_timeout_interval: Union[int, float, None] = None,
1621    ) -> Union[int, float]:
1622        """
1623        Return the interval value to check the status of timeouts.
1624        """
1625        if isinstance(check_timeout_interval, (int, float)):
1626            return check_timeout_interval
1627        return get_config('jobs', 'check_timeout_interval_seconds')

Return the interval value to check the status of timeouts.

target_args: Optional[Tuple[Any]]
1629    @property
1630    def target_args(self) -> Union[Tuple[Any], None]:
1631        """
1632        Return the positional arguments to pass to the target function.
1633        """
1634        target_args = (
1635            self.__dict__.get('_target_args', None)
1636            or self.properties.get('target', {}).get('args', None)
1637        )
1638        if target_args is None:
1639            return tuple([])
1640
1641        return tuple(target_args)

Return the positional arguments to pass to the target function.

target_kw: Optional[Dict[str, Any]]
1643    @property
1644    def target_kw(self) -> Union[Dict[str, Any], None]:
1645        """
1646        Return the keyword arguments to pass to the target function.
1647        """
1648        target_kw = (
1649            self.__dict__.get('_target_kw', None)
1650            or self.properties.get('target', {}).get('kw', None)
1651        )
1652        if target_kw is None:
1653            return {}
1654
1655        return {key: val for key, val in target_kw.items()}

Return the keyword arguments to pass to the target function.