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

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]:
425    def run(
426        self,
427        keep_daemon_output: bool = True,
428        allow_dirty_run: bool = False,
429        wait: bool = False,
430        timeout: Union[int, float] = 4,
431        debug: bool = False,
432    ) -> SuccessTuple:
433        """Run the daemon as a child process and continue executing the parent.
434
435        Parameters
436        ----------
437        keep_daemon_output: bool, default True
438            If `False`, delete the daemon's output directory upon exiting.
439
440        allow_dirty_run: bool, default False
441            If `True`, run the daemon, even if the `daemon_id` directory exists.
442            This option is dangerous because if the same `daemon_id` runs concurrently,
443            the last to finish will overwrite the output of the first.
444
445        wait: bool, default True
446            If `True`, block until `Daemon.status` is running (or the timeout expires).
447
448        timeout: Union[int, float], default 4
449            If `wait` is `True`, block for up to `timeout` seconds before returning a failure.
450
451        Returns
452        -------
453        A SuccessTuple indicating success.
454
455        """
456        import platform
457        if platform.system() == 'Windows':
458            return False, "Cannot run background jobs on Windows."
459
460        ### The daemon might exist and be paused.
461        if self.status == 'paused':
462            return self.resume()
463
464        self._remove_stop_file()
465        if self.status == 'running':
466            return True, f"Daemon '{self}' is already running."
467
468        self.mkdir_if_not_exists(allow_dirty_run)
469        _write_pickle_success_tuple = self.write_pickle()
470        if not _write_pickle_success_tuple[0]:
471            return _write_pickle_success_tuple
472
473        _launch_daemon_code = (
474            "from meerschaum.utils.daemon import Daemon, _daemons; "
475            f"daemon = Daemon(daemon_id='{self.daemon_id}'); "
476            f"_daemons['{self.daemon_id}'] = daemon; "
477            f"daemon._run_exit(keep_daemon_output={keep_daemon_output}, "
478            "allow_dirty_run=True)"
479        )
480        env = dict(os.environ)
481        _launch_success_bool = venv_exec(_launch_daemon_code, debug=debug, venv=None, env=env)
482        msg = (
483            "Success"
484            if _launch_success_bool
485            else f"Failed to start daemon '{self.daemon_id}'."
486        )
487        if not wait or not _launch_success_bool:
488            return _launch_success_bool, msg
489
490        timeout = self.get_timeout_seconds(timeout)
491        check_timeout_interval = self.get_check_timeout_interval_seconds()
492
493        if not timeout:
494            success = self.status == 'running'
495            msg = "Success" if success else f"Failed to run daemon '{self.daemon_id}'."
496            if success:
497                self._capture_process_timestamp('began')
498            return success, msg
499
500        begin = time.perf_counter()
501        while (time.perf_counter() - begin) < timeout:
502            if self.status == 'running':
503                self._capture_process_timestamp('began')
504                return True, "Success"
505            time.sleep(check_timeout_interval)
506
507        return False, (
508            f"Failed to start daemon '{self.daemon_id}' within {timeout} second"
509            + ('s' if timeout != 1 else '') + '.'
510        )

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]:
513    def kill(self, timeout: Union[int, float, None] = 8) -> SuccessTuple:
514        """
515        Forcibly terminate a running daemon.
516        Sends a SIGTERM signal to the process.
517
518        Parameters
519        ----------
520        timeout: Optional[int], default 3
521            How many seconds to wait for the process to terminate.
522
523        Returns
524        -------
525        A SuccessTuple indicating success.
526        """
527        ### A lost PID file means a detached/orphaned daemon — possibly SEVERAL processes
528        ### sharing this daemon_id (e.g. accumulated across crashed restarts). Reap them
529        ### ALL by daemon_id rather than reporting a false "stopped" and stranding them
530        ### (which forces a manual `pkill meerschaum` or hunting the daemon_id in htop).
531        if not self.pid_path.exists():
532            reaped = self._kill_detached_processes(timeout)
533            self._write_stop_file('kill')
534            self.stdin_file.close()
535            self._remove_blocking_stdin_file()
536            return True, (
537                (f"Reaped {reaped} detached process" + ('es' if reaped != 1 else '') + '.')
538                if reaped
539                else "Process has already stopped."
540            )
541
542        if self.status != 'paused':
543            success, msg = self._send_signal(signal.SIGTERM, timeout=timeout)
544            if success:
545                ### Sweep any sibling/detached processes left over for this daemon_id.
546                self._kill_detached_processes(timeout)
547                self._write_stop_file('kill')
548                self.stdin_file.close()
549                self._remove_blocking_stdin_file()
550                return success, msg
551
552        if self.status == 'stopped':
553            reaped = self._kill_detached_processes(timeout)
554            self._write_stop_file('kill')
555            self.stdin_file.close()
556            self._remove_blocking_stdin_file()
557            return True, (
558                (f"Reaped {reaped} detached process" + ('es' if reaped != 1 else '') + '.')
559                if reaped
560                else "Process has already stopped."
561            )
562
563        psutil = attempt_import('psutil')
564        process = self.process
565        try:
566            process.terminate()
567            process.kill()
568            process.wait(timeout=timeout)
569        except Exception as e:
570            return False, f"Failed to kill job {self} ({process}) with exception: {e}"
571
572        try:
573            if process.status():
574                return False, "Failed to stop daemon '{self}' ({process})."
575        except psutil.NoSuchProcess:
576            pass
577
578        if self.pid_path.exists():
579            try:
580                self.pid_path.unlink()
581            except Exception:
582                pass
583
584        self._write_stop_file('kill')
585        self.stdin_file.close()
586        self._remove_blocking_stdin_file()
587        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]:
589    def quit(self, timeout: Union[int, float, None] = None) -> SuccessTuple:
590        """Gracefully quit a running daemon."""
591        if self.status == 'paused':
592            return self.kill(timeout)
593
594        ### A lost PID file means a detached/orphaned daemon (possibly several processes);
595        ### the normal signal path can't see them, so reap them all by daemon_id instead
596        ### of returning a false "not running".
597        if not self.pid_path.exists():
598            reaped = self._kill_detached_processes(timeout)
599            self._write_stop_file('quit')
600            self.stdin_file.close()
601            self._remove_blocking_stdin_file()
602            return True, (
603                (f"Reaped {reaped} detached process" + ('es' if reaped != 1 else '') + '.')
604                if reaped
605                else "Process is not running."
606            )
607
608        signal_success, signal_msg = self._send_signal(signal.SIGINT, timeout=timeout)
609        if signal_success:
610            self._write_stop_file('quit')
611            self.stdin_file.close()
612            self._remove_blocking_stdin_file()
613        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]:
615    def pause(
616        self,
617        timeout: Union[int, float, None] = None,
618        check_timeout_interval: Union[float, int, None] = None,
619    ) -> SuccessTuple:
620        """
621        Pause the daemon if it is running.
622
623        Parameters
624        ----------
625        timeout: Union[float, int, None], default None
626            The maximum number of seconds to wait for a process to suspend.
627
628        check_timeout_interval: Union[float, int, None], default None
629            The number of seconds to wait between checking if the process is still running.
630
631        Returns
632        -------
633        A `SuccessTuple` indicating whether the `Daemon` process was successfully suspended.
634        """
635        self._remove_blocking_stdin_file()
636
637        if self.process is None:
638            return False, f"Daemon '{self.daemon_id}' is not running and cannot be paused."
639
640        if self.status == 'paused':
641            return True, f"Daemon '{self.daemon_id}' is already paused."
642
643        self._write_stop_file('pause')
644        self.stdin_file.close()
645        self._remove_blocking_stdin_file()
646        try:
647            self.process.suspend()
648        except Exception as e:
649            return False, f"Failed to pause daemon '{self.daemon_id}':\n{e}"
650
651        timeout = self.get_timeout_seconds(timeout)
652        check_timeout_interval = self.get_check_timeout_interval_seconds(
653            check_timeout_interval
654        )
655
656        psutil = attempt_import('psutil')
657
658        if not timeout:
659            try:
660                success = self.process.status() == 'stopped'
661            except psutil.NoSuchProcess:
662                success = True
663            msg = "Success" if success else f"Failed to suspend daemon '{self.daemon_id}'."
664            if success:
665                self._capture_process_timestamp('paused')
666            return success, msg
667
668        begin = time.perf_counter()
669        while (time.perf_counter() - begin) < timeout:
670            try:
671                if self.process.status() == 'stopped':
672                    self._capture_process_timestamp('paused')
673                    return True, "Success"
674            except psutil.NoSuchProcess as e:
675                return False, f"Process exited unexpectedly. Was it killed?\n{e}"
676            time.sleep(check_timeout_interval)
677
678        return False, (
679            f"Failed to pause daemon '{self.daemon_id}' within {timeout} second"
680            + ('s' if timeout != 1 else '') + '.'
681        )

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]:
683    def resume(
684        self,
685        timeout: Union[int, float, None] = None,
686        check_timeout_interval: Union[float, int, None] = None,
687    ) -> SuccessTuple:
688        """
689        Resume the daemon if it is paused.
690
691        Parameters
692        ----------
693        timeout: Union[float, int, None], default None
694            The maximum number of seconds to wait for a process to resume.
695
696        check_timeout_interval: Union[float, int, None], default None
697            The number of seconds to wait between checking if the process is still stopped.
698
699        Returns
700        -------
701        A `SuccessTuple` indicating whether the `Daemon` process was successfully resumed.
702        """
703        if self.status == 'running':
704            return True, f"Daemon '{self.daemon_id}' is already running."
705
706        if self.status == 'stopped':
707            return False, f"Daemon '{self.daemon_id}' is stopped and cannot be resumed."
708
709        self._remove_stop_file()
710        try:
711            if self.process is None:
712                return False, f"Cannot resume daemon '{self.daemon_id}'."
713
714            self.process.resume()
715        except Exception as e:
716            return False, f"Failed to resume daemon '{self.daemon_id}':\n{e}"
717
718        timeout = self.get_timeout_seconds(timeout)
719        check_timeout_interval = self.get_check_timeout_interval_seconds(
720            check_timeout_interval
721        )
722
723        if not timeout:
724            success = self.status == 'running'
725            msg = "Success" if success else f"Failed to resume daemon '{self.daemon_id}'."
726            if success:
727                self._capture_process_timestamp('began')
728            return success, msg
729
730        begin = time.perf_counter()
731        while (time.perf_counter() - begin) < timeout:
732            if self.status == 'running':
733                self._capture_process_timestamp('began')
734                return True, "Success"
735            time.sleep(check_timeout_interval)
736
737        return False, (
738            f"Failed to resume daemon '{self.daemon_id}' within {timeout} second"
739            + ('s' if timeout != 1 else '') + '.'
740        )

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):
941    def mkdir_if_not_exists(self, allow_dirty_run: bool = False):
942        """Create the Daemon's directory.
943        If `allow_dirty_run` is `False` and the directory already exists,
944        raise a `FileExistsError`.
945        """
946        try:
947            self.path.mkdir(parents=True, exist_ok=True)
948            _already_exists = any(os.scandir(self.path))
949        except FileExistsError:
950            _already_exists = True
951
952        if _already_exists and not allow_dirty_run:
953            error(
954                f"Daemon '{self.daemon_id}' already exists. " +
955                "To allow this daemon to run, do one of the following:\n"
956                + "  - Execute `daemon.cleanup()`.\n"
957                + f"  - Delete the directory '{self.path}'.\n"
958                + "  - Pass `allow_dirty_run=True` to `daemon.run()`.\n",
959                FileExistsError,
960            )

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

process: "Union['psutil.Process', None]"
962    @property
963    def process(self) -> Union['psutil.Process', None]:
964        """
965        Return the psutil process for the Daemon.
966        """
967        psutil = attempt_import('psutil')
968        pid = self.pid
969        if pid is None:
970            return None
971        if '_process' not in self.__dict__ or self.__dict__['_process'].pid != int(pid):
972            try:
973                self._process = psutil.Process(int(pid))
974                process_exists = True
975            except Exception:
976                process_exists = False
977            if not process_exists:
978                _ = self.__dict__.pop('_process', None)
979                try:
980                    if self.pid_path.exists():
981                        self.pid_path.unlink()
982                except Exception:
983                    pass
984                return None
985        return self._process

Return the psutil process for the Daemon.

status: str
 987    @property
 988    def status(self) -> str:
 989        """
 990        Return the running status of this Daemon.
 991        """
 992        if self.process is None:
 993            return 'stopped'
 994
 995        psutil = attempt_import('psutil', lazy=False)
 996        try:
 997            if self.process.status() == 'stopped':
 998                return 'paused'
 999            if self.process.status() == 'zombie':
1000                raise psutil.NoSuchProcess(self.process.pid)
1001        except (psutil.NoSuchProcess, AttributeError):
1002            if self.pid_path.exists():
1003                try:
1004                    self.pid_path.unlink()
1005                except Exception:
1006                    pass
1007            return 'stopped'
1008
1009        return 'running'

Return the running status of this Daemon.

path: pathlib.Path
1019    @property
1020    def path(self) -> pathlib.Path:
1021        """
1022        Return the path for this Daemon's directory.
1023        """
1024        return self._get_path_from_daemon_id(self.daemon_id)

Return the path for this Daemon's directory.

properties_path: pathlib.Path
1033    @property
1034    def properties_path(self) -> pathlib.Path:
1035        """
1036        Return the `propterties.json` path for this Daemon.
1037        """
1038        return self._get_properties_path_from_daemon_id(self.daemon_id)

Return the propterties.json path for this Daemon.

stop_path: pathlib.Path
1040    @property
1041    def stop_path(self) -> pathlib.Path:
1042        """
1043        Return the path for the stop file (created when manually stopped).
1044        """
1045        return self.path / '.stop.json'

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

log_path: pathlib.Path
1047    @property
1048    def log_path(self) -> pathlib.Path:
1049        """
1050        Return the log path.
1051        """
1052        logs_cf = self.properties.get('logs', None) or {}
1053        if 'path' not in logs_cf:
1054            import meerschaum.config.paths as paths
1055            return paths.LOGS_RESOURCES_PATH / (self.daemon_id + '.log')
1056
1057        return pathlib.Path(logs_cf['path'])

Return the log path.

stdin_file_path: pathlib.Path
1059    @property
1060    def stdin_file_path(self) -> pathlib.Path:
1061        """
1062        Return the stdin file path.
1063        """
1064        return self.path / 'input.stdin'

Return the stdin file path.

blocking_stdin_file_path: pathlib.Path
1066    @property
1067    def blocking_stdin_file_path(self) -> pathlib.Path:
1068        """
1069        Return the stdin file path.
1070        """
1071        if '_blocking_stdin_file_path' in self.__dict__:
1072            return self._blocking_stdin_file_path
1073
1074        return self.path / 'input.stdin.block'

Return the stdin file path.

prompt_kwargs_file_path: pathlib.Path
1076    @property
1077    def prompt_kwargs_file_path(self) -> pathlib.Path:
1078        """
1079        Return the file path to the kwargs for the invoking `prompt()`.
1080        """
1081        return self.path / 'prompt_kwargs.json'

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

log_offset_path: pathlib.Path
1083    @property
1084    def log_offset_path(self) -> pathlib.Path:
1085        """
1086        Return the log offset file path.
1087        """
1088        import meerschaum.config.paths as paths
1089        return paths.LOGS_RESOURCES_PATH / ('.' + self.daemon_id + '.log.offset')

Return the log offset file path.

log_offset_lock: "'fasteners.InterProcessLock'"
1091    @property
1092    def log_offset_lock(self) -> 'fasteners.InterProcessLock':
1093        """
1094        Return the process lock context manager.
1095        """
1096        if '_log_offset_lock' in self.__dict__:
1097            return self._log_offset_lock
1098
1099        fasteners = attempt_import('fasteners')
1100        self._log_offset_lock = fasteners.InterProcessLock(self.log_offset_path)
1101        return self._log_offset_lock

Return the process lock context manager.

rotating_log: meerschaum.utils.daemon.RotatingFile
1103    @property
1104    def rotating_log(self) -> RotatingFile:
1105        """
1106        The rotating log file for the daemon's output.
1107        """
1108        if '_rotating_log' in self.__dict__:
1109            return self._rotating_log
1110
1111        logs_cf = self.properties.get('logs', None) or {}
1112        write_timestamps = logs_cf.get('write_timestamps', None)
1113        if write_timestamps is None:
1114            write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled')
1115
1116        timestamp_format = logs_cf.get('timestamp_format', None)
1117        if timestamp_format is None:
1118            timestamp_format = get_config('jobs', 'logs', 'timestamps', 'format')
1119
1120        num_files_to_keep = logs_cf.get('num_files_to_keep', None)
1121        if num_files_to_keep is None:
1122            num_files_to_keep = get_config('jobs', 'logs', 'num_files_to_keep')
1123
1124        max_file_size = logs_cf.get('max_file_size', None)
1125        if max_file_size is None:
1126            max_file_size = get_config('jobs', 'logs', 'max_file_size')
1127
1128        redirect_streams = logs_cf.get('redirect_streams', True)
1129
1130        self._rotating_log = RotatingFile(
1131            self.log_path,
1132            redirect_streams=redirect_streams,
1133            write_timestamps=write_timestamps,
1134            timestamp_format=timestamp_format,
1135            num_files_to_keep=num_files_to_keep,
1136            max_file_size=max_file_size,
1137        )
1138        return self._rotating_log

The rotating log file for the daemon's output.

stdin_file
1140    @property
1141    def stdin_file(self):
1142        """
1143        Return the file handler for the stdin file.
1144        """
1145        if (_stdin_file := self.__dict__.get('_stdin_file', None)):
1146            return _stdin_file
1147
1148        self._stdin_file = StdinFile(
1149            self.stdin_file_path,
1150            lock_file_path=self.blocking_stdin_file_path,
1151        )
1152        return self._stdin_file

Return the file handler for the stdin file.

log_text: Optional[str]
1154    @property
1155    def log_text(self) -> Union[str, None]:
1156        """
1157        Read the log files and return their contents.
1158        Returns `None` if the log file does not exist.
1159        """
1160        logs_cf = self.properties.get('logs', None) or {}
1161        write_timestamps = logs_cf.get('write_timestamps', None)
1162        if write_timestamps is None:
1163            write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled')
1164
1165        timestamp_format = logs_cf.get('timestamp_format', None)
1166        if timestamp_format is None:
1167            timestamp_format = get_config('jobs', 'logs', 'timestamps', 'format')
1168
1169        num_files_to_keep = logs_cf.get('num_files_to_keep', None)
1170        if num_files_to_keep is None:
1171            num_files_to_keep = get_config('jobs', 'logs', 'num_files_to_keep')
1172
1173        max_file_size = logs_cf.get('max_file_size', None)
1174        if max_file_size is None:
1175            max_file_size = get_config('jobs', 'logs', 'max_file_size')
1176
1177        new_rotating_log = RotatingFile(
1178            self.rotating_log.file_path,
1179            num_files_to_keep=num_files_to_keep,
1180            max_file_size=max_file_size,
1181            write_timestamps=write_timestamps,
1182            timestamp_format=timestamp_format,
1183        )
1184        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]:
1186    def readlines(self) -> List[str]:
1187        """
1188        Read the next log lines, persisting the cursor for later use.
1189        Note this will alter the cursor of `self.rotating_log`.
1190        """
1191        self.rotating_log._cursor = self._read_log_offset()
1192        lines = self.rotating_log.readlines()
1193        self._write_log_offset()
1194        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]
1227    @property
1228    def pid(self) -> Union[int, None]:
1229        """
1230        Read the PID file and return its contents.
1231        Returns `None` if the PID file does not exist.
1232        """
1233        if not self.pid_path.exists():
1234            ### The PID file can be lost while a detached daemon keeps running (e.g. the
1235            ### launcher exited without cleanup). Recover the PID by finding the process
1236            ### whose command line embeds this daemon_id, so `status`/`stop`/`kill` see the
1237            ### live process instead of reporting a false "stopped" (which would strand the
1238            ### orphan, forcing a manual `pkill meerschaum`).
1239            detached_pids = self._find_detached_pids()
1240            return detached_pids[0] if detached_pids else None
1241        try:
1242            with open(self.pid_path, 'r', encoding='utf-8') as f:
1243                text = f.read()
1244            if len(text) == 0:
1245                return None
1246            pid = int(text.rstrip())
1247        except Exception as e:
1248            warn(e)
1249            text = None
1250            pid = None
1251        return pid

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

pid_path: pathlib.Path
1253    @property
1254    def pid_path(self) -> pathlib.Path:
1255        """
1256        Return the path to a file containing the PID for this Daemon.
1257        """
1258        return self.path / 'process.pid'

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

pid_lock: "'fasteners.InterProcessLock'"
1260    @property
1261    def pid_lock(self) -> 'fasteners.InterProcessLock':
1262        """
1263        Return the process lock context manager.
1264        """
1265        if '_pid_lock' in self.__dict__:
1266            return self._pid_lock
1267
1268        fasteners = attempt_import('fasteners')
1269        self._pid_lock = fasteners.InterProcessLock(self.pid_path)
1270        return self._pid_lock

Return the process lock context manager.

pickle_path: pathlib.Path
1272    @property
1273    def pickle_path(self) -> pathlib.Path:
1274        """
1275        Return the path for the pickle file.
1276        """
1277        return self.path / 'pickle.pkl'

Return the path for the pickle file.

def read_properties(self) -> Optional[Dict[str, Any]]:
1279    def read_properties(self) -> Optional[Dict[str, Any]]:
1280        """Read the properties JSON file and return the dictionary."""
1281        if not self.properties_path.exists():
1282            return None
1283        try:
1284            with open(self.properties_path, 'r', encoding='utf-8') as file:
1285                properties = json.load(file)
1286        except Exception:
1287            properties = {}
1288        
1289        return properties or {}

Read the properties JSON file and return the dictionary.

def read_pickle(self) -> Daemon:
1291    def read_pickle(self) -> Daemon:
1292        """Read a Daemon's pickle file and return the `Daemon`."""
1293        import pickle
1294        import traceback
1295        if not self.pickle_path.exists():
1296            error(f"Pickle file does not exist for daemon '{self.daemon_id}'.")
1297
1298        if self.pickle_path.stat().st_size == 0:
1299            error(f"Pickle was empty for daemon '{self.daemon_id}'.")
1300
1301        try:
1302            with open(self.pickle_path, 'rb') as pickle_file:
1303                daemon = pickle.load(pickle_file)
1304            success, msg = True, 'Success'
1305        except Exception as e:
1306            success, msg = False, str(e)
1307            daemon = None
1308            traceback.print_exception(type(e), e, e.__traceback__)
1309        if not success:
1310            error(msg)
1311        return daemon

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

properties: Dict[str, Any]
1313    @property
1314    def properties(self) -> Dict[str, Any]:
1315        """
1316        Return the contents of the properties JSON file.
1317        """
1318        try:
1319            _file_properties = self.read_properties() or {}
1320        except Exception:
1321            traceback.print_exc()
1322            _file_properties = {}
1323
1324        if not self._properties:
1325            self._properties = _file_properties
1326
1327        if self._properties is None:
1328            self._properties = {}
1329
1330        if (
1331            self._properties.get('result', None) is None
1332            and _file_properties.get('result', None) is not None
1333        ):
1334            _ = self._properties.pop('result', None)
1335
1336        if _file_properties is not None:
1337            self._properties = apply_patch_to_config(
1338                _file_properties,
1339                self._properties,
1340            )
1341
1342        return self._properties

Return the contents of the properties JSON file.

hidden: bool
1344    @property
1345    def hidden(self) -> bool:
1346        """
1347        Return a bool indicating whether this Daemon should be displayed.
1348        """
1349        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]:
1351    def write_properties(self) -> SuccessTuple:
1352        """Write the properties dictionary to the properties JSON file
1353        (only if self.properties exists).
1354        """
1355        from meerschaum.utils.misc import generate_password
1356        success, msg = (
1357            False,
1358            f"No properties to write for daemon '{self.daemon_id}'."
1359        )
1360        backup_path = self.properties_path.parent / (generate_password(8) + '.json')
1361        props = self.properties
1362        if props is not None:
1363            try:
1364                self.path.mkdir(parents=True, exist_ok=True)
1365                if self.properties_path.exists():
1366                    self.properties_path.rename(backup_path)
1367                with open(self.properties_path, 'w+', encoding='utf-8') as properties_file:
1368                    json.dump(props, properties_file)
1369                success, msg = True, 'Success'
1370            except Exception as e:
1371                success, msg = False, str(e)
1372
1373        try:
1374            if backup_path.exists():
1375                if not success:
1376                    backup_path.rename(self.properties_path)
1377                else:
1378                    backup_path.unlink()
1379        except Exception as e:
1380            success, msg = False, str(e)
1381
1382        return success, msg

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

def write_pickle(self) -> Tuple[bool, str]:
1384    def write_pickle(self) -> SuccessTuple:
1385        """Write the pickle file for the daemon."""
1386        import pickle
1387        import traceback
1388        from meerschaum.utils.misc import generate_password
1389
1390        if not self.pickle:
1391            return True, "Success"
1392
1393        from meerschaum._internal.entry import _shells
1394        if _shells:
1395            from meerschaum._internal.shell.Shell import revert_input
1396            revert_input()
1397
1398        backup_path = self.pickle_path.parent / (generate_password(7) + '.pkl')
1399        try:
1400            self.path.mkdir(parents=True, exist_ok=True)
1401            if self.pickle_path.exists():
1402                self.pickle_path.rename(backup_path)
1403            with open(self.pickle_path, 'wb+') as pickle_file:
1404                pickle.dump(self, pickle_file)
1405            success, msg = True, "Success"
1406        except Exception as e:
1407            success, msg = False, str(e)
1408            traceback.print_exception(type(e), e, e.__traceback__)
1409        try:
1410            if backup_path.exists():
1411                if not success:
1412                    backup_path.rename(self.pickle_path)
1413                else:
1414                    backup_path.unlink()
1415        except Exception as e:
1416            success, msg = False, str(e)
1417        return success, msg

Write the pickle file for the daemon.

def cleanup(self, keep_logs: bool = False) -> Tuple[bool, str]:
1447    def cleanup(self, keep_logs: bool = False) -> SuccessTuple:
1448        """
1449        Remove a daemon's directory after execution.
1450
1451        Parameters
1452        ----------
1453        keep_logs: bool, default False
1454            If `True`, skip deleting the daemon's log files.
1455
1456        Returns
1457        -------
1458        A `SuccessTuple` indicating success.
1459        """
1460        if self.path.exists():
1461            try:
1462                shutil.rmtree(self.path)
1463            except Exception as e:
1464                msg = f"Failed to clean up '{self.daemon_id}':\n{e}"
1465                warn(msg)
1466                return False, msg
1467        if not keep_logs:
1468            self.rotating_log.delete()
1469            try:
1470                if self.log_offset_path.exists():
1471                    self.log_offset_path.unlink()
1472            except Exception as e:
1473                msg = f"Failed to remove offset file for '{self.daemon_id}':\n{e}"
1474                warn(msg)
1475                return False, msg
1476        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]:
1479    def get_timeout_seconds(self, timeout: Union[int, float, None] = None) -> Union[int, float]:
1480        """
1481        Return the timeout value to use. Use `--timeout-seconds` if provided,
1482        else the configured default (8).
1483        """
1484        if isinstance(timeout, (int, float)):
1485            return timeout
1486        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]:
1489    def get_check_timeout_interval_seconds(
1490        self,
1491        check_timeout_interval: Union[int, float, None] = None,
1492    ) -> Union[int, float]:
1493        """
1494        Return the interval value to check the status of timeouts.
1495        """
1496        if isinstance(check_timeout_interval, (int, float)):
1497            return check_timeout_interval
1498        return get_config('jobs', 'check_timeout_interval_seconds')

Return the interval value to check the status of timeouts.

target_args: Optional[Tuple[Any]]
1500    @property
1501    def target_args(self) -> Union[Tuple[Any], None]:
1502        """
1503        Return the positional arguments to pass to the target function.
1504        """
1505        target_args = (
1506            self.__dict__.get('_target_args', None)
1507            or self.properties.get('target', {}).get('args', None)
1508        )
1509        if target_args is None:
1510            return tuple([])
1511
1512        return tuple(target_args)

Return the positional arguments to pass to the target function.

target_kw: Optional[Dict[str, Any]]
1514    @property
1515    def target_kw(self) -> Union[Dict[str, Any], None]:
1516        """
1517        Return the keyword arguments to pass to the target function.
1518        """
1519        target_kw = (
1520            self.__dict__.get('_target_kw', None)
1521            or self.properties.get('target', {}).get('kw', None)
1522        )
1523        if target_kw is None:
1524            return {}
1525
1526        return {key: val for key, val in target_kw.items()}

Return the keyword arguments to pass to the target function.