meerschaum.jobs

Higher-level utilities for managing meerschaum.utils.daemon.Daemon.

  1#! /usr/bin/env python3
  2# -*- coding: utf-8 -*-
  3# vim:fenc=utf-8
  4
  5"""
  6Higher-level utilities for managing `meerschaum.utils.daemon.Daemon`.
  7"""
  8
  9import meerschaum as mrsm
 10from meerschaum.utils.typing import Dict, Optional, List, SuccessTuple
 11
 12from meerschaum.jobs import _native
 13from meerschaum.jobs._Job import Job, StopMonitoringLogs
 14from meerschaum.jobs._Executor import Executor
 15from meerschaum.jobs._access import (
 16    filter_visible_jobs,
 17    can_see_job,
 18    job_access,
 19    ownership_properties,
 20    get_team_jobs,
 21    move_team_jobs,
 22)
 23
 24__all__ = (
 25    'Job',
 26    'StopMonitoringLogs',
 27    'systemd',
 28    'get_jobs',
 29    'get_filtered_jobs',
 30    'get_restart_jobs',
 31    'get_running_jobs',
 32    'get_stopped_jobs',
 33    'get_paused_jobs',
 34    'get_restart_jobs',
 35    'get_executor_keys_from_context',
 36    'make_executor',
 37    'Executor',
 38    'check_restart_jobs',
 39    'start_check_jobs_thread',
 40    'stop_check_jobs_thread',
 41    'filter_visible_jobs',
 42    'can_see_job',
 43    'job_access',
 44    'ownership_properties',
 45    'get_team_jobs',
 46    'move_team_jobs',
 47    'report_lap',
 48)
 49
 50executor_types: List[str] = ['api', 'local']
 51
 52
 53#: Set by the worker while it runs a plugin action for the Rust host (`report_lap`).
 54_lap_reporter = None
 55
 56
 57def report_lap(
 58    success: bool = True,
 59    message: str = '',
 60    next_in: Optional[float] = None,
 61) -> SuccessTuple:
 62    """
 63    Record a lap of a plugin action that repeats its work by itself (its function takes `loop`,
 64    so `--loop` is left to it): the job's health then shows how its laps go, and the Overview's
 65    Next runs when the next one is due.
 66
 67    Call it once at the end of each iteration. Outside a job (a foreground run, a script) it does
 68    nothing. An action without a `loop` parameter does not need it: with `--loop` or `-s`, the
 69    host runs it in laps and records them itself.
 70
 71    Parameters
 72    ----------
 73    success: bool, default True
 74        Whether this lap succeeded (a failed lap counts toward `consecutive_failures`).
 75
 76    message: str, default ''
 77        What the lap did, shown with the job's health.
 78
 79    next_in: Optional[float], default None
 80        Seconds until the next lap begins (the action's own `min_seconds`). It sets the cadence
 81        the job is expected at (stalled after three times it) and `job.health['next_run']`.
 82
 83    Returns
 84    -------
 85    A `SuccessTuple`: whether the lap was recorded.
 86
 87    Examples
 88    --------
 89    >>> import time
 90    >>> import meerschaum as mrsm
 91    >>> def heartbeat_ack(loop: bool = False, min_seconds: float = 20, **kwargs):
 92    ...     while True:
 93    ...         success, msg = ack_once()
 94    ...         mrsm.jobs.report_lap(success, msg, next_in=min_seconds)
 95    ...         if not loop:
 96    ...             return success, msg
 97    ...         time.sleep(min_seconds)
 98    """
 99    reporter = _lap_reporter
100    if reporter is None:
101        return True, "Not running in a job's plugin action: the lap was not recorded."
102    lap = {'success': bool(success), 'message': str(message or '')}
103    if next_in is not None:
104        lap['next_in'] = float(next_in)
105    value = reporter(lap)
106    if isinstance(value, (list, tuple)) and len(value) == 2:
107        return bool(value[0]), str(value[1])
108    return False, f"Unexpected answer from the host: {value!r}"
109
110
111def _rows_to_jobs(rows) -> Dict[str, Job]:
112    """Build the `Job`s of a native listing."""
113    jobs = {}
114    for row in rows:
115        keys = row['executor_keys']
116        jobs[row['name']] = Job(
117            row['name'],
118            None if keys == 'local' else row['sysargs'],
119            executor_keys=keys,
120        )
121    return jobs
122
123
124def get_jobs(
125    executor_keys: Optional[str] = None,
126    include_hidden: bool = False,
127    combine_local_and_systemd: bool = True,
128    debug: bool = False,
129) -> Dict[str, Job]:
130    """
131    Return a dictionary of the existing jobs.
132
133    Parameters
134    ----------
135    executor_keys: Optional[str], default None
136        If provided, return remote jobs on the given API instance.
137        Otherwise return local jobs.
138
139    include_hidden: bool, default False
140        If `True`, include jobs with the `hidden` attribute.
141
142    Returns
143    -------
144    A dictionary mapping job names to jobs.
145    """
146    from meerschaum.utils.warnings import warn
147    native = _native.core()
148    try:
149        rows, warnings = native.jobs_get_jobs(
150            executor_keys,
151            include_hidden,
152            combine_local_and_systemd,
153            api_attributes=_native.api_attributes(executor_keys),
154        )
155    except RuntimeError as e:
156        ### An unreachable or unauthorized executor is not "no jobs".
157        warn(f"Could not get the jobs of executor '{executor_keys}': {e}", stack=False)
158        return {}
159
160    _native.emit_warnings(warnings)
161    return _rows_to_jobs(rows)
162
163
164def get_filtered_jobs(
165    executor_keys: Optional[str] = None,
166    filter_list: Optional[List[str]] = None,
167    include_hidden: bool = False,
168    combine_local_and_systemd: bool = True,
169    warn: bool = False,
170    debug: bool = False,
171) -> Dict[str, Job]:
172    """
173    Return a list of jobs filtered by the user.
174    """
175    from meerschaum.utils.warnings import warn as _warn
176    native = _native.core()
177    try:
178        rows, listing_warnings, missing = native.jobs_get_filtered_jobs(
179            executor_keys,
180            filter_list or [],
181            include_hidden,
182            combine_local_and_systemd,
183            api_attributes=_native.api_attributes(executor_keys),
184        )
185    except RuntimeError as e:
186        _warn(f"Could not get the jobs of executor '{executor_keys}': {e}", stack=False)
187        return {}
188
189    _native.emit_warnings(listing_warnings)
190    if warn:
191        _native.emit_warnings(missing)
192    return _rows_to_jobs(rows)
193
194
195def get_restart_jobs(
196    executor_keys: Optional[str] = None,
197    jobs: Optional[Dict[str, Job]] = None,
198    include_hidden: bool = False,
199    combine_local_and_systemd: bool = True,
200    debug: bool = False,
201) -> Dict[str, Job]:
202    """
203    Return jobs which were created with `--restart` or `--loop`.
204    """
205    if jobs is None:
206        jobs = get_jobs(
207            executor_keys,
208            include_hidden=include_hidden,
209            combine_local_and_systemd=combine_local_and_systemd,
210            debug=debug,
211        )
212
213    return {
214        name: job
215        for name, job in jobs.items()
216        if job.restart
217    }
218
219
220def get_running_jobs(
221    executor_keys: Optional[str] = None,
222    jobs: Optional[Dict[str, Job]] = None,
223    include_hidden: bool = False,
224    combine_local_and_systemd: bool = True,
225    debug: bool = False,
226) -> Dict[str, Job]:
227    """
228    Return a dictionary of running jobs.
229    """
230    if jobs is None:
231        jobs = get_jobs(
232            executor_keys,
233            include_hidden=include_hidden,
234            combine_local_and_systemd=combine_local_and_systemd,
235            debug=debug,
236        )
237
238    return {
239        name: job
240        for name, job in jobs.items()
241        if job.status == 'running'
242    }
243
244
245def get_paused_jobs(
246    executor_keys: Optional[str] = None,
247    jobs: Optional[Dict[str, Job]] = None,
248    include_hidden: bool = False,
249    combine_local_and_systemd: bool = True,
250    debug: bool = False,
251) -> Dict[str, Job]:
252    """
253    Return a dictionary of paused jobs.
254    """
255    if jobs is None:
256        jobs = get_jobs(
257            executor_keys,
258            include_hidden=include_hidden,
259            combine_local_and_systemd=combine_local_and_systemd,
260            debug=debug,
261        )
262
263    return {
264        name: job
265        for name, job in jobs.items()
266        if job.status == 'paused'
267    }
268
269
270def get_stopped_jobs(
271    executor_keys: Optional[str] = None,
272    jobs: Optional[Dict[str, Job]] = None,
273    include_hidden: bool = False,
274    combine_local_and_systemd: bool = True,
275    debug: bool = False,
276) -> Dict[str, Job]:
277    """
278    Return a dictionary of stopped jobs.
279    """
280    if jobs is None:
281        jobs = get_jobs(
282            executor_keys,
283            include_hidden=include_hidden,
284            combine_local_and_systemd=combine_local_and_systemd,
285            debug=debug,
286        )
287
288    return {
289        name: job
290        for name, job in jobs.items()
291        if job.status == 'stopped'
292    }
293
294
295def make_executor(cls):
296    """
297    Register a class as an `Executor`.
298    """
299    import re
300    from meerschaum.connectors import make_connector
301    suffix_regex = r'executor$'
302    typ = re.sub(suffix_regex, '', cls.__name__.lower())
303    if typ not in executor_types:
304        executor_types.append(typ)
305    return make_connector(cls, _is_executor=True)
306
307
308def check_restart_jobs(
309    executor_keys: Optional[str] = 'local',
310    jobs: Optional[Dict[str, Job]] = None,
311    include_hidden: bool = True,
312    silent: bool = False,
313    debug: bool = False,
314) -> SuccessTuple:
315    """
316    Restart any stopped jobs which were created with `--restart`.
317
318    Parameters
319    ----------
320    executor_keys: Optional[str], default None
321        If provided, check jobs on the given remote API instance.
322        Otherwise check local jobs.
323
324    include_hidden: bool, default True
325        If `True`, include hidden jobs in the check.
326
327    silent: bool, default False
328        If `True`, do not print the restart success message.
329    """
330    from meerschaum.utils.misc import items_str
331
332    if jobs is None:
333        jobs = get_jobs(
334            executor_keys,
335            include_hidden=include_hidden,
336            combine_local_and_systemd=False,
337            debug=debug,
338        )
339
340    if not jobs:
341        return True, "No jobs to restart."
342
343    results = {}
344    for name, job in jobs.items():
345        check_success, check_msg = job.check_restart()
346        results[job.name] = (check_success, check_msg)
347        if not silent:
348            mrsm.pprint((check_success, check_msg))
349
350    success_names = [name for name, (check_success, check_msg) in results.items() if check_success]
351    fail_names = [name for name, (check_success, check_msg) in results.items() if not check_success]
352    success = len(success_names) == len(jobs)
353    msg = (
354        (
355            "Successfully restarted job"
356            + ('s' if len(success_names) != 1 else '')
357            + ' ' + items_str(success_names) + '.'
358        )
359        if success
360        else (
361            "Failed to restart job"
362            + ('s' if len(success_names) != 1 else '')
363            + ' ' + items_str(fail_names) + '.'
364        )
365    )
366    return success, msg
367
368
369def _check_restart_jobs_against_lock(*args, **kwargs):
370    import fcntl
371    import meerschaum.config.paths as paths
372    paths.CHECK_JOBS_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
373    with open(paths.CHECK_JOBS_LOCK_PATH, 'a+', encoding='utf-8') as lock_file:
374        fcntl.flock(lock_file, fcntl.LOCK_EX)
375        try:
376            check_restart_jobs(*args, **kwargs)
377        finally:
378            fcntl.flock(lock_file, fcntl.LOCK_UN)
379
380
381_check_loop_stop_thread = None
382def start_check_jobs_thread():
383    """
384    Start a thread to regularly monitor jobs.
385    """
386    import atexit
387    from functools import partial
388    from meerschaum.utils.threading import RepeatTimer
389    from meerschaum._internal.static import STATIC_CONFIG
390
391    global _check_loop_stop_thread
392    sleep_seconds = STATIC_CONFIG['jobs']['check_restart_seconds']
393
394    _check_loop_stop_thread = RepeatTimer(
395        sleep_seconds,
396        partial(
397            _check_restart_jobs_against_lock,
398            silent=True,
399        )
400    )
401    _check_loop_stop_thread.daemon = True
402    atexit.register(stop_check_jobs_thread)
403
404    _check_loop_stop_thread.start()
405    return _check_loop_stop_thread
406
407
408def stop_check_jobs_thread():
409    """
410    Stop the job monitoring thread.
411    """
412    import meerschaum.config.paths as paths
413    from meerschaum.utils.warnings import warn
414    if _check_loop_stop_thread is None:
415        return
416
417    _check_loop_stop_thread.cancel()
418
419    try:
420        if paths.CHECK_JOBS_LOCK_PATH.exists():
421            paths.CHECK_JOBS_LOCK_PATH.unlink()
422    except Exception as e:
423        warn(f"Failed to remove check jobs lock file:\n{e}")
424
425
426def get_executor_keys_from_context() -> str:
427    """
428    Return the configured executor (`meerschaum:executor`).
429    Otherwise, on a host with `systemd`, return `'systemd'` (for any root directory) unless
430    Meerschaum runs from a source checkout or a development build.
431    Otherwise return `'local'`.
432    """
433    return _native.core().jobs_executor_keys_from_context()
434
435
436def _install_healthcheck_job() -> SuccessTuple:
437    """
438    Install the systemd job which checks local jobs.
439    """
440    from meerschaum.config import get_config
441
442    enable_healthcheck = get_config('system', 'experimental', 'systemd_healthcheck')
443    if not enable_healthcheck:
444        return False, "Local healthcheck is disabled."
445
446    if get_executor_keys_from_context() != 'systemd':
447        return False, "Not running systemd."
448
449    job = Job(
450        '.local-healthcheck',
451        ['restart', 'jobs', '-e', 'local', '--loop', '--min-seconds', '60'],
452        executor_keys='systemd',
453    )
454    return job.start()
class Job:
 90class Job:
 91    """
 92    Manage a job, locally, with `systemd`, or remotely via the API.
 93    """
 94
 95    def __init__(
 96        self,
 97        name: str,
 98        sysargs: Union[List[str], str, None] = None,
 99        env: Optional[Dict[str, str]] = None,
100        executor_keys: Optional[str] = None,
101        delete_after_completion: bool = False,
102        refresh_seconds: Union[int, float, None] = None,
103        _properties: Optional[Dict[str, Any]] = None,
104        _status_hook: Optional[Callable[[], str]] = None,
105        _result_hook: Optional[Callable[[], SuccessTuple]] = None,
106        _externally_managed: bool = False,
107    ):
108        """
109        Create a new job.
110
111        Parameters
112        ----------
113        name: str
114            The name of the job to be created.
115            This will also be used as the Daemon ID.
116
117        sysargs: Union[List[str], str, None], default None
118            The sysargs of the command to be executed, e.g. 'start api'.
119
120        env: Optional[Dict[str, str]], default None
121            If provided, set these environment variables in the job's process.
122
123        executor_keys: Optional[str], default None
124            If provided, execute the job remotely on an API instance, e.g. 'api:main'
125            (or with `'systemd'`). Defaults to `'local'`, or the context's executor.
126
127        delete_after_completion: bool, default False
128            If `True`, delete this job when it has finished executing.
129
130        refresh_seconds: Union[int, float, None], default None
131            The number of seconds to sleep between refreshes.
132            Defaults to the configured value `system.cli.refresh_seconds`.
133
134        _properties: Optional[Dict[str, Any]], default None
135            If provided, use this to patch the daemon's properties.
136        """
137        native = _native.core()
138        self._core = native.Job(
139            name,
140            sysargs,
141            env=env,
142            executor_keys=executor_keys,
143            delete_after_completion=delete_after_completion,
144            refresh_seconds=refresh_seconds,
145            properties=_properties,
146            externally_managed=_externally_managed,
147            api_attributes=_native.api_attributes(executor_keys),
148        )
149        _native.emit_warnings(self._core.take_warnings())
150        self.name = name
151        self.executor_keys = self._core.executor_keys
152        self.refresh_seconds = (
153            refresh_seconds
154            if refresh_seconds is not None
155            else mrsm.get_config('system', 'cli', 'refresh_seconds')
156        )
157        if _status_hook is not None:
158            self._status_hook = _status_hook
159
160        if _result_hook is not None:
161            self._result_hook = _result_hook
162
163        self._externally_managed = _externally_managed
164
165    def _warn(self) -> None:
166        """Show the warnings the last native operation produced."""
167        _native.emit_warnings(self._core.take_warnings())
168
169    @staticmethod
170    def from_pid(pid: int, executor_keys: Optional[str] = None) -> Job:
171        """
172        Build a `Job` from the PID of a running Meerschaum process.
173
174        Parameters
175        ----------
176        pid: int
177            The PID of the process.
178
179        executor_keys: Optional[str], default None
180            The executor keys to assign to the job.
181        """
182        import os
183        try:
184            os.kill(pid, 0)
185        except ProcessLookupError as e:
186            warn(f"Process with PID {pid} does not exist.", stack=False)
187            raise e
188        except PermissionError:
189            pass
190
191        native = _native.core()
192        daemon_ids = native.jobs_daemon_ids()
193        for daemon_id in daemon_ids:
194            job = Job(daemon_id, executor_keys=executor_keys)
195            if job.pid == pid:
196                return job
197
198        cmdline_path = pathlib.Path('/proc') / str(pid) / 'cmdline'
199        if not cmdline_path.exists():
200            raise EnvironmentError(f"Is process {pid} a Meerschaum job?")
201
202        command_args = [
203            arg
204            for arg in cmdline_path.read_bytes().decode('utf-8', errors='replace').split('\0')
205            if arg
206        ]
207        # During startup the PID may change from the supervisor to its worker between
208        # the caller's PID read and the registry lookup above. Scope its persistent marker
209        # to this registry: another root may have a different Job with the same name.
210        if '--jobs-dir' in command_args:
211            import meerschaum.config.paths as paths
212            index = command_args.index('--jobs-dir') + 1
213            if (
214                index >= len(command_args)
215                or pathlib.Path(command_args[index]).resolve() != paths.DAEMON_RESOURCES_PATH.resolve()
216            ):
217                raise EnvironmentError(f"Process {pid} belongs to another Meerschaum jobs directory.")
218            for arg in command_args:
219                if arg.startswith("daemon_id='") and arg.endswith("'"):
220                    daemon_id = arg[len("daemon_id='"):-1]
221                    if daemon_id in daemon_ids:
222                        return Job(daemon_id, executor_keys=executor_keys)
223            if '__mrsm_job_supervisor__' in command_args or '__mrsm_run_daemon__' in command_args:
224                if '--daemon-id' in command_args:
225                    index = command_args.index('--daemon-id') + 1
226                    if index < len(command_args) and command_args[index] in daemon_ids:
227                        return Job(command_args[index], executor_keys=executor_keys)
228
229        if len(command_args) > 1 and command_args[1] == '-c':
230            raise EnvironmentError(f"Is process {pid} a running job?")
231
232        mrsm_ix = 0
233        for i, arg in enumerate(command_args):
234            if 'mrsm' in arg or 'meerschaum' in arg.lower():
235                mrsm_ix = i
236                break
237
238        sysargs = command_args[mrsm_ix + 1:]
239        name = None
240        for flag in ('--name', '--job-name'):
241            if flag in sysargs and sysargs.index(flag) + 1 < len(sysargs):
242                name = sysargs[sysargs.index(flag) + 1]
243                break
244        if name is None:
245            name = native.jobs_new_daemon_name()
246        return Job(name, sysargs, executor_keys=executor_keys)
247
248    def start(self, debug: bool = False) -> SuccessTuple:
249        """
250        Start the job's daemon.
251        """
252        result = self._core.start()
253        self._warn()
254        return result
255
256    def stop(
257        self,
258        timeout_seconds: Union[int, float, None] = None,
259        debug: bool = False,
260    ) -> SuccessTuple:
261        """
262        Stop the job's daemon.
263        """
264        result = self._core.stop(timeout_seconds)
265        self._warn()
266        return result
267
268    def pause(
269        self,
270        timeout_seconds: Union[int, float, None] = None,
271        debug: bool = False,
272    ) -> SuccessTuple:
273        """
274        Pause the job's daemon.
275        """
276        result = self._core.pause(timeout_seconds)
277        self._warn()
278        return result
279
280    def resume(
281        self,
282        timeout_seconds: Union[int, float, None] = None,
283        debug: bool = False,
284    ) -> SuccessTuple:
285        """
286        Resume a paused job.
287        """
288        result = self._core.resume(timeout_seconds)
289        self._warn()
290        return result
291
292    def delete(self, debug: bool = False) -> SuccessTuple:
293        """
294        Delete the job and its daemon.
295        """
296        result = self._core.delete()
297        self._warn()
298        return result
299
300    def is_running(self) -> bool:
301        """
302        Determine whether the job's daemon is running.
303        """
304        return self.status == 'running'
305
306    def exists(self, debug: bool = False) -> bool:
307        """
308        Determine whether the job exists.
309        """
310        return self._core.exists()
311
312    def get_logs(self) -> Union[str, None]:
313        """
314        Return the output text of the job's daemon.
315        """
316        return self._core.get_logs()
317
318    def monitor_logs(
319        self,
320        callback_function: Callable[[str], None] = _default_stdout_callback,
321        input_callback_function: Optional[Callable[[], str]] = None,
322        stop_callback_function: Optional[Callable[[SuccessTuple], None]] = None,
323        stop_event: Optional[asyncio.Event] = None,
324        stop_on_exit: bool = False,
325        strip_timestamps: bool = False,
326        accept_input: bool = True,
327        debug: bool = False,
328        _wait_if_stopped: bool = True,
329    ):
330        """
331        Monitor the job's log files and execute a callback on new lines.
332
333        Parameters
334        ----------
335        callback_function: Callable[[str], None], default partial(print, end='')
336            The callback to execute as new data comes in.
337            Defaults to printing the output directly to `stdout`.
338
339        input_callback_function: Optional[Callable[[], str]], default None
340            If provided, execute this callback when the daemon is blocking on stdin.
341            Defaults to `sys.stdin.readline()`.
342
343        stop_callback_function: Optional[Callable[[SuccessTuple]], str], default None
344            If provided, execute this callback when the daemon stops.
345            The job's SuccessTuple will be passed to the callback.
346
347        stop_event: Optional[asyncio.Event], default None
348            If provided, stop monitoring when this event is set.
349            You may instead raise `meerschaum.jobs.StopMonitoringLogs`
350            from within `callback_function` to stop monitoring.
351
352        stop_on_exit: bool, default False
353            If `True`, stop monitoring when the job stops.
354
355        strip_timestamps: bool, default False
356            If `True`, remove leading timestamps from lines.
357
358        accept_input: bool, default True
359            If `True`, accept input when the daemon blocks on stdin.
360        """
361        return self._monitor(
362            callback_function,
363            input_callback_function,
364            stop_callback_function,
365            stop_event,
366            stop_on_exit,
367            strip_timestamps,
368            accept_input,
369            _wait_if_stopped,
370            loop=None,
371            stop_flag=None,
372        )
373
374    async def monitor_logs_async(
375        self,
376        callback_function: Callable[[str], None] = _default_stdout_callback,
377        input_callback_function: Optional[Callable[[], str]] = None,
378        stop_callback_function: Optional[Callable[[SuccessTuple], None]] = None,
379        stop_event: Optional[asyncio.Event] = None,
380        stop_on_exit: bool = False,
381        strip_timestamps: bool = False,
382        accept_input: bool = True,
383        debug: bool = False,
384        _wait_if_stopped: bool = True,
385    ):
386        """
387        Monitor the job's log files and await a callback on new lines.
388
389        Parameters
390        ----------
391        callback_function: Callable[[str], None], default _default_stdout_callback
392            The callback to execute as new data comes in.
393            Defaults to printing the output directly to `stdout`.
394
395        input_callback_function: Optional[Callable[[], str]], default None
396            If provided, execute this callback when the daemon is blocking on stdin.
397            Defaults to `sys.stdin.readline()`.
398
399        stop_callback_function: Optional[Callable[[SuccessTuple]], str], default None
400            If provided, execute this callback when the daemon stops.
401            The job's SuccessTuple will be passed to the callback.
402
403        stop_event: Optional[asyncio.Event], default None
404            If provided, stop monitoring when this event is set.
405            You may instead raise `meerschaum.jobs.StopMonitoringLogs`
406            from within `callback_function` to stop monitoring.
407
408        stop_on_exit: bool, default False
409            If `True`, stop monitoring when the job stops.
410
411        strip_timestamps: bool, default False
412            If `True`, remove leading timestamps from lines.
413
414        accept_input: bool, default True
415            If `True`, accept input when the daemon blocks on stdin.
416        """
417        loop = asyncio.get_running_loop()
418        stop_flag = _native.core().StopFlag()
419        run = functools.partial(
420            self._monitor,
421            callback_function,
422            input_callback_function,
423            stop_callback_function,
424            stop_event,
425            stop_on_exit,
426            strip_timestamps,
427            accept_input,
428            _wait_if_stopped,
429            loop=loop,
430            stop_flag=stop_flag,
431        )
432        try:
433            await loop.run_in_executor(None, run)
434        except asyncio.CancelledError:
435            stop_flag.set()
436            raise
437        finally:
438            stop_flag.set()
439
440    def _monitor(
441        self,
442        callback_function,
443        input_callback_function,
444        stop_callback_function,
445        stop_event,
446        stop_on_exit,
447        strip_timestamps,
448        accept_input,
449        wait_if_stopped,
450        loop,
451        stop_flag,
452    ):
453        """
454        Follow the logs with the native monitor, running the callbacks on this thread's helper
455        (coroutine functions are awaited on `loop`, or on a private loop when there is none).
456        """
457        def sync(function):
458            if function is None or not inspect.iscoroutinefunction(function):
459                return function
460
461            def call(*args):
462                coroutine = function(*args)
463                if loop is not None:
464                    return asyncio.run_coroutine_threadsafe(coroutine, loop).result()
465                return asyncio.run(coroutine)
466
467            return call
468
469        def default_input_callback_function():
470            from meerschaum.utils.prompt import prompt
471            prompt_kwargs = self.get_prompt_kwargs()
472            if prompt_kwargs:
473                answer = prompt(**prompt_kwargs)
474                return answer + '\n'
475            return sys.stdin.readline()
476
477        input_callback = sync(input_callback_function) or default_input_callback_function
478        self._core.monitor_logs(
479            sync(callback_function),
480            input_callback=input_callback if accept_input else None,
481            stop_callback=sync(stop_callback_function),
482            stop_event=stop_event,
483            stop_flag=stop_flag,
484            stop_on_exit=stop_on_exit,
485            strip_timestamps=strip_timestamps,
486            accept_input=accept_input,
487            wait_if_stopped=wait_if_stopped,
488            stop_exception=StopMonitoringLogs,
489            clear_callback=_clear_screen,
490            warn=lambda message: warn(message),
491        )
492
493    def is_blocking_on_stdin(self, debug: bool = False) -> bool:
494        """
495        Return whether a job's daemon is blocking on stdin.
496        """
497        return self._core.is_blocking_on_stdin()
498
499    def get_prompt_kwargs(self, debug: bool = False) -> Dict[str, Any]:
500        """
501        Return the kwargs to the blocking `prompt()`, if available.
502        """
503        return self._core.get_prompt_kwargs()
504
505    def write_stdin(self, data):
506        """
507        Write to a job's daemon's `stdin`.
508        """
509        self._core.write_stdin(data)
510
511    @property
512    def executor(self) -> Union[Executor, None]:
513        """
514        If the job is not local, return the connector to its executor
515        (the remote API instance, `systemd`, a plugin's executor).
516        """
517        if self.executor_keys == 'local':
518            return None
519        from meerschaum.jobs._native import is_python_executor_type
520        if is_python_executor_type(self.executor_keys.split(':', maxsplit=1)[0]):
521            return mrsm.get_connector(self.executor_keys)
522        ### An executor type of a native (Rust) plugin: no Python connector exists.
523        from meerschaum.jobs._NativeExecutor import NativeExecutor
524        return NativeExecutor(self.executor_keys)
525
526    @property
527    def status(self) -> str:
528        """
529        Return the running status of the job's daemon.
530        """
531        if '_status_hook' in self.__dict__:
532            return self._status_hook()
533
534        return self._core.status
535
536    @property
537    def pid(self) -> Union[int, None]:
538        """
539        Return the PID of the job's dameon.
540        """
541        return self._core.pid
542
543    @property
544    def restart(self) -> bool:
545        """
546        Return whether to restart a stopped job.
547        """
548        return self._core.restarts
549
550    @property
551    def result(self) -> SuccessTuple:
552        """
553        Return the `SuccessTuple` when the job has terminated.
554        """
555        if '_result_hook' in self.__dict__:
556            if self.is_running():
557                return True, f"{self} is running."
558            return self._result_hook()
559
560        return self._core.result
561
562    @property
563    def sysargs(self) -> List[str]:
564        """
565        Return the sysargs to use for the Daemon.
566        """
567        return self._core.sysargs
568
569    @property
570    def health(self) -> Dict[str, Any]:
571        """
572        Return how the job's laps go (a job that repeats its work: `--loop`, `-s`, `--listen`).
573
574        Returns
575        -------
576        A dictionary with the keys:
577
578        - `state`: `'healthy'` (the last lap succeeded), `'failing'` (the last lap failed),
579          `'stalled'` (no lap for three times the expected interval, and at least a minute;
580          an hour without a sync for `--listen`; at least an hour for a first lap),
581          `'stopped'` (not running, or paused) or `'unknown'` (nothing recorded yet, e.g.
582          `'first sync in progress'`).
583        - `last_success`, `last_lap`: ISO-8601 UTC timestamps (or `None`).
584        - `consecutive_failures`: laps that failed in a row.
585        - `expected_interval_seconds`: how often laps are expected (`--min-seconds`, the
586          schedule's period, an hour for `--listen`), or `None`.
587        - `next_run`: while a running job waits for its next lap (`--min-seconds`, a cooldown
588          after a failure, a schedule's next fire), when that lap is due (ISO-8601 UTC);
589          otherwise `None`.
590        - `message`: what to show next to the state, e.g. `'last good sync 2 min ago'`.
591
592        Examples
593        --------
594        >>> job = Job('weather', 'sync pipes -c plugin:noaa --loop')
595        >>> success, msg = job.start()
596        >>> job.health['state']  # Before its first lap ends.
597        'unknown'
598        """
599        return self._core.health()
600
601    @property
602    def owner(self) -> Union[str, None]:
603        """
604        Return the user who started this job through the API server or the web console
605        (`None` for a job created locally: the CLI, Compose, systemd or Python). A job started
606        by another job has that job's owner. For an `api:` job, the server's record.
607
608        Examples
609        --------
610        >>> Job('nightly', executor_keys='api:main').owner
611        'alice'
612        """
613        return self._core.access().get('owner', None)
614
615    @property
616    def team(self) -> Union[str, None]:
617        """
618        Return the team this job belongs to: the primary team of the user who started it through
619        the API server or the web console (a job started by another job: that job's team).
620        `None` means Everyone (every local job).
621
622        Members of the team (and administrators) see the job and, with the job permissions,
623        control it. Renaming the team renames it here; deleting the team moves the job to
624        Everyone.
625        """
626        return self._core.access().get('team', None)
627
628    def get_daemon_properties(self) -> Dict[str, Any]:
629        """
630        Return the `properties` dictionary for the job's daemon.
631        """
632        return self._core.get_daemon_properties()
633
634    @property
635    def daemon(self) -> 'Daemon':
636        """
637        Return the daemon which this job manages.
638        """
639        from meerschaum.utils.daemon import Daemon
640        if self.executor_keys == 'local' and self.exists():
641            return Daemon(daemon_id=self.name)
642
643        return Daemon(
644            daemon_id=self.name,
645            sysargs=self.sysargs,
646            label=shlex.join(self.sysargs),
647            properties=self.get_daemon_properties(),
648        )
649
650    @property
651    def began(self) -> Union[datetime, None]:
652        """
653        The datetime when the job began running.
654        """
655        return _parse_dt(self._core.began)
656
657    @property
658    def ended(self) -> Union[datetime, None]:
659        """
660        The datetime when the job stopped running.
661        """
662        return _parse_dt(self._core.ended)
663
664    @property
665    def paused(self) -> Union[datetime, None]:
666        """
667        The datetime when the job was suspended while running.
668        """
669        return _parse_dt(self._core.paused)
670
671    @property
672    def stop_time(self) -> Union[datetime, None]:
673        """
674        Return the timestamp when the job was manually stopped.
675        """
676        return _parse_dt(self._core.stop_time)
677
678    @property
679    def hidden(self) -> bool:
680        """
681        Return a bool indicating whether this job should be displayed.
682        """
683        return self._core.hidden
684
685    def check_restart(self) -> SuccessTuple:
686        """
687        If `restart` is `True` and the daemon is not running,
688        restart the job.
689        Do not restart if the job was manually stopped.
690        """
691        result = self._core.check_restart()
692        self._warn()
693        return result
694
695    @property
696    def label(self) -> str:
697        """
698        Return the job's Daemon label (joined sysargs).
699        """
700        return self._core.label
701
702    @property
703    def _externally_managed_file(self) -> pathlib.Path:
704        """
705        Return the path to the externally managed file.
706        """
707        return pathlib.Path(self._core.paths()['externally_managed'])
708
709    def _set_externally_managed(self):
710        """
711        Set this job as externally managed.
712        """
713        self._externally_managed = True
714        self._core.set_externally_managed()
715
716    @property
717    def _is_externally_managed(self) -> bool:
718        """
719        Return whether this job is externally managed.
720        """
721        return self._core.is_externally_managed
722
723    @property
724    def env(self) -> Dict[str, str]:
725        """
726        Return the environment variables to set for the job's process.
727        """
728        return self._core.env
729
730    @property
731    def delete_after_completion(self) -> bool:
732        """
733        Return whether this job is configured to delete itself after completion.
734        """
735        return self._core.delete_after_completion
736
737    def __str__(self) -> str:
738        return self._core.describe()
739
740    def __repr__(self) -> str:
741        return str(self)
742
743    def __hash__(self) -> int:
744        return hash(self.name)

Manage a job, locally, with systemd, or remotely via the API.

Job( name: str, sysargs: List[str] | str | None = None, env: Dict[str, str] | None = None, executor_keys: str | None = None, delete_after_completion: bool = False, refresh_seconds: int | float | None = None, _properties: Dict[str, Any] | None = None, _status_hook: Callable[[], str] | None = None, _result_hook: Callable[[], Tuple[bool, str]] | None = None, _externally_managed: bool = False)
 95    def __init__(
 96        self,
 97        name: str,
 98        sysargs: Union[List[str], str, None] = None,
 99        env: Optional[Dict[str, str]] = None,
100        executor_keys: Optional[str] = None,
101        delete_after_completion: bool = False,
102        refresh_seconds: Union[int, float, None] = None,
103        _properties: Optional[Dict[str, Any]] = None,
104        _status_hook: Optional[Callable[[], str]] = None,
105        _result_hook: Optional[Callable[[], SuccessTuple]] = None,
106        _externally_managed: bool = False,
107    ):
108        """
109        Create a new job.
110
111        Parameters
112        ----------
113        name: str
114            The name of the job to be created.
115            This will also be used as the Daemon ID.
116
117        sysargs: Union[List[str], str, None], default None
118            The sysargs of the command to be executed, e.g. 'start api'.
119
120        env: Optional[Dict[str, str]], default None
121            If provided, set these environment variables in the job's process.
122
123        executor_keys: Optional[str], default None
124            If provided, execute the job remotely on an API instance, e.g. 'api:main'
125            (or with `'systemd'`). Defaults to `'local'`, or the context's executor.
126
127        delete_after_completion: bool, default False
128            If `True`, delete this job when it has finished executing.
129
130        refresh_seconds: Union[int, float, None], default None
131            The number of seconds to sleep between refreshes.
132            Defaults to the configured value `system.cli.refresh_seconds`.
133
134        _properties: Optional[Dict[str, Any]], default None
135            If provided, use this to patch the daemon's properties.
136        """
137        native = _native.core()
138        self._core = native.Job(
139            name,
140            sysargs,
141            env=env,
142            executor_keys=executor_keys,
143            delete_after_completion=delete_after_completion,
144            refresh_seconds=refresh_seconds,
145            properties=_properties,
146            externally_managed=_externally_managed,
147            api_attributes=_native.api_attributes(executor_keys),
148        )
149        _native.emit_warnings(self._core.take_warnings())
150        self.name = name
151        self.executor_keys = self._core.executor_keys
152        self.refresh_seconds = (
153            refresh_seconds
154            if refresh_seconds is not None
155            else mrsm.get_config('system', 'cli', 'refresh_seconds')
156        )
157        if _status_hook is not None:
158            self._status_hook = _status_hook
159
160        if _result_hook is not None:
161            self._result_hook = _result_hook
162
163        self._externally_managed = _externally_managed

Create a new job.

Parameters
  • name (str): The name of the job to be created. This will also be used as the Daemon ID.
  • sysargs (Union[List[str], str, None], default None): The sysargs of the command to be executed, e.g. 'start api'.
  • env (Optional[Dict[str, str]], default None): If provided, set these environment variables in the job's process.
  • executor_keys (Optional[str], default None): If provided, execute the job remotely on an API instance, e.g. 'api:main' (or with 'systemd'). Defaults to 'local', or the context's executor.
  • delete_after_completion (bool, default False): If True, delete this job when it has finished executing.
  • refresh_seconds (Union[int, float, None], default None): The number of seconds to sleep between refreshes. Defaults to the configured value system.cli.refresh_seconds.
  • _properties (Optional[Dict[str, Any]], default None): If provided, use this to patch the daemon's properties.
name
executor_keys
refresh_seconds
@staticmethod
def from_pid(pid: int, executor_keys: str | None = None) -> Job:
169    @staticmethod
170    def from_pid(pid: int, executor_keys: Optional[str] = None) -> Job:
171        """
172        Build a `Job` from the PID of a running Meerschaum process.
173
174        Parameters
175        ----------
176        pid: int
177            The PID of the process.
178
179        executor_keys: Optional[str], default None
180            The executor keys to assign to the job.
181        """
182        import os
183        try:
184            os.kill(pid, 0)
185        except ProcessLookupError as e:
186            warn(f"Process with PID {pid} does not exist.", stack=False)
187            raise e
188        except PermissionError:
189            pass
190
191        native = _native.core()
192        daemon_ids = native.jobs_daemon_ids()
193        for daemon_id in daemon_ids:
194            job = Job(daemon_id, executor_keys=executor_keys)
195            if job.pid == pid:
196                return job
197
198        cmdline_path = pathlib.Path('/proc') / str(pid) / 'cmdline'
199        if not cmdline_path.exists():
200            raise EnvironmentError(f"Is process {pid} a Meerschaum job?")
201
202        command_args = [
203            arg
204            for arg in cmdline_path.read_bytes().decode('utf-8', errors='replace').split('\0')
205            if arg
206        ]
207        # During startup the PID may change from the supervisor to its worker between
208        # the caller's PID read and the registry lookup above. Scope its persistent marker
209        # to this registry: another root may have a different Job with the same name.
210        if '--jobs-dir' in command_args:
211            import meerschaum.config.paths as paths
212            index = command_args.index('--jobs-dir') + 1
213            if (
214                index >= len(command_args)
215                or pathlib.Path(command_args[index]).resolve() != paths.DAEMON_RESOURCES_PATH.resolve()
216            ):
217                raise EnvironmentError(f"Process {pid} belongs to another Meerschaum jobs directory.")
218            for arg in command_args:
219                if arg.startswith("daemon_id='") and arg.endswith("'"):
220                    daemon_id = arg[len("daemon_id='"):-1]
221                    if daemon_id in daemon_ids:
222                        return Job(daemon_id, executor_keys=executor_keys)
223            if '__mrsm_job_supervisor__' in command_args or '__mrsm_run_daemon__' in command_args:
224                if '--daemon-id' in command_args:
225                    index = command_args.index('--daemon-id') + 1
226                    if index < len(command_args) and command_args[index] in daemon_ids:
227                        return Job(command_args[index], executor_keys=executor_keys)
228
229        if len(command_args) > 1 and command_args[1] == '-c':
230            raise EnvironmentError(f"Is process {pid} a running job?")
231
232        mrsm_ix = 0
233        for i, arg in enumerate(command_args):
234            if 'mrsm' in arg or 'meerschaum' in arg.lower():
235                mrsm_ix = i
236                break
237
238        sysargs = command_args[mrsm_ix + 1:]
239        name = None
240        for flag in ('--name', '--job-name'):
241            if flag in sysargs and sysargs.index(flag) + 1 < len(sysargs):
242                name = sysargs[sysargs.index(flag) + 1]
243                break
244        if name is None:
245            name = native.jobs_new_daemon_name()
246        return Job(name, sysargs, executor_keys=executor_keys)

Build a Job from the PID of a running Meerschaum process.

Parameters
  • pid (int): The PID of the process.
  • executor_keys (Optional[str], default None): The executor keys to assign to the job.
def start(self, debug: bool = False) -> Tuple[bool, str]:
248    def start(self, debug: bool = False) -> SuccessTuple:
249        """
250        Start the job's daemon.
251        """
252        result = self._core.start()
253        self._warn()
254        return result

Start the job's daemon.

def stop( self, timeout_seconds: int | float | None = None, debug: bool = False) -> Tuple[bool, str]:
256    def stop(
257        self,
258        timeout_seconds: Union[int, float, None] = None,
259        debug: bool = False,
260    ) -> SuccessTuple:
261        """
262        Stop the job's daemon.
263        """
264        result = self._core.stop(timeout_seconds)
265        self._warn()
266        return result

Stop the job's daemon.

def pause( self, timeout_seconds: int | float | None = None, debug: bool = False) -> Tuple[bool, str]:
268    def pause(
269        self,
270        timeout_seconds: Union[int, float, None] = None,
271        debug: bool = False,
272    ) -> SuccessTuple:
273        """
274        Pause the job's daemon.
275        """
276        result = self._core.pause(timeout_seconds)
277        self._warn()
278        return result

Pause the job's daemon.

def resume( self, timeout_seconds: int | float | None = None, debug: bool = False) -> Tuple[bool, str]:
280    def resume(
281        self,
282        timeout_seconds: Union[int, float, None] = None,
283        debug: bool = False,
284    ) -> SuccessTuple:
285        """
286        Resume a paused job.
287        """
288        result = self._core.resume(timeout_seconds)
289        self._warn()
290        return result

Resume a paused job.

def delete(self, debug: bool = False) -> Tuple[bool, str]:
292    def delete(self, debug: bool = False) -> SuccessTuple:
293        """
294        Delete the job and its daemon.
295        """
296        result = self._core.delete()
297        self._warn()
298        return result

Delete the job and its daemon.

def is_running(self) -> bool:
300    def is_running(self) -> bool:
301        """
302        Determine whether the job's daemon is running.
303        """
304        return self.status == 'running'

Determine whether the job's daemon is running.

def exists(self, debug: bool = False) -> bool:
306    def exists(self, debug: bool = False) -> bool:
307        """
308        Determine whether the job exists.
309        """
310        return self._core.exists()

Determine whether the job exists.

def get_logs(self) -> str | None:
312    def get_logs(self) -> Union[str, None]:
313        """
314        Return the output text of the job's daemon.
315        """
316        return self._core.get_logs()

Return the output text of the job's daemon.

def monitor_logs( self, callback_function: Callable[[str], NoneType] = <function _default_stdout_callback>, input_callback_function: Callable[[], str] | None = None, stop_callback_function: Callable[[Tuple[bool, str]], NoneType] | None = None, stop_event: asyncio.locks.Event | None = None, stop_on_exit: bool = False, strip_timestamps: bool = False, accept_input: bool = True, debug: bool = False, _wait_if_stopped: bool = True):
318    def monitor_logs(
319        self,
320        callback_function: Callable[[str], None] = _default_stdout_callback,
321        input_callback_function: Optional[Callable[[], str]] = None,
322        stop_callback_function: Optional[Callable[[SuccessTuple], None]] = None,
323        stop_event: Optional[asyncio.Event] = None,
324        stop_on_exit: bool = False,
325        strip_timestamps: bool = False,
326        accept_input: bool = True,
327        debug: bool = False,
328        _wait_if_stopped: bool = True,
329    ):
330        """
331        Monitor the job's log files and execute a callback on new lines.
332
333        Parameters
334        ----------
335        callback_function: Callable[[str], None], default partial(print, end='')
336            The callback to execute as new data comes in.
337            Defaults to printing the output directly to `stdout`.
338
339        input_callback_function: Optional[Callable[[], str]], default None
340            If provided, execute this callback when the daemon is blocking on stdin.
341            Defaults to `sys.stdin.readline()`.
342
343        stop_callback_function: Optional[Callable[[SuccessTuple]], str], default None
344            If provided, execute this callback when the daemon stops.
345            The job's SuccessTuple will be passed to the callback.
346
347        stop_event: Optional[asyncio.Event], default None
348            If provided, stop monitoring when this event is set.
349            You may instead raise `meerschaum.jobs.StopMonitoringLogs`
350            from within `callback_function` to stop monitoring.
351
352        stop_on_exit: bool, default False
353            If `True`, stop monitoring when the job stops.
354
355        strip_timestamps: bool, default False
356            If `True`, remove leading timestamps from lines.
357
358        accept_input: bool, default True
359            If `True`, accept input when the daemon blocks on stdin.
360        """
361        return self._monitor(
362            callback_function,
363            input_callback_function,
364            stop_callback_function,
365            stop_event,
366            stop_on_exit,
367            strip_timestamps,
368            accept_input,
369            _wait_if_stopped,
370            loop=None,
371            stop_flag=None,
372        )

Monitor the job's log files and execute a callback on new lines.

Parameters
  • callback_function (Callable[[str], None], default partial(print, end='')): The callback to execute as new data comes in. Defaults to printing the output directly to stdout.
  • input_callback_function (Optional[Callable[[], str]], default None): If provided, execute this callback when the daemon is blocking on stdin. Defaults to sys.stdin.readline().
  • stop_callback_function (Optional[Callable[[SuccessTuple]], str], default None): If provided, execute this callback when the daemon stops. The job's SuccessTuple will be passed to the callback.
  • stop_event (Optional[asyncio.Event], default None): If provided, stop monitoring when this event is set. You may instead raise meerschaum.jobs.StopMonitoringLogs from within callback_function to stop monitoring.
  • stop_on_exit (bool, default False): If True, stop monitoring when the job stops.
  • strip_timestamps (bool, default False): If True, remove leading timestamps from lines.
  • accept_input (bool, default True): If True, accept input when the daemon blocks on stdin.
async def monitor_logs_async( self, callback_function: Callable[[str], NoneType] = <function _default_stdout_callback>, input_callback_function: Callable[[], str] | None = None, stop_callback_function: Callable[[Tuple[bool, str]], NoneType] | None = None, stop_event: asyncio.locks.Event | None = None, stop_on_exit: bool = False, strip_timestamps: bool = False, accept_input: bool = True, debug: bool = False, _wait_if_stopped: bool = True):
374    async def monitor_logs_async(
375        self,
376        callback_function: Callable[[str], None] = _default_stdout_callback,
377        input_callback_function: Optional[Callable[[], str]] = None,
378        stop_callback_function: Optional[Callable[[SuccessTuple], None]] = None,
379        stop_event: Optional[asyncio.Event] = None,
380        stop_on_exit: bool = False,
381        strip_timestamps: bool = False,
382        accept_input: bool = True,
383        debug: bool = False,
384        _wait_if_stopped: bool = True,
385    ):
386        """
387        Monitor the job's log files and await a callback on new lines.
388
389        Parameters
390        ----------
391        callback_function: Callable[[str], None], default _default_stdout_callback
392            The callback to execute as new data comes in.
393            Defaults to printing the output directly to `stdout`.
394
395        input_callback_function: Optional[Callable[[], str]], default None
396            If provided, execute this callback when the daemon is blocking on stdin.
397            Defaults to `sys.stdin.readline()`.
398
399        stop_callback_function: Optional[Callable[[SuccessTuple]], str], default None
400            If provided, execute this callback when the daemon stops.
401            The job's SuccessTuple will be passed to the callback.
402
403        stop_event: Optional[asyncio.Event], default None
404            If provided, stop monitoring when this event is set.
405            You may instead raise `meerschaum.jobs.StopMonitoringLogs`
406            from within `callback_function` to stop monitoring.
407
408        stop_on_exit: bool, default False
409            If `True`, stop monitoring when the job stops.
410
411        strip_timestamps: bool, default False
412            If `True`, remove leading timestamps from lines.
413
414        accept_input: bool, default True
415            If `True`, accept input when the daemon blocks on stdin.
416        """
417        loop = asyncio.get_running_loop()
418        stop_flag = _native.core().StopFlag()
419        run = functools.partial(
420            self._monitor,
421            callback_function,
422            input_callback_function,
423            stop_callback_function,
424            stop_event,
425            stop_on_exit,
426            strip_timestamps,
427            accept_input,
428            _wait_if_stopped,
429            loop=loop,
430            stop_flag=stop_flag,
431        )
432        try:
433            await loop.run_in_executor(None, run)
434        except asyncio.CancelledError:
435            stop_flag.set()
436            raise
437        finally:
438            stop_flag.set()

Monitor the job's log files and await a callback on new lines.

Parameters
  • callback_function (Callable[[str], None], default _default_stdout_callback): The callback to execute as new data comes in. Defaults to printing the output directly to stdout.
  • input_callback_function (Optional[Callable[[], str]], default None): If provided, execute this callback when the daemon is blocking on stdin. Defaults to sys.stdin.readline().
  • stop_callback_function (Optional[Callable[[SuccessTuple]], str], default None): If provided, execute this callback when the daemon stops. The job's SuccessTuple will be passed to the callback.
  • stop_event (Optional[asyncio.Event], default None): If provided, stop monitoring when this event is set. You may instead raise meerschaum.jobs.StopMonitoringLogs from within callback_function to stop monitoring.
  • stop_on_exit (bool, default False): If True, stop monitoring when the job stops.
  • strip_timestamps (bool, default False): If True, remove leading timestamps from lines.
  • accept_input (bool, default True): If True, accept input when the daemon blocks on stdin.
def is_blocking_on_stdin(self, debug: bool = False) -> bool:
493    def is_blocking_on_stdin(self, debug: bool = False) -> bool:
494        """
495        Return whether a job's daemon is blocking on stdin.
496        """
497        return self._core.is_blocking_on_stdin()

Return whether a job's daemon is blocking on stdin.

def get_prompt_kwargs(self, debug: bool = False) -> Dict[str, Any]:
499    def get_prompt_kwargs(self, debug: bool = False) -> Dict[str, Any]:
500        """
501        Return the kwargs to the blocking `prompt()`, if available.
502        """
503        return self._core.get_prompt_kwargs()

Return the kwargs to the blocking prompt(), if available.

def write_stdin(self, data):
505    def write_stdin(self, data):
506        """
507        Write to a job's daemon's `stdin`.
508        """
509        self._core.write_stdin(data)

Write to a job's daemon's stdin.

executor: Executor | None
511    @property
512    def executor(self) -> Union[Executor, None]:
513        """
514        If the job is not local, return the connector to its executor
515        (the remote API instance, `systemd`, a plugin's executor).
516        """
517        if self.executor_keys == 'local':
518            return None
519        from meerschaum.jobs._native import is_python_executor_type
520        if is_python_executor_type(self.executor_keys.split(':', maxsplit=1)[0]):
521            return mrsm.get_connector(self.executor_keys)
522        ### An executor type of a native (Rust) plugin: no Python connector exists.
523        from meerschaum.jobs._NativeExecutor import NativeExecutor
524        return NativeExecutor(self.executor_keys)

If the job is not local, return the connector to its executor (the remote API instance, systemd, a plugin's executor).

status: str
526    @property
527    def status(self) -> str:
528        """
529        Return the running status of the job's daemon.
530        """
531        if '_status_hook' in self.__dict__:
532            return self._status_hook()
533
534        return self._core.status

Return the running status of the job's daemon.

pid: int | None
536    @property
537    def pid(self) -> Union[int, None]:
538        """
539        Return the PID of the job's dameon.
540        """
541        return self._core.pid

Return the PID of the job's dameon.

restart: bool
543    @property
544    def restart(self) -> bool:
545        """
546        Return whether to restart a stopped job.
547        """
548        return self._core.restarts

Return whether to restart a stopped job.

result: Tuple[bool, str]
550    @property
551    def result(self) -> SuccessTuple:
552        """
553        Return the `SuccessTuple` when the job has terminated.
554        """
555        if '_result_hook' in self.__dict__:
556            if self.is_running():
557                return True, f"{self} is running."
558            return self._result_hook()
559
560        return self._core.result

Return the SuccessTuple when the job has terminated.

sysargs: List[str]
562    @property
563    def sysargs(self) -> List[str]:
564        """
565        Return the sysargs to use for the Daemon.
566        """
567        return self._core.sysargs

Return the sysargs to use for the Daemon.

health: Dict[str, Any]
569    @property
570    def health(self) -> Dict[str, Any]:
571        """
572        Return how the job's laps go (a job that repeats its work: `--loop`, `-s`, `--listen`).
573
574        Returns
575        -------
576        A dictionary with the keys:
577
578        - `state`: `'healthy'` (the last lap succeeded), `'failing'` (the last lap failed),
579          `'stalled'` (no lap for three times the expected interval, and at least a minute;
580          an hour without a sync for `--listen`; at least an hour for a first lap),
581          `'stopped'` (not running, or paused) or `'unknown'` (nothing recorded yet, e.g.
582          `'first sync in progress'`).
583        - `last_success`, `last_lap`: ISO-8601 UTC timestamps (or `None`).
584        - `consecutive_failures`: laps that failed in a row.
585        - `expected_interval_seconds`: how often laps are expected (`--min-seconds`, the
586          schedule's period, an hour for `--listen`), or `None`.
587        - `next_run`: while a running job waits for its next lap (`--min-seconds`, a cooldown
588          after a failure, a schedule's next fire), when that lap is due (ISO-8601 UTC);
589          otherwise `None`.
590        - `message`: what to show next to the state, e.g. `'last good sync 2 min ago'`.
591
592        Examples
593        --------
594        >>> job = Job('weather', 'sync pipes -c plugin:noaa --loop')
595        >>> success, msg = job.start()
596        >>> job.health['state']  # Before its first lap ends.
597        'unknown'
598        """
599        return self._core.health()

Return how the job's laps go (a job that repeats its work: --loop, -s, --listen).

Returns
  • A dictionary with the keys:
  • - state ('healthy' (the last lap succeeded), 'failing' (the last lap failed),): 'stalled' (no lap for three times the expected interval, and at least a minute; an hour without a sync for --listen; at least an hour for a first lap), 'stopped' (not running, or paused) or 'unknown' (nothing recorded yet, e.g. 'first sync in progress').
  • - last_success, last_lap (ISO-8601 UTC timestamps (or None).):

  • - consecutive_failures (laps that failed in a row.):

  • - expected_interval_seconds (how often laps are expected (--min-seconds, the): schedule's period, an hour for --listen), or None.

  • - next_run (while a running job waits for its next lap (--min-seconds, a cooldown): after a failure, a schedule's next fire), when that lap is due (ISO-8601 UTC); otherwise None.
  • - message (what to show next to the state, e.g. 'last good sync 2 min ago'.):
Examples
>>> job = Job('weather', 'sync pipes -c plugin:noaa --loop')
>>> success, msg = job.start()
>>> job.health['state']  # Before its first lap ends.
'unknown'
owner: str | None
601    @property
602    def owner(self) -> Union[str, None]:
603        """
604        Return the user who started this job through the API server or the web console
605        (`None` for a job created locally: the CLI, Compose, systemd or Python). A job started
606        by another job has that job's owner. For an `api:` job, the server's record.
607
608        Examples
609        --------
610        >>> Job('nightly', executor_keys='api:main').owner
611        'alice'
612        """
613        return self._core.access().get('owner', None)

Return the user who started this job through the API server or the web console (None for a job created locally: the CLI, Compose, systemd or Python). A job started by another job has that job's owner. For an api: job, the server's record.

Examples
>>> Job('nightly', executor_keys='api:main').owner
'alice'
team: str | None
615    @property
616    def team(self) -> Union[str, None]:
617        """
618        Return the team this job belongs to: the primary team of the user who started it through
619        the API server or the web console (a job started by another job: that job's team).
620        `None` means Everyone (every local job).
621
622        Members of the team (and administrators) see the job and, with the job permissions,
623        control it. Renaming the team renames it here; deleting the team moves the job to
624        Everyone.
625        """
626        return self._core.access().get('team', None)

Return the team this job belongs to: the primary team of the user who started it through the API server or the web console (a job started by another job: that job's team). None means Everyone (every local job).

Members of the team (and administrators) see the job and, with the job permissions, control it. Renaming the team renames it here; deleting the team moves the job to Everyone.

def get_daemon_properties(self) -> Dict[str, Any]:
628    def get_daemon_properties(self) -> Dict[str, Any]:
629        """
630        Return the `properties` dictionary for the job's daemon.
631        """
632        return self._core.get_daemon_properties()

Return the properties dictionary for the job's daemon.

daemon: meerschaum.utils.daemon.Daemon
634    @property
635    def daemon(self) -> 'Daemon':
636        """
637        Return the daemon which this job manages.
638        """
639        from meerschaum.utils.daemon import Daemon
640        if self.executor_keys == 'local' and self.exists():
641            return Daemon(daemon_id=self.name)
642
643        return Daemon(
644            daemon_id=self.name,
645            sysargs=self.sysargs,
646            label=shlex.join(self.sysargs),
647            properties=self.get_daemon_properties(),
648        )

Return the daemon which this job manages.

began: datetime.datetime | None
650    @property
651    def began(self) -> Union[datetime, None]:
652        """
653        The datetime when the job began running.
654        """
655        return _parse_dt(self._core.began)

The datetime when the job began running.

ended: datetime.datetime | None
657    @property
658    def ended(self) -> Union[datetime, None]:
659        """
660        The datetime when the job stopped running.
661        """
662        return _parse_dt(self._core.ended)

The datetime when the job stopped running.

paused: datetime.datetime | None
664    @property
665    def paused(self) -> Union[datetime, None]:
666        """
667        The datetime when the job was suspended while running.
668        """
669        return _parse_dt(self._core.paused)

The datetime when the job was suspended while running.

stop_time: datetime.datetime | None
671    @property
672    def stop_time(self) -> Union[datetime, None]:
673        """
674        Return the timestamp when the job was manually stopped.
675        """
676        return _parse_dt(self._core.stop_time)

Return the timestamp when the job was manually stopped.

hidden: bool
678    @property
679    def hidden(self) -> bool:
680        """
681        Return a bool indicating whether this job should be displayed.
682        """
683        return self._core.hidden

Return a bool indicating whether this job should be displayed.

def check_restart(self) -> Tuple[bool, str]:
685    def check_restart(self) -> SuccessTuple:
686        """
687        If `restart` is `True` and the daemon is not running,
688        restart the job.
689        Do not restart if the job was manually stopped.
690        """
691        result = self._core.check_restart()
692        self._warn()
693        return result

If restart is True and the daemon is not running, restart the job. Do not restart if the job was manually stopped.

label: str
695    @property
696    def label(self) -> str:
697        """
698        Return the job's Daemon label (joined sysargs).
699        """
700        return self._core.label

Return the job's Daemon label (joined sysargs).

env: Dict[str, str]
723    @property
724    def env(self) -> Dict[str, str]:
725        """
726        Return the environment variables to set for the job's process.
727        """
728        return self._core.env

Return the environment variables to set for the job's process.

delete_after_completion: bool
730    @property
731    def delete_after_completion(self) -> bool:
732        """
733        Return whether this job is configured to delete itself after completion.
734        """
735        return self._core.delete_after_completion

Return whether this job is configured to delete itself after completion.

class StopMonitoringLogs(builtins.Exception):
50class StopMonitoringLogs(Exception):
51    """
52    Raise this exception to stop the logs monitoring.
53    """

Raise this exception to stop the logs monitoring.

def get_jobs( executor_keys: str | None = None, include_hidden: bool = False, combine_local_and_systemd: bool = True, debug: bool = False) -> Dict[str, Job]:
125def get_jobs(
126    executor_keys: Optional[str] = None,
127    include_hidden: bool = False,
128    combine_local_and_systemd: bool = True,
129    debug: bool = False,
130) -> Dict[str, Job]:
131    """
132    Return a dictionary of the existing jobs.
133
134    Parameters
135    ----------
136    executor_keys: Optional[str], default None
137        If provided, return remote jobs on the given API instance.
138        Otherwise return local jobs.
139
140    include_hidden: bool, default False
141        If `True`, include jobs with the `hidden` attribute.
142
143    Returns
144    -------
145    A dictionary mapping job names to jobs.
146    """
147    from meerschaum.utils.warnings import warn
148    native = _native.core()
149    try:
150        rows, warnings = native.jobs_get_jobs(
151            executor_keys,
152            include_hidden,
153            combine_local_and_systemd,
154            api_attributes=_native.api_attributes(executor_keys),
155        )
156    except RuntimeError as e:
157        ### An unreachable or unauthorized executor is not "no jobs".
158        warn(f"Could not get the jobs of executor '{executor_keys}': {e}", stack=False)
159        return {}
160
161    _native.emit_warnings(warnings)
162    return _rows_to_jobs(rows)

Return a dictionary of the existing jobs.

Parameters
  • executor_keys (Optional[str], default None): If provided, return remote jobs on the given API instance. Otherwise return local jobs.
  • include_hidden (bool, default False): If True, include jobs with the hidden attribute.
Returns
  • A dictionary mapping job names to jobs.
def get_filtered_jobs( executor_keys: str | None = None, filter_list: List[str] | None = None, include_hidden: bool = False, combine_local_and_systemd: bool = True, warn: bool = False, debug: bool = False) -> Dict[str, Job]:
165def get_filtered_jobs(
166    executor_keys: Optional[str] = None,
167    filter_list: Optional[List[str]] = None,
168    include_hidden: bool = False,
169    combine_local_and_systemd: bool = True,
170    warn: bool = False,
171    debug: bool = False,
172) -> Dict[str, Job]:
173    """
174    Return a list of jobs filtered by the user.
175    """
176    from meerschaum.utils.warnings import warn as _warn
177    native = _native.core()
178    try:
179        rows, listing_warnings, missing = native.jobs_get_filtered_jobs(
180            executor_keys,
181            filter_list or [],
182            include_hidden,
183            combine_local_and_systemd,
184            api_attributes=_native.api_attributes(executor_keys),
185        )
186    except RuntimeError as e:
187        _warn(f"Could not get the jobs of executor '{executor_keys}': {e}", stack=False)
188        return {}
189
190    _native.emit_warnings(listing_warnings)
191    if warn:
192        _native.emit_warnings(missing)
193    return _rows_to_jobs(rows)

Return a list of jobs filtered by the user.

def get_restart_jobs( executor_keys: str | None = None, jobs: Dict[str, Job] | None = None, include_hidden: bool = False, combine_local_and_systemd: bool = True, debug: bool = False) -> Dict[str, Job]:
196def get_restart_jobs(
197    executor_keys: Optional[str] = None,
198    jobs: Optional[Dict[str, Job]] = None,
199    include_hidden: bool = False,
200    combine_local_and_systemd: bool = True,
201    debug: bool = False,
202) -> Dict[str, Job]:
203    """
204    Return jobs which were created with `--restart` or `--loop`.
205    """
206    if jobs is None:
207        jobs = get_jobs(
208            executor_keys,
209            include_hidden=include_hidden,
210            combine_local_and_systemd=combine_local_and_systemd,
211            debug=debug,
212        )
213
214    return {
215        name: job
216        for name, job in jobs.items()
217        if job.restart
218    }

Return jobs which were created with --restart or --loop.

def get_running_jobs( executor_keys: str | None = None, jobs: Dict[str, Job] | None = None, include_hidden: bool = False, combine_local_and_systemd: bool = True, debug: bool = False) -> Dict[str, Job]:
221def get_running_jobs(
222    executor_keys: Optional[str] = None,
223    jobs: Optional[Dict[str, Job]] = None,
224    include_hidden: bool = False,
225    combine_local_and_systemd: bool = True,
226    debug: bool = False,
227) -> Dict[str, Job]:
228    """
229    Return a dictionary of running jobs.
230    """
231    if jobs is None:
232        jobs = get_jobs(
233            executor_keys,
234            include_hidden=include_hidden,
235            combine_local_and_systemd=combine_local_and_systemd,
236            debug=debug,
237        )
238
239    return {
240        name: job
241        for name, job in jobs.items()
242        if job.status == 'running'
243    }

Return a dictionary of running jobs.

def get_stopped_jobs( executor_keys: str | None = None, jobs: Dict[str, Job] | None = None, include_hidden: bool = False, combine_local_and_systemd: bool = True, debug: bool = False) -> Dict[str, Job]:
271def get_stopped_jobs(
272    executor_keys: Optional[str] = None,
273    jobs: Optional[Dict[str, Job]] = None,
274    include_hidden: bool = False,
275    combine_local_and_systemd: bool = True,
276    debug: bool = False,
277) -> Dict[str, Job]:
278    """
279    Return a dictionary of stopped jobs.
280    """
281    if jobs is None:
282        jobs = get_jobs(
283            executor_keys,
284            include_hidden=include_hidden,
285            combine_local_and_systemd=combine_local_and_systemd,
286            debug=debug,
287        )
288
289    return {
290        name: job
291        for name, job in jobs.items()
292        if job.status == 'stopped'
293    }

Return a dictionary of stopped jobs.

def get_paused_jobs( executor_keys: str | None = None, jobs: Dict[str, Job] | None = None, include_hidden: bool = False, combine_local_and_systemd: bool = True, debug: bool = False) -> Dict[str, Job]:
246def get_paused_jobs(
247    executor_keys: Optional[str] = None,
248    jobs: Optional[Dict[str, Job]] = None,
249    include_hidden: bool = False,
250    combine_local_and_systemd: bool = True,
251    debug: bool = False,
252) -> Dict[str, Job]:
253    """
254    Return a dictionary of paused jobs.
255    """
256    if jobs is None:
257        jobs = get_jobs(
258            executor_keys,
259            include_hidden=include_hidden,
260            combine_local_and_systemd=combine_local_and_systemd,
261            debug=debug,
262        )
263
264    return {
265        name: job
266        for name, job in jobs.items()
267        if job.status == 'paused'
268    }

Return a dictionary of paused jobs.

def get_executor_keys_from_context() -> str:
427def get_executor_keys_from_context() -> str:
428    """
429    Return the configured executor (`meerschaum:executor`).
430    Otherwise, on a host with `systemd`, return `'systemd'` (for any root directory) unless
431    Meerschaum runs from a source checkout or a development build.
432    Otherwise return `'local'`.
433    """
434    return _native.core().jobs_executor_keys_from_context()

Return the configured executor (meerschaum:executor). Otherwise, on a host with systemd, return 'systemd' (for any root directory) unless Meerschaum runs from a source checkout or a development build. Otherwise return 'local'.

def make_executor(cls):
296def make_executor(cls):
297    """
298    Register a class as an `Executor`.
299    """
300    import re
301    from meerschaum.connectors import make_connector
302    suffix_regex = r'executor$'
303    typ = re.sub(suffix_regex, '', cls.__name__.lower())
304    if typ not in executor_types:
305        executor_types.append(typ)
306    return make_connector(cls, _is_executor=True)

Register a class as an Executor.

class Executor(meerschaum.connectors._Connector.Connector):
 22class Executor(Connector):
 23    """
 24    Define the methods for managing jobs.
 25    """
 26
 27    @abstractmethod
 28    def get_job_names(self, debug: bool = False) -> List[str]:
 29        """
 30        Return a list of existing jobs, including hidden ones.
 31        """
 32
 33    @abstractmethod
 34    def get_job_exists(self, name: str, debug: bool = False) -> bool:
 35        """
 36        Return whether a job exists.
 37        """
 38
 39    @abstractmethod
 40    def get_jobs(self, debug: bool = False) -> Dict[str, Job]:
 41        """
 42        Return a dictionary of existing jobs.
 43        """
 44
 45    @abstractmethod
 46    def get_job_metadata(self, name: str, debug: bool = False) -> Dict[str, Any]:
 47        """
 48        Return a job's metadata.
 49        """
 50
 51    @abstractmethod
 52    def get_job_properties(self, name: str, debug: bool = False) -> Dict[str, Any]:
 53        """
 54        Return the underlying daemon's properties.
 55        """
 56    @abstractmethod
 57    def get_job_status(self, name: str, debug: bool = False) -> str:
 58        """
 59        Return the job's status.
 60        """
 61
 62    @abstractmethod
 63    def get_job_began(self, name: str, debug: bool = False) -> Union[str, None]:
 64        """
 65        Return when a job began running.
 66        """
 67
 68    @abstractmethod
 69    def get_job_ended(self, name: str, debug: bool = False) -> Union[str, None]:
 70        """
 71        Return when a job stopped running.
 72        """
 73
 74    @abstractmethod
 75    def get_job_paused(self, name: str, debug: bool = False) -> Union[str, None]:
 76        """
 77        Return a job's `paused` timestamp, if it exists.
 78        """
 79    
 80    @abstractmethod
 81    def create_job(
 82        self,
 83        name: str,
 84        sysargs: List[str],
 85        properties: Optional[Dict[str, Any]] = None,
 86        debug: bool = False,
 87    ) -> SuccessTuple:
 88        """
 89        Create a new job.
 90        """
 91
 92    @abstractmethod
 93    def start_job(self, name: str, debug: bool = False) -> SuccessTuple:
 94        """
 95        Start a job.
 96        """
 97
 98    @abstractmethod
 99    def stop_job(self, name: str, debug: bool = False) -> SuccessTuple:
100        """
101        Stop a job.
102        """
103
104    @abstractmethod
105    def pause_job(self, name: str, debug: bool = False) -> SuccessTuple:
106        """
107        Pause a job.
108        """
109
110    @abstractmethod
111    def delete_job(self, name: str, debug: bool = False) -> SuccessTuple:
112        """
113        Delete a job.
114        """
115
116    @abstractmethod
117    def get_logs(self, name: str, debug: bool = False) -> str:
118        """
119        Return a job's log output.
120        """
121
122    @abstractmethod
123    def get_job_stop_time(self, name: str, debug: bool = False) -> Union[datetime, None]:
124        """
125        Return the job's manual stop time.
126        """
127
128    @abstractmethod
129    async def monitor_logs_async(
130        self,
131        name: str,
132        callback_function: Callable[[Any], Any],
133        input_callback_function: Callable[[], str],
134        stop_callback_function: Callable[[SuccessTuple], str],
135        stop_on_exit: bool = False,
136        strip_timestamps: bool = False,
137        accept_input: bool = True,
138        debug: bool = False,
139    ):
140        """
141        Monitor a job's log files and await a callback with the changes.
142        """
143
144    @abstractmethod
145    def monitor_logs(self, *args, **kwargs):
146        """
147        Monitor a job's log files.
148        """
149
150    @abstractmethod
151    def get_job_is_blocking_on_stdin(self, name: str, debug: bool = False) -> bool:
152        """
153        Return whether a job is blocking on stdin.
154        """
155
156    @abstractmethod
157    def get_job_prompt_kwargs(self, name: str, debug: bool = False) -> Dict[str, Any]:
158        """
159        Return the kwargs to the blocking prompt.
160        """

Define the methods for managing jobs.

@abstractmethod
def get_job_names(self, debug: bool = False) -> List[str]:
27    @abstractmethod
28    def get_job_names(self, debug: bool = False) -> List[str]:
29        """
30        Return a list of existing jobs, including hidden ones.
31        """

Return a list of existing jobs, including hidden ones.

@abstractmethod
def get_job_exists(self, name: str, debug: bool = False) -> bool:
33    @abstractmethod
34    def get_job_exists(self, name: str, debug: bool = False) -> bool:
35        """
36        Return whether a job exists.
37        """

Return whether a job exists.

@abstractmethod
def get_jobs(self, debug: bool = False) -> Dict[str, Job]:
39    @abstractmethod
40    def get_jobs(self, debug: bool = False) -> Dict[str, Job]:
41        """
42        Return a dictionary of existing jobs.
43        """

Return a dictionary of existing jobs.

@abstractmethod
def get_job_metadata(self, name: str, debug: bool = False) -> Dict[str, Any]:
45    @abstractmethod
46    def get_job_metadata(self, name: str, debug: bool = False) -> Dict[str, Any]:
47        """
48        Return a job's metadata.
49        """

Return a job's metadata.

@abstractmethod
def get_job_properties(self, name: str, debug: bool = False) -> Dict[str, Any]:
51    @abstractmethod
52    def get_job_properties(self, name: str, debug: bool = False) -> Dict[str, Any]:
53        """
54        Return the underlying daemon's properties.
55        """

Return the underlying daemon's properties.

@abstractmethod
def get_job_status(self, name: str, debug: bool = False) -> str:
56    @abstractmethod
57    def get_job_status(self, name: str, debug: bool = False) -> str:
58        """
59        Return the job's status.
60        """

Return the job's status.

@abstractmethod
def get_job_began(self, name: str, debug: bool = False) -> str | None:
62    @abstractmethod
63    def get_job_began(self, name: str, debug: bool = False) -> Union[str, None]:
64        """
65        Return when a job began running.
66        """

Return when a job began running.

@abstractmethod
def get_job_ended(self, name: str, debug: bool = False) -> str | None:
68    @abstractmethod
69    def get_job_ended(self, name: str, debug: bool = False) -> Union[str, None]:
70        """
71        Return when a job stopped running.
72        """

Return when a job stopped running.

@abstractmethod
def get_job_paused(self, name: str, debug: bool = False) -> str | None:
74    @abstractmethod
75    def get_job_paused(self, name: str, debug: bool = False) -> Union[str, None]:
76        """
77        Return a job's `paused` timestamp, if it exists.
78        """

Return a job's paused timestamp, if it exists.

@abstractmethod
def create_job( self, name: str, sysargs: List[str], properties: Dict[str, Any] | None = None, debug: bool = False) -> Tuple[bool, str]:
80    @abstractmethod
81    def create_job(
82        self,
83        name: str,
84        sysargs: List[str],
85        properties: Optional[Dict[str, Any]] = None,
86        debug: bool = False,
87    ) -> SuccessTuple:
88        """
89        Create a new job.
90        """

Create a new job.

@abstractmethod
def start_job(self, name: str, debug: bool = False) -> Tuple[bool, str]:
92    @abstractmethod
93    def start_job(self, name: str, debug: bool = False) -> SuccessTuple:
94        """
95        Start a job.
96        """

Start a job.

@abstractmethod
def stop_job(self, name: str, debug: bool = False) -> Tuple[bool, str]:
 98    @abstractmethod
 99    def stop_job(self, name: str, debug: bool = False) -> SuccessTuple:
100        """
101        Stop a job.
102        """

Stop a job.

@abstractmethod
def pause_job(self, name: str, debug: bool = False) -> Tuple[bool, str]:
104    @abstractmethod
105    def pause_job(self, name: str, debug: bool = False) -> SuccessTuple:
106        """
107        Pause a job.
108        """

Pause a job.

@abstractmethod
def delete_job(self, name: str, debug: bool = False) -> Tuple[bool, str]:
110    @abstractmethod
111    def delete_job(self, name: str, debug: bool = False) -> SuccessTuple:
112        """
113        Delete a job.
114        """

Delete a job.

@abstractmethod
def get_logs(self, name: str, debug: bool = False) -> str:
116    @abstractmethod
117    def get_logs(self, name: str, debug: bool = False) -> str:
118        """
119        Return a job's log output.
120        """

Return a job's log output.

@abstractmethod
def get_job_stop_time(self, name: str, debug: bool = False) -> datetime.datetime | None:
122    @abstractmethod
123    def get_job_stop_time(self, name: str, debug: bool = False) -> Union[datetime, None]:
124        """
125        Return the job's manual stop time.
126        """

Return the job's manual stop time.

@abstractmethod
async def monitor_logs_async( self, name: str, callback_function: Callable[[Any], Any], input_callback_function: Callable[[], str], stop_callback_function: Callable[[Tuple[bool, str]], str], stop_on_exit: bool = False, strip_timestamps: bool = False, accept_input: bool = True, debug: bool = False):
128    @abstractmethod
129    async def monitor_logs_async(
130        self,
131        name: str,
132        callback_function: Callable[[Any], Any],
133        input_callback_function: Callable[[], str],
134        stop_callback_function: Callable[[SuccessTuple], str],
135        stop_on_exit: bool = False,
136        strip_timestamps: bool = False,
137        accept_input: bool = True,
138        debug: bool = False,
139    ):
140        """
141        Monitor a job's log files and await a callback with the changes.
142        """

Monitor a job's log files and await a callback with the changes.

@abstractmethod
def monitor_logs(self, *args, **kwargs):
144    @abstractmethod
145    def monitor_logs(self, *args, **kwargs):
146        """
147        Monitor a job's log files.
148        """

Monitor a job's log files.

@abstractmethod
def get_job_is_blocking_on_stdin(self, name: str, debug: bool = False) -> bool:
150    @abstractmethod
151    def get_job_is_blocking_on_stdin(self, name: str, debug: bool = False) -> bool:
152        """
153        Return whether a job is blocking on stdin.
154        """

Return whether a job is blocking on stdin.

@abstractmethod
def get_job_prompt_kwargs(self, name: str, debug: bool = False) -> Dict[str, Any]:
156    @abstractmethod
157    def get_job_prompt_kwargs(self, name: str, debug: bool = False) -> Dict[str, Any]:
158        """
159        Return the kwargs to the blocking prompt.
160        """

Return the kwargs to the blocking prompt.

def check_restart_jobs( executor_keys: str | None = 'local', jobs: Dict[str, Job] | None = None, include_hidden: bool = True, silent: bool = False, debug: bool = False) -> Tuple[bool, str]:
309def check_restart_jobs(
310    executor_keys: Optional[str] = 'local',
311    jobs: Optional[Dict[str, Job]] = None,
312    include_hidden: bool = True,
313    silent: bool = False,
314    debug: bool = False,
315) -> SuccessTuple:
316    """
317    Restart any stopped jobs which were created with `--restart`.
318
319    Parameters
320    ----------
321    executor_keys: Optional[str], default None
322        If provided, check jobs on the given remote API instance.
323        Otherwise check local jobs.
324
325    include_hidden: bool, default True
326        If `True`, include hidden jobs in the check.
327
328    silent: bool, default False
329        If `True`, do not print the restart success message.
330    """
331    from meerschaum.utils.misc import items_str
332
333    if jobs is None:
334        jobs = get_jobs(
335            executor_keys,
336            include_hidden=include_hidden,
337            combine_local_and_systemd=False,
338            debug=debug,
339        )
340
341    if not jobs:
342        return True, "No jobs to restart."
343
344    results = {}
345    for name, job in jobs.items():
346        check_success, check_msg = job.check_restart()
347        results[job.name] = (check_success, check_msg)
348        if not silent:
349            mrsm.pprint((check_success, check_msg))
350
351    success_names = [name for name, (check_success, check_msg) in results.items() if check_success]
352    fail_names = [name for name, (check_success, check_msg) in results.items() if not check_success]
353    success = len(success_names) == len(jobs)
354    msg = (
355        (
356            "Successfully restarted job"
357            + ('s' if len(success_names) != 1 else '')
358            + ' ' + items_str(success_names) + '.'
359        )
360        if success
361        else (
362            "Failed to restart job"
363            + ('s' if len(success_names) != 1 else '')
364            + ' ' + items_str(fail_names) + '.'
365        )
366    )
367    return success, msg

Restart any stopped jobs which were created with --restart.

Parameters
  • executor_keys (Optional[str], default None): If provided, check jobs on the given remote API instance. Otherwise check local jobs.
  • include_hidden (bool, default True): If True, include hidden jobs in the check.
  • silent (bool, default False): If True, do not print the restart success message.
def start_check_jobs_thread():
383def start_check_jobs_thread():
384    """
385    Start a thread to regularly monitor jobs.
386    """
387    import atexit
388    from functools import partial
389    from meerschaum.utils.threading import RepeatTimer
390    from meerschaum._internal.static import STATIC_CONFIG
391
392    global _check_loop_stop_thread
393    sleep_seconds = STATIC_CONFIG['jobs']['check_restart_seconds']
394
395    _check_loop_stop_thread = RepeatTimer(
396        sleep_seconds,
397        partial(
398            _check_restart_jobs_against_lock,
399            silent=True,
400        )
401    )
402    _check_loop_stop_thread.daemon = True
403    atexit.register(stop_check_jobs_thread)
404
405    _check_loop_stop_thread.start()
406    return _check_loop_stop_thread

Start a thread to regularly monitor jobs.

def stop_check_jobs_thread():
409def stop_check_jobs_thread():
410    """
411    Stop the job monitoring thread.
412    """
413    import meerschaum.config.paths as paths
414    from meerschaum.utils.warnings import warn
415    if _check_loop_stop_thread is None:
416        return
417
418    _check_loop_stop_thread.cancel()
419
420    try:
421        if paths.CHECK_JOBS_LOCK_PATH.exists():
422            paths.CHECK_JOBS_LOCK_PATH.unlink()
423    except Exception as e:
424        warn(f"Failed to remove check jobs lock file:\n{e}")

Stop the job monitoring thread.

def filter_visible_jobs( principal_or_user: Any, jobs: Dict[str, Any] | Iterable[Any], instance: Any = None) -> Dict[str, Any] | List[Any]:
175def filter_visible_jobs(
176    principal_or_user: Any,
177    jobs: Union[Dict[str, Any], Iterable[Any]],
178    instance: Any = None,
179) -> Union[Dict[str, Any], List[Any]]:
180    """
181    Return the jobs a person may see.
182
183    Administrators see every job and owners their own. A job started through the API server or
184    the console belongs to its owner's primary team, and its team's members see it; a local job
185    (the CLI, Compose, systemd) is Everyone's, and every user sees it. A console run is private:
186    its owner, the managers of its team and administrators.
187
188    Parameters
189    ----------
190    principal_or_user: Any
191        Who is asking: a `meerschaum.core.access.Principal`, the console's viewer, a dictionary
192        (`username`, `is_admin`, `teams`), a `meerschaum.core.User` or a username (read from
193        `instance`), or `None` (anonymous: only Everyone's jobs).
194
195    jobs: Union[Dict[str, Job], Iterable[Job]]
196        The jobs to filter, e.g. `meerschaum.jobs.get_jobs()`.
197
198    instance: Any, default None
199        The instance a `User` or a username is read from (default: the user's own, else the
200        configured instance). A user who cannot be read is no administrator and in no team.
201
202    Returns
203    -------
204    The visible jobs, in the same shape as `jobs` (a dictionary or a list).
205
206    Examples
207    --------
208    >>> from meerschaum.jobs import get_jobs, filter_visible_jobs
209    >>> visible = filter_visible_jobs(mrsm.User('bob'), get_jobs())
210    """
211    parts = _viewer_parts(principal_or_user, instance=instance)
212    if isinstance(jobs, dict):
213        return {name: job for name, job in jobs.items() if _can_see(parts, job)}
214    return [job for job in jobs if _can_see(parts, job)]

Return the jobs a person may see.

Administrators see every job and owners their own. A job started through the API server or the console belongs to its owner's primary team, and its team's members see it; a local job (the CLI, Compose, systemd) is Everyone's, and every user sees it. A console run is private: its owner, the managers of its team and administrators.

Parameters
  • principal_or_user (Any): Who is asking: a meerschaum.core.access.Principal, the console's viewer, a dictionary (username, is_admin, teams), a meerschaum.core.User or a username (read from instance), or None (anonymous: only Everyone's jobs).
  • jobs (Union[Dict[str, Job], Iterable[Job]]): The jobs to filter, e.g. meerschaum.jobs.get_jobs().
  • instance (Any, default None): The instance a User or a username is read from (default: the user's own, else the configured instance). A user who cannot be read is no administrator and in no team.
Returns
  • The visible jobs, in the same shape as jobs (a dictionary or a list).
Examples
>>> from meerschaum.jobs import get_jobs, filter_visible_jobs
>>> visible = filter_visible_jobs(mrsm.User('bob'), get_jobs())
def can_see_job(principal_or_user: Any, job: Any, instance: Any = None) -> bool:
168def can_see_job(principal_or_user: Any, job: Any, instance: Any = None) -> bool:
169    """
170    Return whether `principal_or_user` may see `job` (see `filter_visible_jobs`).
171    """
172    return _can_see(_viewer_parts(principal_or_user, instance=instance), job)

Return whether principal_or_user may see job (see filter_visible_jobs).

def job_access(job: Any) -> Dict[str, Any]:
140def job_access(job: Any) -> Dict[str, Any]:
141    """
142    Return who a job belongs to: `{'owner', 'team', 'private'}`.
143
144    `owner` is the user who started it through the API server or the console (`None` for a local
145    job), `team` its team (`None`: Everyone) and `private` whether it is a console run (visible to
146    its owner, its team's managers and administrators only). A `systemd` or `api:` job reports
147    what its executor recorded.
148    """
149    core = getattr(job, '_core', None)
150    if core is not None and hasattr(core, 'access'):
151        return core.access()
152    return _native.core().jobs_access_of(str(getattr(job, 'name', '')), _job_properties(job))

Return who a job belongs to: {'owner', 'team', 'private'}.

owner is the user who started it through the API server or the console (None for a local job), team its team (None: Everyone) and private whether it is a console run (visible to its owner, its team's managers and administrators only). A systemd or api: job reports what its executor recorded.

def ownership_properties(owner: str | None = None, team: str | None = None) -> Dict[str, Any]:
217def ownership_properties(owner: Optional[str] = None, team: Optional[str] = None) -> Dict[str, Any]:
218    """
219    Return the properties that record who started a job, to pass as `Job(..., _properties=...)`.
220
221    The API server and the console record the caller (`owner`) and their primary team (`team`;
222    `None` or `'Everyone'`: Everyone) on the jobs they start.
223
224    Examples
225    --------
226    >>> ownership_properties('alice', 'Data')
227    {'owner': 'alice', 'team': 'Data', 'mrsm_owner': 'alice'}
228    """
229    return _native.core().jobs_ownership_properties(owner, team)

Return the properties that record who started a job, to pass as Job(..., _properties=...).

The API server and the console record the caller (owner) and their primary team (team; None or 'Everyone': Everyone) on the jobs they start.

Examples
>>> ownership_properties('alice', 'Data')
{'owner': 'alice', 'team': 'Data', 'mrsm_owner': 'alice'}
def get_team_jobs(team: str) -> List[str]:
232def get_team_jobs(team: str) -> List[str]:
233    """
234    Return the names of this root's jobs (local and `systemd`) whose team is `team` (names compare
235    without case; `'Everyone'`: the jobs without a team), e.g. for a deletion's confirmation.
236    """
237    return list(_native.core().jobs_team_jobs(str(team)))

Return the names of this root's jobs (local and systemd) whose team is team (names compare without case; 'Everyone': the jobs without a team), e.g. for a deletion's confirmation.

def move_team_jobs(team: str, to: str | None = None) -> Tuple[bool, str]:
240def move_team_jobs(team: str, to: Optional[str] = None) -> SuccessTuple:
241    """
242    Move this root's jobs of `team` to the team `to` (a rename), or to Everyone (`to=None`, as
243    deleting the team does).
244
245    Renaming or deleting a team through `mrsm.Team`, the CLI, the API server or the console calls
246    this for the root where it runs.
247
248    Returns
249    -------
250    A `SuccessTuple` naming how many jobs moved (and which could not be updated).
251    """
252    moved, failed = _native.core().jobs_retag_team(str(team), to)
253    target = f"team '{to}'" if to and str(to).strip().lower() != EVERYONE.lower() else EVERYONE
254    msg = f"Moved {len(moved)} job{'' if len(moved) == 1 else 's'} to {target}."
255    if failed:
256        msg += " Could not update " + ', '.join(f"'{name}' ({why})" for name, why in failed) + '.'
257    return not failed, msg

Move this root's jobs of team to the team to (a rename), or to Everyone (to=None, as deleting the team does).

Renaming or deleting a team through mrsm.Team, the CLI, the API server or the console calls this for the root where it runs.

Returns
  • A SuccessTuple naming how many jobs moved (and which could not be updated).
def report_lap( success: bool = True, message: str = '', next_in: float | None = None) -> Tuple[bool, str]:
 58def report_lap(
 59    success: bool = True,
 60    message: str = '',
 61    next_in: Optional[float] = None,
 62) -> SuccessTuple:
 63    """
 64    Record a lap of a plugin action that repeats its work by itself (its function takes `loop`,
 65    so `--loop` is left to it): the job's health then shows how its laps go, and the Overview's
 66    Next runs when the next one is due.
 67
 68    Call it once at the end of each iteration. Outside a job (a foreground run, a script) it does
 69    nothing. An action without a `loop` parameter does not need it: with `--loop` or `-s`, the
 70    host runs it in laps and records them itself.
 71
 72    Parameters
 73    ----------
 74    success: bool, default True
 75        Whether this lap succeeded (a failed lap counts toward `consecutive_failures`).
 76
 77    message: str, default ''
 78        What the lap did, shown with the job's health.
 79
 80    next_in: Optional[float], default None
 81        Seconds until the next lap begins (the action's own `min_seconds`). It sets the cadence
 82        the job is expected at (stalled after three times it) and `job.health['next_run']`.
 83
 84    Returns
 85    -------
 86    A `SuccessTuple`: whether the lap was recorded.
 87
 88    Examples
 89    --------
 90    >>> import time
 91    >>> import meerschaum as mrsm
 92    >>> def heartbeat_ack(loop: bool = False, min_seconds: float = 20, **kwargs):
 93    ...     while True:
 94    ...         success, msg = ack_once()
 95    ...         mrsm.jobs.report_lap(success, msg, next_in=min_seconds)
 96    ...         if not loop:
 97    ...             return success, msg
 98    ...         time.sleep(min_seconds)
 99    """
100    reporter = _lap_reporter
101    if reporter is None:
102        return True, "Not running in a job's plugin action: the lap was not recorded."
103    lap = {'success': bool(success), 'message': str(message or '')}
104    if next_in is not None:
105        lap['next_in'] = float(next_in)
106    value = reporter(lap)
107    if isinstance(value, (list, tuple)) and len(value) == 2:
108        return bool(value[0]), str(value[1])
109    return False, f"Unexpected answer from the host: {value!r}"

Record a lap of a plugin action that repeats its work by itself (its function takes loop, so --loop is left to it): the job's health then shows how its laps go, and the Overview's Next runs when the next one is due.

Call it once at the end of each iteration. Outside a job (a foreground run, a script) it does nothing. An action without a loop parameter does not need it: with --loop or -s, the host runs it in laps and records them itself.

Parameters
  • success (bool, default True): Whether this lap succeeded (a failed lap counts toward consecutive_failures).
  • message (str, default ''): What the lap did, shown with the job's health.
  • next_in (Optional[float], default None): Seconds until the next lap begins (the action's own min_seconds). It sets the cadence the job is expected at (stalled after three times it) and job.health['next_run'].
Returns
  • A SuccessTuple (whether the lap was recorded.):
Examples
>>> import time
>>> import meerschaum as mrsm
>>> def heartbeat_ack(loop: bool = False, min_seconds: float = 20, **kwargs):
...     while True:
...         success, msg = ack_once()
...         mrsm.jobs.report_lap(success, msg, next_in=min_seconds)
...         if not loop:
...             return success, msg
...         time.sleep(min_seconds)