meerschaum 2.2.7__py3-none-any.whl → 2.3.0.dev3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- meerschaum/__init__.py +6 -1
- meerschaum/_internal/arguments/_parse_arguments.py +10 -3
- meerschaum/_internal/arguments/_parser.py +44 -15
- meerschaum/_internal/entry.py +22 -1
- meerschaum/_internal/shell/Shell.py +129 -31
- meerschaum/actions/__init__.py +8 -6
- meerschaum/actions/api.py +12 -12
- meerschaum/actions/attach.py +108 -0
- meerschaum/actions/delete.py +35 -26
- meerschaum/actions/show.py +119 -148
- meerschaum/actions/start.py +85 -75
- meerschaum/actions/stop.py +68 -39
- meerschaum/api/_events.py +18 -1
- meerschaum/api/_oauth2.py +2 -0
- meerschaum/api/_websockets.py +2 -2
- meerschaum/api/dash/jobs.py +5 -2
- meerschaum/api/routes/__init__.py +1 -0
- meerschaum/api/routes/_actions.py +122 -44
- meerschaum/api/routes/_jobs.py +371 -0
- meerschaum/api/routes/_pipes.py +5 -5
- meerschaum/config/_default.py +1 -0
- meerschaum/config/_paths.py +1 -0
- meerschaum/config/_shell.py +8 -3
- meerschaum/config/_version.py +1 -1
- meerschaum/config/static/__init__.py +10 -0
- meerschaum/connectors/__init__.py +9 -11
- meerschaum/connectors/api/APIConnector.py +18 -1
- meerschaum/connectors/api/_actions.py +60 -71
- meerschaum/connectors/api/_jobs.py +330 -0
- meerschaum/connectors/parse.py +23 -7
- meerschaum/plugins/__init__.py +89 -5
- meerschaum/utils/daemon/Daemon.py +255 -30
- meerschaum/utils/daemon/FileDescriptorInterceptor.py +5 -5
- meerschaum/utils/daemon/RotatingFile.py +10 -6
- meerschaum/utils/daemon/StdinFile.py +110 -0
- meerschaum/utils/daemon/__init__.py +13 -7
- meerschaum/utils/formatting/__init__.py +2 -1
- meerschaum/utils/formatting/_jobs.py +83 -54
- meerschaum/utils/formatting/_shell.py +6 -0
- meerschaum/utils/jobs/_Job.py +710 -0
- meerschaum/utils/jobs/__init__.py +245 -0
- meerschaum/utils/misc.py +18 -17
- meerschaum/utils/packages/_packages.py +2 -2
- meerschaum/utils/prompt.py +16 -8
- {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dev3.dist-info}/METADATA +9 -9
- {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dev3.dist-info}/RECORD +52 -46
- {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dev3.dist-info}/WHEEL +1 -1
- {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dev3.dist-info}/LICENSE +0 -0
- {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dev3.dist-info}/NOTICE +0 -0
- {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dev3.dist-info}/entry_points.txt +0 -0
- {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dev3.dist-info}/top_level.txt +0 -0
- {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dev3.dist-info}/zip-safe +0 -0
@@ -0,0 +1,710 @@
|
|
1
|
+
#! /usr/bin/env python3
|
2
|
+
# -*- coding: utf-8 -*-
|
3
|
+
# vim:fenc=utf-8
|
4
|
+
|
5
|
+
"""
|
6
|
+
Define the Meerschaum abstraction atop daemons.
|
7
|
+
"""
|
8
|
+
|
9
|
+
import shlex
|
10
|
+
import asyncio
|
11
|
+
import threading
|
12
|
+
import json
|
13
|
+
import pathlib
|
14
|
+
import os
|
15
|
+
import sys
|
16
|
+
import traceback
|
17
|
+
from functools import partial
|
18
|
+
from datetime import datetime, timezone
|
19
|
+
|
20
|
+
import meerschaum as mrsm
|
21
|
+
from meerschaum.utils.typing import List, Optional, Union, SuccessTuple, Any, Dict, Callable
|
22
|
+
from meerschaum._internal.entry import entry
|
23
|
+
from meerschaum.utils.warnings import warn
|
24
|
+
from meerschaum.config.paths import LOGS_RESOURCES_PATH
|
25
|
+
from meerschaum.config import get_config
|
26
|
+
|
27
|
+
BANNED_CHARS: List[str] = [
|
28
|
+
',', ';', "'", '"',
|
29
|
+
]
|
30
|
+
RESTART_FLAGS: List[str] = [
|
31
|
+
'-s',
|
32
|
+
'--restart',
|
33
|
+
'--loop',
|
34
|
+
'--schedule',
|
35
|
+
'--cron',
|
36
|
+
]
|
37
|
+
|
38
|
+
class StopMonitoringLogs(Exception):
|
39
|
+
"""
|
40
|
+
Raise this exception to stop the logs monitoring.
|
41
|
+
"""
|
42
|
+
|
43
|
+
|
44
|
+
class Job:
|
45
|
+
"""
|
46
|
+
Manage a `meerschaum.utils.daemon.Daemon`, locally or remotely via the API.
|
47
|
+
"""
|
48
|
+
|
49
|
+
def __init__(
|
50
|
+
self,
|
51
|
+
name: str,
|
52
|
+
sysargs: Union[List[str], str, None] = None,
|
53
|
+
executor_keys: Optional[str] = None,
|
54
|
+
_properties: Optional[Dict[str, Any]] = None,
|
55
|
+
):
|
56
|
+
"""
|
57
|
+
Create a new job to manage a `meerschaum.utils.daemon.Daemon`.
|
58
|
+
|
59
|
+
Parameters
|
60
|
+
----------
|
61
|
+
name: str
|
62
|
+
The name of the job to be created.
|
63
|
+
This will also be used as the Daemon ID.
|
64
|
+
|
65
|
+
sysargs: Union[List[str], str, None], default None
|
66
|
+
The sysargs of the command to be executed, e.g. 'start api'.
|
67
|
+
|
68
|
+
executor_keys: Optional[str], default None
|
69
|
+
If provided, execute the job remotely on an API instance, e.g. 'api:main'.
|
70
|
+
|
71
|
+
_properties: Optional[Dict[str, Any]], default None
|
72
|
+
If provided, use this to patch the daemon's properties.
|
73
|
+
"""
|
74
|
+
from meerschaum.utils.daemon import Daemon
|
75
|
+
for char in BANNED_CHARS:
|
76
|
+
if char in name:
|
77
|
+
raise ValueError(f"Invalid name: ({char}) is not allowed.")
|
78
|
+
|
79
|
+
if isinstance(sysargs, str):
|
80
|
+
sysargs = shlex.split(sysargs)
|
81
|
+
|
82
|
+
if executor_keys == 'local':
|
83
|
+
executor_keys = None
|
84
|
+
self.executor_keys = executor_keys
|
85
|
+
self.name = name
|
86
|
+
try:
|
87
|
+
self._daemon = (
|
88
|
+
Daemon(daemon_id=name)
|
89
|
+
if executor_keys is not None
|
90
|
+
else None
|
91
|
+
)
|
92
|
+
except Exception:
|
93
|
+
self._daemon = None
|
94
|
+
|
95
|
+
self._properties_patch = _properties or {}
|
96
|
+
|
97
|
+
daemon_sysargs = (
|
98
|
+
self._daemon.properties.get('target', {}).get('args', [None])[0]
|
99
|
+
if self._daemon is not None
|
100
|
+
else None
|
101
|
+
)
|
102
|
+
|
103
|
+
if daemon_sysargs and sysargs and daemon_sysargs != sysargs:
|
104
|
+
warn("Given sysargs differ from existing sysargs.")
|
105
|
+
|
106
|
+
self._sysargs = [
|
107
|
+
arg
|
108
|
+
for arg in (daemon_sysargs or sysargs or [])
|
109
|
+
if arg not in ('-d', '--daemon')
|
110
|
+
]
|
111
|
+
for restart_flag in RESTART_FLAGS:
|
112
|
+
if restart_flag in self._sysargs:
|
113
|
+
self._properties_patch.update({'restart': True})
|
114
|
+
break
|
115
|
+
|
116
|
+
def start(self, debug: bool = False) -> SuccessTuple:
|
117
|
+
"""
|
118
|
+
Start the job's daemon.
|
119
|
+
"""
|
120
|
+
if self.executor is not None:
|
121
|
+
if not self.exists(debug=debug):
|
122
|
+
return self.executor.create_job(self.name, self.sysargs, debug=debug)
|
123
|
+
return self.executor.start_job(self.name, debug=debug)
|
124
|
+
|
125
|
+
if self.is_running():
|
126
|
+
return True, f"{self} is already running."
|
127
|
+
|
128
|
+
success, msg = self.daemon.run(
|
129
|
+
keep_daemon_output=True,
|
130
|
+
allow_dirty_run=True,
|
131
|
+
)
|
132
|
+
if not success:
|
133
|
+
return success, msg
|
134
|
+
|
135
|
+
return success, f"Started {self}."
|
136
|
+
|
137
|
+
def stop(self, timeout_seconds: Optional[int] = None, debug: bool = False) -> SuccessTuple:
|
138
|
+
"""
|
139
|
+
Stop the job's daemon.
|
140
|
+
"""
|
141
|
+
if self.executor is not None:
|
142
|
+
return self.executor.stop_job(self.name, debug=debug)
|
143
|
+
|
144
|
+
if self.daemon.status == 'stopped':
|
145
|
+
if not self.restart:
|
146
|
+
return True, f"{self} is not running."
|
147
|
+
|
148
|
+
quit_success, quit_msg = self.daemon.quit(timeout=timeout_seconds)
|
149
|
+
if quit_success:
|
150
|
+
return quit_success, f"Stopped {self}."
|
151
|
+
|
152
|
+
warn(
|
153
|
+
f"Failed to gracefully quit {self}.",
|
154
|
+
stack=False,
|
155
|
+
)
|
156
|
+
kill_success, kill_msg = self.daemon.kill(timeout=timeout_seconds)
|
157
|
+
if not kill_success:
|
158
|
+
return kill_success, kill_msg
|
159
|
+
|
160
|
+
return kill_success, f"Killed {self}."
|
161
|
+
|
162
|
+
def pause(self, timeout_seconds: Optional[int] = None, debug: bool = False) -> SuccessTuple:
|
163
|
+
"""
|
164
|
+
Pause the job's daemon.
|
165
|
+
"""
|
166
|
+
if self.executor is not None:
|
167
|
+
return self.executor.pause_job(self.name, debug=debug)
|
168
|
+
|
169
|
+
pause_success, pause_msg = self.daemon.pause(timeout=timeout_seconds)
|
170
|
+
if not pause_success:
|
171
|
+
return pause_success, pause_msg
|
172
|
+
|
173
|
+
return pause_success, f"Paused {self}."
|
174
|
+
|
175
|
+
def delete(self, debug: bool = False) -> SuccessTuple:
|
176
|
+
"""
|
177
|
+
Delete the job and its daemon.
|
178
|
+
"""
|
179
|
+
if self.executor is not None:
|
180
|
+
return self.executor.delete_job(self.name, debug=debug)
|
181
|
+
|
182
|
+
if self.is_running():
|
183
|
+
stop_success, stop_msg = self.stop()
|
184
|
+
if not stop_success:
|
185
|
+
return stop_success, stop_msg
|
186
|
+
|
187
|
+
cleanup_success, cleanup_msg = self.daemon.cleanup()
|
188
|
+
if not cleanup_success:
|
189
|
+
return cleanup_success, cleanup_msg
|
190
|
+
|
191
|
+
return cleanup_success, f"Deleted {self}."
|
192
|
+
|
193
|
+
def is_running(self) -> bool:
|
194
|
+
"""
|
195
|
+
Determine whether the job's daemon is running.
|
196
|
+
"""
|
197
|
+
return self.status == 'running'
|
198
|
+
|
199
|
+
def exists(self, debug: bool = False) -> bool:
|
200
|
+
"""
|
201
|
+
Determine whether the job exists.
|
202
|
+
"""
|
203
|
+
if self.executor is not None:
|
204
|
+
return self.executor.get_job_exists(self.name, debug=debug)
|
205
|
+
|
206
|
+
return self.daemon.path.exists()
|
207
|
+
|
208
|
+
def get_logs(self) -> Union[str, None]:
|
209
|
+
"""
|
210
|
+
Return the output text of the job's daemon.
|
211
|
+
"""
|
212
|
+
if self.executor is not None:
|
213
|
+
return self.executor.get_logs(self.name)
|
214
|
+
|
215
|
+
return self.daemon.log_text
|
216
|
+
|
217
|
+
def monitor_logs(
|
218
|
+
self,
|
219
|
+
callback_function: Callable[[str], None] = partial(print, end=''),
|
220
|
+
input_callback_function: Optional[Callable[[], str]] = None,
|
221
|
+
stop_callback_function: Optional[Callable[[SuccessTuple], None]] = None,
|
222
|
+
stop_event: Optional[asyncio.Event] = None,
|
223
|
+
stop_on_exit: bool = False,
|
224
|
+
strip_timestamps: bool = False,
|
225
|
+
accept_input: bool = True,
|
226
|
+
debug: bool = False,
|
227
|
+
):
|
228
|
+
"""
|
229
|
+
Monitor the job's log files and execute a callback on new lines.
|
230
|
+
|
231
|
+
Parameters
|
232
|
+
----------
|
233
|
+
callback_function: Callable[[str], None], default partial(print, end='')
|
234
|
+
The callback to execute as new data comes in.
|
235
|
+
Defaults to printing the output directly to `stdout`.
|
236
|
+
|
237
|
+
input_callback_function: Optional[Callable[[], str]], default None
|
238
|
+
If provided, execute this callback when the daemon is blocking on stdin.
|
239
|
+
Defaults to `sys.stdin.readline()`.
|
240
|
+
|
241
|
+
stop_callback_function: Optional[Callable[[SuccessTuple]], str], default None
|
242
|
+
If provided, execute this callback when the daemon stops.
|
243
|
+
The job's SuccessTuple will be passed to the callback.
|
244
|
+
|
245
|
+
stop_event: Optional[asyncio.Event], default None
|
246
|
+
If provided, stop monitoring when this event is set.
|
247
|
+
You may instead raise `meerschaum.utils.jobs.StopMonitoringLogs`
|
248
|
+
from within `callback_function` to stop monitoring.
|
249
|
+
|
250
|
+
stop_on_exit: bool, default False
|
251
|
+
If `True`, stop monitoring when the job stops.
|
252
|
+
|
253
|
+
strip_timestamps: bool, default False
|
254
|
+
If `True`, remove leading timestamps from lines.
|
255
|
+
|
256
|
+
accept_input: bool, default True
|
257
|
+
If `True`, accept input when the daemon blocks on stdin.
|
258
|
+
"""
|
259
|
+
def default_input_callback_function():
|
260
|
+
return sys.stdin.readline()
|
261
|
+
|
262
|
+
if input_callback_function is None:
|
263
|
+
input_callback_function = default_input_callback_function
|
264
|
+
|
265
|
+
if self.executor is not None:
|
266
|
+
self.executor.monitor_logs(
|
267
|
+
self.name,
|
268
|
+
callback_function,
|
269
|
+
input_callback_function=input_callback_function,
|
270
|
+
stop_callback_function=stop_callback_function,
|
271
|
+
stop_on_exit=stop_on_exit,
|
272
|
+
accept_input=accept_input,
|
273
|
+
strip_timestamps=strip_timestamps,
|
274
|
+
debug=debug,
|
275
|
+
)
|
276
|
+
return
|
277
|
+
|
278
|
+
monitor_logs_coroutine = self.monitor_logs_async(
|
279
|
+
callback_function=callback_function,
|
280
|
+
input_callback_function=input_callback_function,
|
281
|
+
stop_callback_function=stop_callback_function,
|
282
|
+
stop_event=stop_event,
|
283
|
+
stop_on_exit=stop_on_exit,
|
284
|
+
strip_timestamps=strip_timestamps,
|
285
|
+
accept_input=accept_input,
|
286
|
+
)
|
287
|
+
return asyncio.run(monitor_logs_coroutine)
|
288
|
+
|
289
|
+
|
290
|
+
async def monitor_logs_async(
|
291
|
+
self,
|
292
|
+
callback_function: Callable[[str], None] = partial(print, end='', flush=True),
|
293
|
+
input_callback_function: Optional[Callable[[], str]] = None,
|
294
|
+
stop_callback_function: Optional[Callable[[SuccessTuple], None]] = None,
|
295
|
+
stop_event: Optional[asyncio.Event] = None,
|
296
|
+
stop_on_exit: bool = False,
|
297
|
+
strip_timestamps: bool = False,
|
298
|
+
accept_input: bool = True,
|
299
|
+
debug: bool = False,
|
300
|
+
):
|
301
|
+
"""
|
302
|
+
Monitor the job's log files and await a callback on new lines.
|
303
|
+
|
304
|
+
Parameters
|
305
|
+
----------
|
306
|
+
callback_function: Callable[[str], None], default partial(print, end='')
|
307
|
+
The callback to execute as new data comes in.
|
308
|
+
Defaults to printing the output directly to `stdout`.
|
309
|
+
|
310
|
+
input_callback_function: Optional[Callable[[], str]], default None
|
311
|
+
If provided, execute this callback when the daemon is blocking on stdin.
|
312
|
+
Defaults to `sys.stdin.readline()`.
|
313
|
+
|
314
|
+
stop_callback_function: Optional[Callable[[SuccessTuple]], str], default None
|
315
|
+
If provided, execute this callback when the daemon stops.
|
316
|
+
The job's SuccessTuple will be passed to the callback.
|
317
|
+
|
318
|
+
stop_event: Optional[asyncio.Event], default None
|
319
|
+
If provided, stop monitoring when this event is set.
|
320
|
+
You may instead raise `meerschaum.utils.jobs.StopMonitoringLogs`
|
321
|
+
from within `callback_function` to stop monitoring.
|
322
|
+
|
323
|
+
stop_on_exit: bool, default False
|
324
|
+
If `True`, stop monitoring when the job stops.
|
325
|
+
|
326
|
+
strip_timestamps: bool, default False
|
327
|
+
If `True`, remove leading timestamps from lines.
|
328
|
+
|
329
|
+
accept_input: bool, default True
|
330
|
+
If `True`, accept input when the daemon blocks on stdin.
|
331
|
+
"""
|
332
|
+
def default_input_callback_function():
|
333
|
+
return sys.stdin.readline()
|
334
|
+
|
335
|
+
if input_callback_function is None:
|
336
|
+
input_callback_function = default_input_callback_function
|
337
|
+
|
338
|
+
if self.executor is not None:
|
339
|
+
await self.executor.monitor_logs_async(
|
340
|
+
self.name,
|
341
|
+
callback_function,
|
342
|
+
input_callback_function=input_callback_function,
|
343
|
+
stop_callback_function=stop_callback_function,
|
344
|
+
stop_on_exit=stop_on_exit,
|
345
|
+
accept_input=accept_input,
|
346
|
+
debug=debug,
|
347
|
+
)
|
348
|
+
return
|
349
|
+
|
350
|
+
from meerschaum.utils.formatting._jobs import strip_timestamp_from_line
|
351
|
+
|
352
|
+
events = {
|
353
|
+
'user': stop_event,
|
354
|
+
'stopped': asyncio.Event(),
|
355
|
+
}
|
356
|
+
combined_event = asyncio.Event()
|
357
|
+
emitted_text = False
|
358
|
+
|
359
|
+
async def check_job_status():
|
360
|
+
nonlocal emitted_text
|
361
|
+
stopped_event = events.get('stopped', None)
|
362
|
+
if stopped_event is None:
|
363
|
+
return
|
364
|
+
sleep_time = 0.1
|
365
|
+
while sleep_time < 60:
|
366
|
+
if self.status == 'stopped':
|
367
|
+
if not emitted_text:
|
368
|
+
await asyncio.sleep(sleep_time)
|
369
|
+
sleep_time = round(sleep_time * 1.1, 2)
|
370
|
+
continue
|
371
|
+
|
372
|
+
if stop_callback_function is not None:
|
373
|
+
try:
|
374
|
+
if asyncio.iscoroutinefunction(stop_callback_function):
|
375
|
+
await stop_callback_function(self.result)
|
376
|
+
else:
|
377
|
+
stop_callback_function(self.result)
|
378
|
+
except Exception:
|
379
|
+
warn(traceback.format_exc())
|
380
|
+
|
381
|
+
if stop_on_exit:
|
382
|
+
events['stopped'].set()
|
383
|
+
|
384
|
+
break
|
385
|
+
await asyncio.sleep(0.1)
|
386
|
+
|
387
|
+
async def check_blocking_on_input():
|
388
|
+
while True:
|
389
|
+
if not emitted_text or not self.is_blocking_on_stdin():
|
390
|
+
try:
|
391
|
+
await asyncio.sleep(0.1)
|
392
|
+
except asyncio.exceptions.CancelledError:
|
393
|
+
break
|
394
|
+
continue
|
395
|
+
|
396
|
+
if not self.is_running():
|
397
|
+
break
|
398
|
+
|
399
|
+
await emit_latest_lines()
|
400
|
+
|
401
|
+
try:
|
402
|
+
print('', end='', flush=True)
|
403
|
+
if asyncio.iscoroutinefunction(input_callback_function):
|
404
|
+
data = await input_callback_function()
|
405
|
+
else:
|
406
|
+
data = input_callback_function()
|
407
|
+
except KeyboardInterrupt:
|
408
|
+
break
|
409
|
+
if not data.endswith('\n'):
|
410
|
+
data += '\n'
|
411
|
+
self.daemon.stdin_file.write(data)
|
412
|
+
await asyncio.sleep(0.1)
|
413
|
+
|
414
|
+
async def combine_events():
|
415
|
+
event_tasks = [
|
416
|
+
asyncio.create_task(event.wait())
|
417
|
+
for event in events.values()
|
418
|
+
if event is not None
|
419
|
+
]
|
420
|
+
if not event_tasks:
|
421
|
+
return
|
422
|
+
|
423
|
+
try:
|
424
|
+
done, pending = await asyncio.wait(
|
425
|
+
event_tasks,
|
426
|
+
return_when=asyncio.FIRST_COMPLETED,
|
427
|
+
)
|
428
|
+
for task in pending:
|
429
|
+
task.cancel()
|
430
|
+
except asyncio.exceptions.CancelledError:
|
431
|
+
pass
|
432
|
+
finally:
|
433
|
+
combined_event.set()
|
434
|
+
|
435
|
+
check_job_status_task = asyncio.create_task(check_job_status())
|
436
|
+
check_blocking_on_input_task = asyncio.create_task(check_blocking_on_input())
|
437
|
+
combine_events_task = asyncio.create_task(combine_events())
|
438
|
+
|
439
|
+
log = self.daemon.rotating_log
|
440
|
+
lines_to_show = get_config('jobs', 'logs', 'lines_to_show')
|
441
|
+
|
442
|
+
async def emit_latest_lines():
|
443
|
+
nonlocal emitted_text
|
444
|
+
lines = log.readlines()
|
445
|
+
for line in lines[(-1 * lines_to_show):]:
|
446
|
+
if stop_event is not None and stop_event.is_set():
|
447
|
+
return
|
448
|
+
|
449
|
+
if strip_timestamps:
|
450
|
+
line = strip_timestamp_from_line(line)
|
451
|
+
|
452
|
+
try:
|
453
|
+
if asyncio.iscoroutinefunction(callback_function):
|
454
|
+
await callback_function(line)
|
455
|
+
else:
|
456
|
+
callback_function(line)
|
457
|
+
emitted_text = True
|
458
|
+
except StopMonitoringLogs:
|
459
|
+
return
|
460
|
+
except Exception:
|
461
|
+
warn(f"Error in logs callback:\n{traceback.format_exc()}")
|
462
|
+
|
463
|
+
await emit_latest_lines()
|
464
|
+
|
465
|
+
tasks = (
|
466
|
+
[check_job_status_task]
|
467
|
+
+ ([check_blocking_on_input_task] if accept_input else [])
|
468
|
+
+ [combine_events_task]
|
469
|
+
)
|
470
|
+
try:
|
471
|
+
_ = asyncio.gather(*tasks, return_exceptions=True)
|
472
|
+
except Exception:
|
473
|
+
warn(f"Failed to run async checks:\n{traceback.format_exc()}")
|
474
|
+
|
475
|
+
watchfiles = mrsm.attempt_import('watchfiles')
|
476
|
+
async for changes in watchfiles.awatch(
|
477
|
+
LOGS_RESOURCES_PATH,
|
478
|
+
stop_event=combined_event,
|
479
|
+
):
|
480
|
+
for change in changes:
|
481
|
+
file_path_str = change[1]
|
482
|
+
file_path = pathlib.Path(file_path_str)
|
483
|
+
latest_subfile_path = log.get_latest_subfile_path()
|
484
|
+
if latest_subfile_path != file_path:
|
485
|
+
continue
|
486
|
+
|
487
|
+
await emit_latest_lines()
|
488
|
+
await emit_latest_lines()
|
489
|
+
|
490
|
+
await emit_latest_lines()
|
491
|
+
|
492
|
+
def is_blocking_on_stdin(self, debug: bool = False) -> bool:
|
493
|
+
"""
|
494
|
+
Return whether a job's daemon is blocking on stdin.
|
495
|
+
"""
|
496
|
+
if self.executor is not None:
|
497
|
+
return self.executor.get_job_is_blocking_on_stdin(self.name, debug=debug)
|
498
|
+
|
499
|
+
return self.is_running() and self.daemon.blocking_stdin_file_path.exists()
|
500
|
+
|
501
|
+
def write_stdin(self, data):
|
502
|
+
"""
|
503
|
+
Write to a job's daemon's `stdin`.
|
504
|
+
"""
|
505
|
+
### TODO implement remote method?
|
506
|
+
if self.executor is not None:
|
507
|
+
pass
|
508
|
+
|
509
|
+
self.daemon.stdin_file.write(data)
|
510
|
+
|
511
|
+
@property
|
512
|
+
def executor(self) -> Union['APIConnector', None]:
|
513
|
+
"""
|
514
|
+
If the job is remote, return the connector to the remote API instance.
|
515
|
+
"""
|
516
|
+
return (
|
517
|
+
mrsm.get_connector(self.executor_keys)
|
518
|
+
if self.executor_keys is not None
|
519
|
+
else None
|
520
|
+
)
|
521
|
+
|
522
|
+
@property
|
523
|
+
def status(self) -> str:
|
524
|
+
"""
|
525
|
+
Return the running status of the job's daemon.
|
526
|
+
"""
|
527
|
+
if self.executor is not None:
|
528
|
+
return self.executor.get_job_metadata(
|
529
|
+
self.name
|
530
|
+
).get('daemon', {}).get('status', 'stopped')
|
531
|
+
|
532
|
+
return self.daemon.status
|
533
|
+
|
534
|
+
@property
|
535
|
+
def pid(self) -> Union[int, None]:
|
536
|
+
"""
|
537
|
+
Return the PID of the job's dameon.
|
538
|
+
"""
|
539
|
+
if self.executor is not None:
|
540
|
+
return self.executor.get_job_metadata(self.name).get('daemon', {}).get('pid', None)
|
541
|
+
|
542
|
+
return self.daemon.pid
|
543
|
+
|
544
|
+
@property
|
545
|
+
def restart(self) -> bool:
|
546
|
+
"""
|
547
|
+
Return whether to restart a stopped job.
|
548
|
+
"""
|
549
|
+
return self.daemon.properties.get('restart', False)
|
550
|
+
|
551
|
+
@property
|
552
|
+
def result(self) -> SuccessTuple:
|
553
|
+
"""
|
554
|
+
Return the `SuccessTuple` when the job has terminated.
|
555
|
+
"""
|
556
|
+
if self.is_running():
|
557
|
+
return True, f"{self} is running."
|
558
|
+
|
559
|
+
_result = self.daemon.properties.get('result', None)
|
560
|
+
if _result is None:
|
561
|
+
return False, "No result available."
|
562
|
+
|
563
|
+
return tuple(_result)
|
564
|
+
|
565
|
+
@property
|
566
|
+
def sysargs(self) -> List[str]:
|
567
|
+
"""
|
568
|
+
Return the sysargs to use for the Daemon.
|
569
|
+
"""
|
570
|
+
if self._sysargs:
|
571
|
+
return self._sysargs
|
572
|
+
|
573
|
+
# target_args = self.daemon.properties.get('target', {}).get('args', None)
|
574
|
+
target_args = self.daemon.target_args
|
575
|
+
if target_args is None:
|
576
|
+
return []
|
577
|
+
self._sysargs = target_args[0] if len(target_args) > 0 else []
|
578
|
+
return self._sysargs
|
579
|
+
|
580
|
+
@property
|
581
|
+
def daemon(self) -> 'Daemon':
|
582
|
+
"""
|
583
|
+
Return the daemon which this job manages.
|
584
|
+
"""
|
585
|
+
from meerschaum.utils.daemon import Daemon
|
586
|
+
if self._daemon is not None and self.executor is None and self._sysargs:
|
587
|
+
return self._daemon
|
588
|
+
|
589
|
+
remote_properties = (
|
590
|
+
{}
|
591
|
+
if self.executor is None
|
592
|
+
else self.executor.get_job_properties(self.name)
|
593
|
+
)
|
594
|
+
properties = {**remote_properties, **self._properties_patch}
|
595
|
+
|
596
|
+
self._daemon = Daemon(
|
597
|
+
target=entry,
|
598
|
+
target_args=[self._sysargs],
|
599
|
+
target_kw={},
|
600
|
+
daemon_id=self.name,
|
601
|
+
label=shlex.join(self._sysargs),
|
602
|
+
properties=properties,
|
603
|
+
)
|
604
|
+
|
605
|
+
return self._daemon
|
606
|
+
|
607
|
+
@property
|
608
|
+
def began(self) -> Union[datetime, None]:
|
609
|
+
"""
|
610
|
+
The datetime when the job began running.
|
611
|
+
"""
|
612
|
+
began_str = self.daemon.properties.get('process', {}).get('began', None)
|
613
|
+
if began_str is None:
|
614
|
+
return None
|
615
|
+
|
616
|
+
return datetime.fromisoformat(began_str)
|
617
|
+
|
618
|
+
@property
|
619
|
+
def ended(self) -> Union[datetime, None]:
|
620
|
+
"""
|
621
|
+
The datetime when the job stopped running.
|
622
|
+
"""
|
623
|
+
ended_str = self.daemon.properties.get('process', {}).get('ended', None)
|
624
|
+
if ended_str is None:
|
625
|
+
return None
|
626
|
+
|
627
|
+
return datetime.fromisoformat(ended_str)
|
628
|
+
|
629
|
+
@property
|
630
|
+
def paused(self) -> Union[datetime, None]:
|
631
|
+
"""
|
632
|
+
The datetime when the job was suspended while running.
|
633
|
+
"""
|
634
|
+
paused_str = self.daemon.properties.get('process', {}).get('paused', None)
|
635
|
+
if paused_str is None:
|
636
|
+
return None
|
637
|
+
|
638
|
+
return datetime.fromisoformat(paused_str)
|
639
|
+
|
640
|
+
@property
|
641
|
+
def stop_time(self) -> Union[datetime, None]:
|
642
|
+
"""
|
643
|
+
Return the timestamp when the job was manually stopped.
|
644
|
+
"""
|
645
|
+
if self.executor is not None:
|
646
|
+
return self.executor.get_job_stop_time(self.name)
|
647
|
+
|
648
|
+
if not self.daemon.stop_path.exists():
|
649
|
+
return None
|
650
|
+
|
651
|
+
try:
|
652
|
+
with open(self.daemon.stop_path, 'r', encoding='utf-8') as f:
|
653
|
+
stop_data = json.load(f)
|
654
|
+
except Exception as e:
|
655
|
+
warn(f"Failed to read stop file for {self}:\n{e}")
|
656
|
+
return None
|
657
|
+
|
658
|
+
stop_time_str = stop_data.get('stop_time', None)
|
659
|
+
if not stop_time_str:
|
660
|
+
warn(f"Could not read stop time for {self}.")
|
661
|
+
return None
|
662
|
+
|
663
|
+
return datetime.fromisoformat(stop_time_str)
|
664
|
+
|
665
|
+
@property
|
666
|
+
def hidden(self) -> bool:
|
667
|
+
"""
|
668
|
+
Return a bool indicating whether this job should be displayed.
|
669
|
+
"""
|
670
|
+
return self.name.startswith('_') or self.name.startswith('.')
|
671
|
+
|
672
|
+
def check_restart(self) -> SuccessTuple:
|
673
|
+
"""
|
674
|
+
If `restart` is `True` and the daemon is not running,
|
675
|
+
restart the job.
|
676
|
+
Do not restart if the job was manually stopped.
|
677
|
+
"""
|
678
|
+
if self.is_running():
|
679
|
+
return True, f"{self} is running."
|
680
|
+
|
681
|
+
if not self.restart:
|
682
|
+
return True, f"{self} does not need to be restarted."
|
683
|
+
|
684
|
+
if self.stop_time is not None:
|
685
|
+
return True, f"{self} was manually stopped."
|
686
|
+
|
687
|
+
return self.start()
|
688
|
+
|
689
|
+
@property
|
690
|
+
def label(self) -> str:
|
691
|
+
"""
|
692
|
+
Return the job's Daemon label (joined sysargs).
|
693
|
+
"""
|
694
|
+
return shlex.join(self.sysargs)
|
695
|
+
|
696
|
+
def __str__(self) -> str:
|
697
|
+
sysargs = self.sysargs
|
698
|
+
sysargs_str = shlex.join(sysargs) if sysargs else ''
|
699
|
+
job_str = f'Job("{self.name}"'
|
700
|
+
if sysargs_str:
|
701
|
+
job_str += f', "{sysargs_str}"'
|
702
|
+
|
703
|
+
job_str += ')'
|
704
|
+
return job_str
|
705
|
+
|
706
|
+
def __repr__(self) -> str:
|
707
|
+
return str(self)
|
708
|
+
|
709
|
+
def __hash__(self) -> int:
|
710
|
+
return hash(self.name)
|