meerschaum 2.2.6__py3-none-any.whl → 2.3.0.dev1__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 +4 -1
- meerschaum/__main__.py +10 -5
- meerschaum/_internal/arguments/_parser.py +44 -15
- meerschaum/_internal/entry.py +35 -14
- meerschaum/_internal/shell/Shell.py +155 -53
- meerschaum/_internal/shell/updates.py +175 -0
- meerschaum/actions/api.py +12 -12
- meerschaum/actions/attach.py +95 -0
- meerschaum/actions/delete.py +35 -26
- meerschaum/actions/register.py +19 -5
- meerschaum/actions/show.py +119 -148
- meerschaum/actions/start.py +85 -75
- meerschaum/actions/stop.py +68 -39
- meerschaum/actions/sync.py +3 -3
- meerschaum/actions/upgrade.py +28 -36
- 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 +340 -0
- meerschaum/api/routes/_pipes.py +25 -25
- meerschaum/config/_default.py +1 -0
- meerschaum/config/_formatting.py +1 -0
- meerschaum/config/_paths.py +5 -0
- meerschaum/config/_shell.py +84 -67
- meerschaum/config/_version.py +1 -1
- meerschaum/config/static/__init__.py +9 -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 +260 -0
- meerschaum/connectors/api/_misc.py +1 -1
- meerschaum/connectors/api/_request.py +13 -9
- meerschaum/connectors/parse.py +23 -7
- meerschaum/core/Pipe/_sync.py +3 -0
- meerschaum/plugins/__init__.py +89 -5
- meerschaum/utils/daemon/Daemon.py +333 -149
- meerschaum/utils/daemon/FileDescriptorInterceptor.py +19 -10
- meerschaum/utils/daemon/RotatingFile.py +18 -7
- meerschaum/utils/daemon/StdinFile.py +110 -0
- meerschaum/utils/daemon/__init__.py +40 -27
- meerschaum/utils/formatting/__init__.py +83 -37
- meerschaum/utils/formatting/_jobs.py +118 -51
- meerschaum/utils/formatting/_shell.py +6 -0
- meerschaum/utils/jobs/_Job.py +684 -0
- meerschaum/utils/jobs/__init__.py +245 -0
- meerschaum/utils/misc.py +18 -17
- meerschaum/utils/packages/__init__.py +21 -15
- meerschaum/utils/packages/_packages.py +2 -2
- meerschaum/utils/prompt.py +20 -7
- meerschaum/utils/schedule.py +21 -15
- {meerschaum-2.2.6.dist-info → meerschaum-2.3.0.dev1.dist-info}/METADATA +9 -9
- {meerschaum-2.2.6.dist-info → meerschaum-2.3.0.dev1.dist-info}/RECORD +61 -54
- {meerschaum-2.2.6.dist-info → meerschaum-2.3.0.dev1.dist-info}/WHEEL +1 -1
- {meerschaum-2.2.6.dist-info → meerschaum-2.3.0.dev1.dist-info}/LICENSE +0 -0
- {meerschaum-2.2.6.dist-info → meerschaum-2.3.0.dev1.dist-info}/NOTICE +0 -0
- {meerschaum-2.2.6.dist-info → meerschaum-2.3.0.dev1.dist-info}/entry_points.txt +0 -0
- {meerschaum-2.2.6.dist-info → meerschaum-2.3.0.dev1.dist-info}/top_level.txt +0 -0
- {meerschaum-2.2.6.dist-info → meerschaum-2.3.0.dev1.dist-info}/zip-safe +0 -0
@@ -8,6 +8,7 @@ Manage running daemons via the Daemon class.
|
|
8
8
|
|
9
9
|
from __future__ import annotations
|
10
10
|
import os
|
11
|
+
import importlib
|
11
12
|
import pathlib
|
12
13
|
import json
|
13
14
|
import shutil
|
@@ -17,22 +18,59 @@ import time
|
|
17
18
|
import traceback
|
18
19
|
from functools import partial
|
19
20
|
from datetime import datetime, timezone
|
20
|
-
|
21
|
+
|
22
|
+
import meerschaum as mrsm
|
23
|
+
from meerschaum.utils.typing import (
|
24
|
+
Optional, Dict, Any, SuccessTuple, Callable, List, Union,
|
25
|
+
is_success_tuple, Tuple,
|
26
|
+
)
|
21
27
|
from meerschaum.config import get_config
|
22
28
|
from meerschaum.config.static import STATIC_CONFIG
|
23
|
-
from meerschaum.config._paths import
|
29
|
+
from meerschaum.config._paths import (
|
30
|
+
DAEMON_RESOURCES_PATH, LOGS_RESOURCES_PATH, DAEMON_ERROR_LOG_PATH,
|
31
|
+
)
|
24
32
|
from meerschaum.config._patch import apply_patch_to_config
|
25
33
|
from meerschaum.utils.warnings import warn, error
|
26
34
|
from meerschaum.utils.packages import attempt_import
|
27
35
|
from meerschaum.utils.venv import venv_exec
|
28
36
|
from meerschaum.utils.daemon._names import get_new_daemon_name
|
29
37
|
from meerschaum.utils.daemon.RotatingFile import RotatingFile
|
38
|
+
from meerschaum.utils.daemon.StdinFile import StdinFile
|
30
39
|
from meerschaum.utils.threading import RepeatTimer
|
31
40
|
from meerschaum.__main__ import _close_pools
|
32
41
|
|
42
|
+
_daemons = []
|
43
|
+
_results = {}
|
44
|
+
|
33
45
|
class Daemon:
|
34
46
|
"""
|
35
47
|
Daemonize Python functions into background processes.
|
48
|
+
|
49
|
+
Examples
|
50
|
+
--------
|
51
|
+
>>> import meerschaum as mrsm
|
52
|
+
>>> from meerschaum.utils.daemons import Daemon
|
53
|
+
>>> daemon = Daemon(print, ('hi',))
|
54
|
+
>>> success, msg = daemon.run()
|
55
|
+
>>> print(daemon.log_text)
|
56
|
+
|
57
|
+
2024-07-29 18:03 | hi
|
58
|
+
2024-07-29 18:03 |
|
59
|
+
>>> daemon.run(allow_dirty_run=True)
|
60
|
+
>>> print(daemon.log_text)
|
61
|
+
|
62
|
+
2024-07-29 18:03 | hi
|
63
|
+
2024-07-29 18:03 |
|
64
|
+
2024-07-29 18:05 | hi
|
65
|
+
2024-07-29 18:05 |
|
66
|
+
>>> mrsm.pprint(daemon.properties)
|
67
|
+
{
|
68
|
+
'label': 'print',
|
69
|
+
'target': {'name': 'print', 'module': 'builtins', 'args': ['hi'], 'kw': {}},
|
70
|
+
'result': None,
|
71
|
+
'process': {'ended': '2024-07-29T18:03:33.752806'}
|
72
|
+
}
|
73
|
+
|
36
74
|
"""
|
37
75
|
|
38
76
|
def __new__(
|
@@ -51,10 +89,57 @@ class Daemon:
|
|
51
89
|
instance = instance.read_pickle()
|
52
90
|
return instance
|
53
91
|
|
92
|
+
@classmethod
|
93
|
+
def from_properties_file(cls, daemon_id: str) -> Daemon:
|
94
|
+
"""
|
95
|
+
Return a Daemon from a properties dictionary.
|
96
|
+
"""
|
97
|
+
properties_path = cls._get_properties_path_from_daemon_id(daemon_id)
|
98
|
+
if not properties_path.exists():
|
99
|
+
raise OSError(f"Properties file '{properties_path}' does not exist.")
|
100
|
+
|
101
|
+
try:
|
102
|
+
with open(properties_path, 'r', encoding='utf-8') as f:
|
103
|
+
properties = json.load(f)
|
104
|
+
except Exception:
|
105
|
+
properties = {}
|
106
|
+
|
107
|
+
if not properties:
|
108
|
+
raise ValueError(f"No properties could be read for daemon '{daemon_id}'.")
|
109
|
+
|
110
|
+
daemon_id = properties_path.parent.name
|
111
|
+
target_cf = properties.get('target', {})
|
112
|
+
target_module_name = target_cf.get('module', None)
|
113
|
+
target_function_name = target_cf.get('name', None)
|
114
|
+
target_args = target_cf.get('args', None)
|
115
|
+
target_kw = target_cf.get('kw', None)
|
116
|
+
label = properties.get('label', None)
|
117
|
+
|
118
|
+
if None in [
|
119
|
+
target_module_name,
|
120
|
+
target_function_name,
|
121
|
+
target_args,
|
122
|
+
target_kw,
|
123
|
+
]:
|
124
|
+
raise ValueError("Missing target function information.")
|
125
|
+
|
126
|
+
target_module = importlib.import_module(target_module_name)
|
127
|
+
target_function = getattr(target_module, target_function_name)
|
128
|
+
|
129
|
+
return Daemon(
|
130
|
+
daemon_id=daemon_id,
|
131
|
+
target=target_function,
|
132
|
+
target_args=target_args,
|
133
|
+
target_kw=target_kw,
|
134
|
+
properties=properties,
|
135
|
+
label=label,
|
136
|
+
)
|
137
|
+
|
138
|
+
|
54
139
|
def __init__(
|
55
140
|
self,
|
56
141
|
target: Optional[Callable[[Any], Any]] = None,
|
57
|
-
target_args:
|
142
|
+
target_args: Union[List[Any], Tuple[Any], None] = None,
|
58
143
|
target_kw: Optional[Dict[str, Any]] = None,
|
59
144
|
daemon_id: Optional[str] = None,
|
60
145
|
label: Optional[str] = None,
|
@@ -66,7 +151,7 @@ class Daemon:
|
|
66
151
|
target: Optional[Callable[[Any], Any]], default None,
|
67
152
|
The function to execute in a child process.
|
68
153
|
|
69
|
-
target_args:
|
154
|
+
target_args: Union[List[Any], Tuple[Any], None], default None
|
70
155
|
Positional arguments to pass to the target function.
|
71
156
|
|
72
157
|
target_kw: Optional[Dict[str, Any]], default None
|
@@ -81,25 +166,54 @@ class Daemon:
|
|
81
166
|
Label string to help identifiy a daemon.
|
82
167
|
If `None`, use the function name instead.
|
83
168
|
|
84
|
-
|
169
|
+
properties: Optional[Dict[str, Any]], default None
|
85
170
|
Override reading from the properties JSON by providing an existing dictionary.
|
86
171
|
"""
|
87
172
|
_pickle = self.__dict__.get('_pickle', False)
|
88
173
|
if daemon_id is not None:
|
89
174
|
self.daemon_id = daemon_id
|
90
175
|
if not self.pickle_path.exists() and not target and ('target' not in self.__dict__):
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
176
|
+
|
177
|
+
if not self.properties_path.exists():
|
178
|
+
raise Exception(
|
179
|
+
f"Daemon '{self.daemon_id}' does not exist. "
|
180
|
+
+ "Pass a target to create a new Daemon."
|
181
|
+
)
|
182
|
+
|
183
|
+
try:
|
184
|
+
new_daemon = self.from_properties_file(daemon_id)
|
185
|
+
except Exception:
|
186
|
+
new_daemon = None
|
187
|
+
|
188
|
+
if new_daemon is not None:
|
189
|
+
new_daemon.write_pickle()
|
190
|
+
target = new_daemon.target
|
191
|
+
target_args = new_daemon.target_args
|
192
|
+
target_kw = new_daemon.target_kw
|
193
|
+
label = new_daemon.label
|
194
|
+
self._properties = new_daemon.properties
|
195
|
+
else:
|
196
|
+
try:
|
197
|
+
self.properties_path.unlink()
|
198
|
+
except Exception:
|
199
|
+
pass
|
200
|
+
|
201
|
+
raise Exception(
|
202
|
+
f"Could not recover daemon '{self.daemon_id}' "
|
203
|
+
+ "from its properties file."
|
204
|
+
)
|
205
|
+
|
95
206
|
if 'target' not in self.__dict__:
|
96
207
|
if target is None:
|
97
|
-
error(
|
208
|
+
error("Cannot create a Daemon without a target.")
|
98
209
|
self.target = target
|
99
|
-
|
100
|
-
|
101
|
-
if '
|
102
|
-
self.
|
210
|
+
|
211
|
+
### NOTE: We have to check self.__dict__ in case we un-pickling.
|
212
|
+
if '_target_args' not in self.__dict__:
|
213
|
+
self._target_args = target_args
|
214
|
+
if '_target_kw' not in self.__dict__:
|
215
|
+
self._target_kw = target_kw
|
216
|
+
|
103
217
|
if 'label' not in self.__dict__:
|
104
218
|
if label is None:
|
105
219
|
label = (
|
@@ -113,16 +227,16 @@ class Daemon:
|
|
113
227
|
self._properties = properties
|
114
228
|
if self._properties is None:
|
115
229
|
self._properties = {}
|
116
|
-
self._properties.update({'label'
|
230
|
+
self._properties.update({'label': self.label})
|
117
231
|
### Instantiate the process and if it doesn't exist, make sure the PID is removed.
|
118
232
|
_ = self.process
|
119
233
|
|
120
234
|
|
121
235
|
def _run_exit(
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
236
|
+
self,
|
237
|
+
keep_daemon_output: bool = True,
|
238
|
+
allow_dirty_run: bool = False,
|
239
|
+
) -> Any:
|
126
240
|
"""Run the daemon's target function.
|
127
241
|
NOTE: This WILL EXIT the parent process!
|
128
242
|
|
@@ -130,7 +244,7 @@ class Daemon:
|
|
130
244
|
----------
|
131
245
|
keep_daemon_output: bool, default True
|
132
246
|
If `False`, delete the daemon's output directory upon exiting.
|
133
|
-
|
247
|
+
|
134
248
|
allow_dirty_run, bool, default False:
|
135
249
|
If `True`, run the daemon, even if the `daemon_id` directory exists.
|
136
250
|
This option is dangerous because if the same `daemon_id` runs twice,
|
@@ -141,31 +255,34 @@ class Daemon:
|
|
141
255
|
Nothing — this will exit the parent process.
|
142
256
|
"""
|
143
257
|
import platform, sys, os, traceback
|
144
|
-
from meerschaum.config._paths import DAEMON_ERROR_LOG_PATH
|
145
258
|
from meerschaum.utils.warnings import warn
|
146
259
|
from meerschaum.config import get_config
|
147
260
|
daemon = attempt_import('daemon')
|
148
261
|
lines = get_config('jobs', 'terminal', 'lines')
|
149
|
-
columns = get_config('jobs','terminal', 'columns')
|
262
|
+
columns = get_config('jobs', 'terminal', 'columns')
|
150
263
|
|
151
264
|
if platform.system() == 'Windows':
|
152
265
|
return False, "Windows is no longer supported."
|
153
266
|
|
154
267
|
self._setup(allow_dirty_run)
|
155
268
|
|
269
|
+
### NOTE: The SIGINT handler has been removed so that child processes may handle
|
270
|
+
### KeyboardInterrupts themselves.
|
271
|
+
### The previous aggressive approach was redundant because of the SIGTERM handler.
|
156
272
|
self._daemon_context = daemon.DaemonContext(
|
157
|
-
pidfile
|
158
|
-
stdout
|
159
|
-
stderr
|
160
|
-
working_directory
|
161
|
-
detach_process
|
162
|
-
files_preserve
|
163
|
-
signal_map
|
164
|
-
signal.SIGINT: self._handle_interrupt,
|
273
|
+
pidfile=self.pid_lock,
|
274
|
+
stdout=self.rotating_log,
|
275
|
+
stderr=self.rotating_log,
|
276
|
+
working_directory=os.getcwd(),
|
277
|
+
detach_process=True,
|
278
|
+
files_preserve=list(self.rotating_log.subfile_objects.values()),
|
279
|
+
signal_map={
|
165
280
|
signal.SIGTERM: self._handle_sigterm,
|
166
281
|
},
|
167
282
|
)
|
168
283
|
|
284
|
+
_daemons.append(self)
|
285
|
+
|
169
286
|
log_refresh_seconds = get_config('jobs', 'logs', 'refresh_files_seconds')
|
170
287
|
self._log_refresh_timer = RepeatTimer(
|
171
288
|
log_refresh_seconds,
|
@@ -175,55 +292,55 @@ class Daemon:
|
|
175
292
|
try:
|
176
293
|
os.environ['LINES'], os.environ['COLUMNS'] = str(int(lines)), str(int(columns))
|
177
294
|
with self._daemon_context:
|
295
|
+
sys.stdin = self.stdin_file
|
178
296
|
os.environ[STATIC_CONFIG['environment']['daemon_id']] = self.daemon_id
|
179
297
|
self.rotating_log.refresh_files(start_interception=True)
|
298
|
+
result = None
|
180
299
|
try:
|
181
300
|
with open(self.pid_path, 'w+', encoding='utf-8') as f:
|
182
301
|
f.write(str(os.getpid()))
|
183
302
|
|
184
303
|
self._log_refresh_timer.start()
|
304
|
+
self.properties['result'] = None
|
305
|
+
self._capture_process_timestamp('began')
|
185
306
|
result = self.target(*self.target_args, **self.target_kw)
|
186
307
|
self.properties['result'] = result
|
308
|
+
except (BrokenPipeError, KeyboardInterrupt, SystemExit):
|
309
|
+
pass
|
187
310
|
except Exception as e:
|
188
|
-
warn(
|
311
|
+
warn(
|
312
|
+
f"Exception in daemon target function: {traceback.format_exc()}",
|
313
|
+
)
|
189
314
|
result = e
|
190
315
|
finally:
|
316
|
+
_results[self.daemon_id] = result
|
317
|
+
|
318
|
+
if keep_daemon_output:
|
319
|
+
self._capture_process_timestamp('ended')
|
320
|
+
else:
|
321
|
+
self.cleanup()
|
322
|
+
|
191
323
|
self._log_refresh_timer.cancel()
|
192
|
-
self.rotating_log.close()
|
193
324
|
if self.pid is None and self.pid_path.exists():
|
194
325
|
self.pid_path.unlink()
|
195
326
|
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
327
|
+
if is_success_tuple(result):
|
328
|
+
try:
|
329
|
+
mrsm.pprint(result)
|
330
|
+
except BrokenPipeError:
|
331
|
+
pass
|
200
332
|
|
201
|
-
return result
|
202
333
|
except Exception as e:
|
203
334
|
daemon_error = traceback.format_exc()
|
204
335
|
with open(DAEMON_ERROR_LOG_PATH, 'a+', encoding='utf-8') as f:
|
205
336
|
f.write(daemon_error)
|
206
337
|
warn(f"Encountered an error while running the daemon '{self}':\n{daemon_error}")
|
207
|
-
finally:
|
208
|
-
self._cleanup_on_exit()
|
209
|
-
|
210
|
-
def _cleanup_on_exit(self):
|
211
|
-
"""Perform cleanup operations when the daemon exits."""
|
212
|
-
try:
|
213
|
-
self._log_refresh_timer.cancel()
|
214
|
-
self.rotating_log.close()
|
215
|
-
if self.pid is None and self.pid_path.exists():
|
216
|
-
self.pid_path.unlink()
|
217
|
-
self._capture_process_timestamp('ended')
|
218
|
-
except Exception as e:
|
219
|
-
warn(f"Error during daemon cleanup: {e}")
|
220
|
-
|
221
338
|
|
222
339
|
def _capture_process_timestamp(
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
340
|
+
self,
|
341
|
+
process_key: str,
|
342
|
+
write_properties: bool = True,
|
343
|
+
) -> None:
|
227
344
|
"""
|
228
345
|
Record the current timestamp to the parameters `process:<process_key>`.
|
229
346
|
|
@@ -238,7 +355,7 @@ class Daemon:
|
|
238
355
|
if 'process' not in self.properties:
|
239
356
|
self.properties['process'] = {}
|
240
357
|
|
241
|
-
if process_key not in ('began', 'ended', 'paused'):
|
358
|
+
if process_key not in ('began', 'ended', 'paused', 'stopped'):
|
242
359
|
raise ValueError(f"Invalid key '{process_key}'.")
|
243
360
|
|
244
361
|
self.properties['process'][process_key] = (
|
@@ -247,13 +364,12 @@ class Daemon:
|
|
247
364
|
if write_properties:
|
248
365
|
self.write_properties()
|
249
366
|
|
250
|
-
|
251
367
|
def run(
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
368
|
+
self,
|
369
|
+
keep_daemon_output: bool = True,
|
370
|
+
allow_dirty_run: bool = False,
|
371
|
+
debug: bool = False,
|
372
|
+
) -> SuccessTuple:
|
257
373
|
"""Run the daemon as a child process and continue executing the parent.
|
258
374
|
|
259
375
|
Parameters
|
@@ -279,6 +395,10 @@ class Daemon:
|
|
279
395
|
if self.status == 'paused':
|
280
396
|
return self.resume()
|
281
397
|
|
398
|
+
self._remove_stop_file()
|
399
|
+
if self.status == 'running':
|
400
|
+
return True, f"Daemon '{self}' is already running."
|
401
|
+
|
282
402
|
self.mkdir_if_not_exists(allow_dirty_run)
|
283
403
|
_write_pickle_success_tuple = self.write_pickle()
|
284
404
|
if not _write_pickle_success_tuple[0]:
|
@@ -288,7 +408,7 @@ class Daemon:
|
|
288
408
|
"from meerschaum.utils.daemon import Daemon; "
|
289
409
|
+ f"daemon = Daemon(daemon_id='{self.daemon_id}'); "
|
290
410
|
+ f"daemon._run_exit(keep_daemon_output={keep_daemon_output}, "
|
291
|
-
+
|
411
|
+
+ "allow_dirty_run=True)"
|
292
412
|
)
|
293
413
|
env = dict(os.environ)
|
294
414
|
env['MRSM_NOASK'] = 'true'
|
@@ -298,12 +418,11 @@ class Daemon:
|
|
298
418
|
if _launch_success_bool
|
299
419
|
else f"Failed to start daemon '{self.daemon_id}'."
|
300
420
|
)
|
301
|
-
self._capture_process_timestamp('began')
|
302
421
|
return _launch_success_bool, msg
|
303
422
|
|
304
|
-
|
305
|
-
|
306
|
-
|
423
|
+
def kill(self, timeout: Union[int, float, None] = 8) -> SuccessTuple:
|
424
|
+
"""
|
425
|
+
Forcibly terminate a running daemon.
|
307
426
|
Sends a SIGTERM signal to the process.
|
308
427
|
|
309
428
|
Parameters
|
@@ -318,10 +437,11 @@ class Daemon:
|
|
318
437
|
if self.status != 'paused':
|
319
438
|
success, msg = self._send_signal(signal.SIGTERM, timeout=timeout)
|
320
439
|
if success:
|
321
|
-
self.
|
440
|
+
self._write_stop_file('kill')
|
322
441
|
return success, msg
|
323
442
|
|
324
443
|
if self.status == 'stopped':
|
444
|
+
self._write_stop_file('kill')
|
325
445
|
return True, "Process has already stopped."
|
326
446
|
|
327
447
|
process = self.process
|
@@ -332,30 +452,30 @@ class Daemon:
|
|
332
452
|
except Exception as e:
|
333
453
|
return False, f"Failed to kill job {self} with exception: {e}"
|
334
454
|
|
335
|
-
self._capture_process_timestamp('ended')
|
336
455
|
if self.pid_path.exists():
|
337
456
|
try:
|
338
457
|
self.pid_path.unlink()
|
339
458
|
except Exception as e:
|
340
459
|
pass
|
341
|
-
return True, "Success"
|
342
460
|
|
461
|
+
self._write_stop_file('kill')
|
462
|
+
return True, "Success"
|
343
463
|
|
344
464
|
def quit(self, timeout: Union[int, float, None] = None) -> SuccessTuple:
|
345
465
|
"""Gracefully quit a running daemon."""
|
346
466
|
if self.status == 'paused':
|
347
467
|
return self.kill(timeout)
|
468
|
+
|
348
469
|
signal_success, signal_msg = self._send_signal(signal.SIGINT, timeout=timeout)
|
349
470
|
if signal_success:
|
350
|
-
self.
|
471
|
+
self._write_stop_file('quit')
|
351
472
|
return signal_success, signal_msg
|
352
473
|
|
353
|
-
|
354
474
|
def pause(
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
475
|
+
self,
|
476
|
+
timeout: Union[int, float, None] = None,
|
477
|
+
check_timeout_interval: Union[float, int, None] = None,
|
478
|
+
) -> SuccessTuple:
|
359
479
|
"""
|
360
480
|
Pause the daemon if it is running.
|
361
481
|
|
@@ -377,6 +497,7 @@ class Daemon:
|
|
377
497
|
if self.status == 'paused':
|
378
498
|
return True, f"Daemon '{self.daemon_id}' is already paused."
|
379
499
|
|
500
|
+
self._write_stop_file('pause')
|
380
501
|
try:
|
381
502
|
self.process.suspend()
|
382
503
|
except Exception as e:
|
@@ -414,12 +535,11 @@ class Daemon:
|
|
414
535
|
+ ('s' if timeout != 1 else '') + '.'
|
415
536
|
)
|
416
537
|
|
417
|
-
|
418
538
|
def resume(
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
539
|
+
self,
|
540
|
+
timeout: Union[int, float, None] = None,
|
541
|
+
check_timeout_interval: Union[float, int, None] = None,
|
542
|
+
) -> SuccessTuple:
|
423
543
|
"""
|
424
544
|
Resume the daemon if it is paused.
|
425
545
|
|
@@ -441,6 +561,7 @@ class Daemon:
|
|
441
561
|
if self.status == 'stopped':
|
442
562
|
return False, f"Daemon '{self.daemon_id}' is stopped and cannot be resumed."
|
443
563
|
|
564
|
+
self._remove_stop_file()
|
444
565
|
try:
|
445
566
|
self.process.resume()
|
446
567
|
except Exception as e:
|
@@ -470,38 +591,33 @@ class Daemon:
|
|
470
591
|
+ ('s' if timeout != 1 else '') + '.'
|
471
592
|
)
|
472
593
|
|
594
|
+
def _write_stop_file(self, action: str) -> SuccessTuple:
|
595
|
+
"""Write the stop file timestamp and action."""
|
596
|
+
if action not in ('quit', 'kill', 'pause'):
|
597
|
+
return False, f"Unsupported action '{action}'."
|
598
|
+
|
599
|
+
with open(self.stop_path, 'w+', encoding='utf-8') as f:
|
600
|
+
json.dump(
|
601
|
+
{
|
602
|
+
'stop_time': datetime.now(timezone.utc).isoformat(),
|
603
|
+
'action': action,
|
604
|
+
},
|
605
|
+
f
|
606
|
+
)
|
473
607
|
|
474
|
-
|
475
|
-
"""
|
476
|
-
Handle `SIGINT` within the Daemon context.
|
477
|
-
This method is injected into the `DaemonContext`.
|
478
|
-
"""
|
479
|
-
from meerschaum.utils.process import signal_handler
|
480
|
-
signal_handler(signal_number, stack_frame)
|
481
|
-
|
482
|
-
self.rotating_log.stop_log_fd_interception(unused_only=False)
|
483
|
-
timer = self.__dict__.get('_log_refresh_timer', None)
|
484
|
-
if timer is not None:
|
485
|
-
timer.cancel()
|
486
|
-
|
487
|
-
daemon_context = self.__dict__.get('_daemon_context', None)
|
488
|
-
if daemon_context is not None:
|
489
|
-
daemon_context.close()
|
608
|
+
return True, "Success"
|
490
609
|
|
491
|
-
|
610
|
+
def _remove_stop_file(self) -> SuccessTuple:
|
611
|
+
"""Remove the stop file"""
|
612
|
+
if not self.stop_path.exists():
|
613
|
+
return True, "Stop file does not exist."
|
492
614
|
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
497
|
-
try:
|
498
|
-
if thread.is_alive():
|
499
|
-
stack = traceback.format_stack(sys._current_frames()[thread.ident])
|
500
|
-
thread.join()
|
501
|
-
except Exception as e:
|
502
|
-
warn(traceback.format_exc())
|
503
|
-
raise KeyboardInterrupt()
|
615
|
+
try:
|
616
|
+
self.stop_path.unlink()
|
617
|
+
except Exception as e:
|
618
|
+
return False, f"Failed to remove stop file:\n{e}"
|
504
619
|
|
620
|
+
return True, "Success"
|
505
621
|
|
506
622
|
def _handle_sigterm(self, signal_number: int, stack_frame: 'frame') -> None:
|
507
623
|
"""
|
@@ -522,7 +638,6 @@ class Daemon:
|
|
522
638
|
_close_pools()
|
523
639
|
raise SystemExit(0)
|
524
640
|
|
525
|
-
|
526
641
|
def _send_signal(
|
527
642
|
self,
|
528
643
|
signal_to_send,
|
@@ -578,7 +693,6 @@ class Daemon:
|
|
578
693
|
+ ('s' if timeout != 1 else '') + '.'
|
579
694
|
)
|
580
695
|
|
581
|
-
|
582
696
|
def mkdir_if_not_exists(self, allow_dirty_run: bool = False):
|
583
697
|
"""Create the Daemon's directory.
|
584
698
|
If `allow_dirty_run` is `False` and the directory already exists,
|
@@ -618,7 +732,6 @@ class Daemon:
|
|
618
732
|
return None
|
619
733
|
return self._process
|
620
734
|
|
621
|
-
|
622
735
|
@property
|
623
736
|
def status(self) -> str:
|
624
737
|
"""
|
@@ -633,7 +746,7 @@ class Daemon:
|
|
633
746
|
return 'paused'
|
634
747
|
if self.process.status() == 'zombie':
|
635
748
|
raise psutil.NoSuchProcess(self.process.pid)
|
636
|
-
except psutil.NoSuchProcess:
|
749
|
+
except (psutil.NoSuchProcess, AttributeError):
|
637
750
|
if self.pid_path.exists():
|
638
751
|
try:
|
639
752
|
self.pid_path.unlink()
|
@@ -643,7 +756,6 @@ class Daemon:
|
|
643
756
|
|
644
757
|
return 'running'
|
645
758
|
|
646
|
-
|
647
759
|
@classmethod
|
648
760
|
def _get_path_from_daemon_id(cls, daemon_id: str) -> pathlib.Path:
|
649
761
|
"""
|
@@ -651,7 +763,6 @@ class Daemon:
|
|
651
763
|
"""
|
652
764
|
return DAEMON_RESOURCES_PATH / daemon_id
|
653
765
|
|
654
|
-
|
655
766
|
@property
|
656
767
|
def path(self) -> pathlib.Path:
|
657
768
|
"""
|
@@ -659,7 +770,6 @@ class Daemon:
|
|
659
770
|
"""
|
660
771
|
return self._get_path_from_daemon_id(self.daemon_id)
|
661
772
|
|
662
|
-
|
663
773
|
@classmethod
|
664
774
|
def _get_properties_path_from_daemon_id(cls, daemon_id: str) -> pathlib.Path:
|
665
775
|
"""
|
@@ -667,7 +777,6 @@ class Daemon:
|
|
667
777
|
"""
|
668
778
|
return cls._get_path_from_daemon_id(daemon_id) / 'properties.json'
|
669
779
|
|
670
|
-
|
671
780
|
@property
|
672
781
|
def properties_path(self) -> pathlib.Path:
|
673
782
|
"""
|
@@ -675,6 +784,12 @@ class Daemon:
|
|
675
784
|
"""
|
676
785
|
return self._get_properties_path_from_daemon_id(self.daemon_id)
|
677
786
|
|
787
|
+
@property
|
788
|
+
def stop_path(self) -> pathlib.Path:
|
789
|
+
"""
|
790
|
+
Return the path for the stop file (created when manually stopped).
|
791
|
+
"""
|
792
|
+
return self.path / '.stop.json'
|
678
793
|
|
679
794
|
@property
|
680
795
|
def log_path(self) -> pathlib.Path:
|
@@ -683,6 +798,19 @@ class Daemon:
|
|
683
798
|
"""
|
684
799
|
return LOGS_RESOURCES_PATH / (self.daemon_id + '.log')
|
685
800
|
|
801
|
+
@property
|
802
|
+
def stdin_file_path(self) -> pathlib.Path:
|
803
|
+
"""
|
804
|
+
Return the stdin file path.
|
805
|
+
"""
|
806
|
+
return self.path / 'input.stdin'
|
807
|
+
|
808
|
+
@property
|
809
|
+
def blocking_stdin_file_path(self) -> pathlib.Path:
|
810
|
+
"""
|
811
|
+
Return the stdin file path.
|
812
|
+
"""
|
813
|
+
return self.path / 'input.stdin.block'
|
686
814
|
|
687
815
|
@property
|
688
816
|
def log_offset_path(self) -> pathlib.Path:
|
@@ -691,24 +819,46 @@ class Daemon:
|
|
691
819
|
"""
|
692
820
|
return LOGS_RESOURCES_PATH / ('.' + self.daemon_id + '.log.offset')
|
693
821
|
|
694
|
-
|
695
822
|
@property
|
696
823
|
def rotating_log(self) -> RotatingFile:
|
824
|
+
"""
|
825
|
+
The rotating log file for the daemon's output.
|
826
|
+
"""
|
697
827
|
if '_rotating_log' in self.__dict__:
|
698
828
|
return self._rotating_log
|
699
829
|
|
830
|
+
write_timestamps = (
|
831
|
+
self.properties.get('logs', {}).get('write_timestamps', None)
|
832
|
+
)
|
833
|
+
if write_timestamps is None:
|
834
|
+
write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled')
|
835
|
+
|
700
836
|
self._rotating_log = RotatingFile(
|
701
837
|
self.log_path,
|
702
|
-
redirect_streams
|
703
|
-
write_timestamps
|
704
|
-
timestamp_format
|
838
|
+
redirect_streams=True,
|
839
|
+
write_timestamps=write_timestamps,
|
840
|
+
timestamp_format=get_config('jobs', 'logs', 'timestamps', 'format'),
|
705
841
|
)
|
706
842
|
return self._rotating_log
|
707
843
|
|
844
|
+
@property
|
845
|
+
def stdin_file(self):
|
846
|
+
"""
|
847
|
+
Return the file handler for the stdin file.
|
848
|
+
"""
|
849
|
+
if '_stdin_file' in self.__dict__:
|
850
|
+
return self._stdin_file
|
851
|
+
|
852
|
+
self._stdin_file = StdinFile(
|
853
|
+
self.stdin_file_path,
|
854
|
+
lock_file_path=self.blocking_stdin_file_path,
|
855
|
+
)
|
856
|
+
return self._stdin_file
|
708
857
|
|
709
858
|
@property
|
710
859
|
def log_text(self) -> Optional[str]:
|
711
|
-
"""
|
860
|
+
"""
|
861
|
+
Read the log files and return their contents.
|
712
862
|
Returns `None` if the log file does not exist.
|
713
863
|
"""
|
714
864
|
new_rotating_log = RotatingFile(
|
@@ -720,7 +870,6 @@ class Daemon:
|
|
720
870
|
)
|
721
871
|
return new_rotating_log.read()
|
722
872
|
|
723
|
-
|
724
873
|
def readlines(self) -> List[str]:
|
725
874
|
"""
|
726
875
|
Read the next log lines, persisting the cursor for later use.
|
@@ -731,7 +880,6 @@ class Daemon:
|
|
731
880
|
self._write_log_offset()
|
732
881
|
return lines
|
733
882
|
|
734
|
-
|
735
883
|
def _read_log_offset(self) -> Tuple[int, int]:
|
736
884
|
"""
|
737
885
|
Return the current log offset cursor.
|
@@ -749,7 +897,6 @@ class Daemon:
|
|
749
897
|
subfile_index, subfile_position = int(cursor_parts[0]), int(cursor_parts[1])
|
750
898
|
return subfile_index, subfile_position
|
751
899
|
|
752
|
-
|
753
900
|
def _write_log_offset(self) -> None:
|
754
901
|
"""
|
755
902
|
Write the current log offset file.
|
@@ -759,10 +906,10 @@ class Daemon:
|
|
759
906
|
subfile_position = self.rotating_log._cursor[1]
|
760
907
|
f.write(f"{subfile_index} {subfile_position}")
|
761
908
|
|
762
|
-
|
763
909
|
@property
|
764
910
|
def pid(self) -> Union[int, None]:
|
765
|
-
"""
|
911
|
+
"""
|
912
|
+
Read the PID file and return its contents.
|
766
913
|
Returns `None` if the PID file does not exist.
|
767
914
|
"""
|
768
915
|
if not self.pid_path.exists():
|
@@ -770,6 +917,8 @@ class Daemon:
|
|
770
917
|
try:
|
771
918
|
with open(self.pid_path, 'r', encoding='utf-8') as f:
|
772
919
|
text = f.read()
|
920
|
+
if len(text) == 0:
|
921
|
+
return None
|
773
922
|
pid = int(text.rstrip())
|
774
923
|
except Exception as e:
|
775
924
|
warn(e)
|
@@ -777,7 +926,6 @@ class Daemon:
|
|
777
926
|
pid = None
|
778
927
|
return pid
|
779
928
|
|
780
|
-
|
781
929
|
@property
|
782
930
|
def pid_path(self) -> pathlib.Path:
|
783
931
|
"""
|
@@ -785,7 +933,6 @@ class Daemon:
|
|
785
933
|
"""
|
786
934
|
return self.path / 'process.pid'
|
787
935
|
|
788
|
-
|
789
936
|
@property
|
790
937
|
def pid_lock(self) -> 'fasteners.InterProcessLock':
|
791
938
|
"""
|
@@ -798,7 +945,6 @@ class Daemon:
|
|
798
945
|
self._pid_lock = fasteners.InterProcessLock(self.pid_path)
|
799
946
|
return self._pid_lock
|
800
947
|
|
801
|
-
|
802
948
|
@property
|
803
949
|
def pickle_path(self) -> pathlib.Path:
|
804
950
|
"""
|
@@ -806,25 +952,27 @@ class Daemon:
|
|
806
952
|
"""
|
807
953
|
return self.path / 'pickle.pkl'
|
808
954
|
|
809
|
-
|
810
955
|
def read_properties(self) -> Optional[Dict[str, Any]]:
|
811
956
|
"""Read the properties JSON file and return the dictionary."""
|
812
957
|
if not self.properties_path.exists():
|
813
958
|
return None
|
814
959
|
try:
|
815
960
|
with open(self.properties_path, 'r', encoding='utf-8') as file:
|
816
|
-
|
961
|
+
properties = json.load(file)
|
817
962
|
except Exception as e:
|
818
|
-
|
819
|
-
|
963
|
+
properties = {}
|
964
|
+
|
965
|
+
return properties
|
820
966
|
|
821
967
|
def read_pickle(self) -> Daemon:
|
822
968
|
"""Read a Daemon's pickle file and return the `Daemon`."""
|
823
969
|
import pickle, traceback
|
824
970
|
if not self.pickle_path.exists():
|
825
971
|
error(f"Pickle file does not exist for daemon '{self.daemon_id}'.")
|
972
|
+
|
826
973
|
if self.pickle_path.stat().st_size == 0:
|
827
974
|
error(f"Pickle was empty for daemon '{self.daemon_id}'.")
|
975
|
+
|
828
976
|
try:
|
829
977
|
with open(self.pickle_path, 'rb') as pickle_file:
|
830
978
|
daemon = pickle.load(pickle_file)
|
@@ -837,21 +985,30 @@ class Daemon:
|
|
837
985
|
error(msg)
|
838
986
|
return daemon
|
839
987
|
|
840
|
-
|
841
988
|
@property
|
842
989
|
def properties(self) -> Dict[str, Any]:
|
843
990
|
"""
|
844
991
|
Return the contents of the properties JSON file.
|
845
992
|
"""
|
846
|
-
|
993
|
+
try:
|
994
|
+
_file_properties = self.read_properties()
|
995
|
+
except Exception:
|
996
|
+
traceback.print_exc()
|
997
|
+
_file_properties = {}
|
998
|
+
|
847
999
|
if not self._properties:
|
848
1000
|
self._properties = _file_properties
|
1001
|
+
|
849
1002
|
if self._properties is None:
|
850
1003
|
self._properties = {}
|
1004
|
+
|
851
1005
|
if _file_properties is not None:
|
852
|
-
self._properties = apply_patch_to_config(
|
853
|
-
|
1006
|
+
self._properties = apply_patch_to_config(
|
1007
|
+
_file_properties,
|
1008
|
+
self._properties,
|
1009
|
+
)
|
854
1010
|
|
1011
|
+
return self._properties
|
855
1012
|
|
856
1013
|
@property
|
857
1014
|
def hidden(self) -> bool:
|
@@ -860,12 +1017,14 @@ class Daemon:
|
|
860
1017
|
"""
|
861
1018
|
return self.daemon_id.startswith('_') or self.daemon_id.startswith('.')
|
862
1019
|
|
863
|
-
|
864
1020
|
def write_properties(self) -> SuccessTuple:
|
865
1021
|
"""Write the properties dictionary to the properties JSON file
|
866
1022
|
(only if self.properties exists).
|
867
1023
|
"""
|
868
|
-
success, msg =
|
1024
|
+
success, msg = (
|
1025
|
+
False,
|
1026
|
+
f"No properties to write for daemon '{self.daemon_id}'."
|
1027
|
+
)
|
869
1028
|
if self.properties is not None:
|
870
1029
|
try:
|
871
1030
|
self.path.mkdir(parents=True, exist_ok=True)
|
@@ -876,7 +1035,6 @@ class Daemon:
|
|
876
1035
|
success, msg = False, str(e)
|
877
1036
|
return success, msg
|
878
1037
|
|
879
|
-
|
880
1038
|
def write_pickle(self) -> SuccessTuple:
|
881
1039
|
"""Write the pickle file for the daemon."""
|
882
1040
|
import pickle, traceback
|
@@ -892,9 +1050,9 @@ class Daemon:
|
|
892
1050
|
|
893
1051
|
|
894
1052
|
def _setup(
|
895
|
-
|
896
|
-
|
897
|
-
|
1053
|
+
self,
|
1054
|
+
allow_dirty_run: bool = False,
|
1055
|
+
) -> None:
|
898
1056
|
"""
|
899
1057
|
Update properties before starting the Daemon.
|
900
1058
|
"""
|
@@ -918,7 +1076,6 @@ class Daemon:
|
|
918
1076
|
if not _write_pickle_success_tuple[0]:
|
919
1077
|
error(_write_pickle_success_tuple[1])
|
920
1078
|
|
921
|
-
|
922
1079
|
def cleanup(self, keep_logs: bool = False) -> SuccessTuple:
|
923
1080
|
"""
|
924
1081
|
Remove a daemon's directory after execution.
|
@@ -962,9 +1119,9 @@ class Daemon:
|
|
962
1119
|
|
963
1120
|
|
964
1121
|
def get_check_timeout_interval_seconds(
|
965
|
-
|
966
|
-
|
967
|
-
|
1122
|
+
self,
|
1123
|
+
check_timeout_interval: Union[int, float, None] = None,
|
1124
|
+
) -> Union[int, float]:
|
968
1125
|
"""
|
969
1126
|
Return the interval value to check the status of timeouts.
|
970
1127
|
"""
|
@@ -972,6 +1129,33 @@ class Daemon:
|
|
972
1129
|
return check_timeout_interval
|
973
1130
|
return get_config('jobs', 'check_timeout_interval_seconds')
|
974
1131
|
|
1132
|
+
@property
|
1133
|
+
def target_args(self) -> Union[Tuple[Any], None]:
|
1134
|
+
"""
|
1135
|
+
Return the positional arguments to pass to the target function.
|
1136
|
+
"""
|
1137
|
+
target_args = (
|
1138
|
+
self.__dict__.get('_target_args', None)
|
1139
|
+
or self.properties.get('target', {}).get('args', None)
|
1140
|
+
)
|
1141
|
+
if target_args is None:
|
1142
|
+
return tuple([])
|
1143
|
+
|
1144
|
+
return tuple(target_args)
|
1145
|
+
|
1146
|
+
@property
|
1147
|
+
def target_kw(self) -> Union[Dict[str, Any], None]:
|
1148
|
+
"""
|
1149
|
+
Return the keyword arguments to pass to the target function.
|
1150
|
+
"""
|
1151
|
+
target_kw = (
|
1152
|
+
self.__dict__.get('_target_kw', None)
|
1153
|
+
or self.properties.get('target', {}).get('kw', None)
|
1154
|
+
)
|
1155
|
+
if target_kw is None:
|
1156
|
+
return {}
|
1157
|
+
|
1158
|
+
return {key: val for key, val in target_kw.items()}
|
975
1159
|
|
976
1160
|
def __getstate__(self):
|
977
1161
|
"""
|
@@ -1022,4 +1206,4 @@ class Daemon:
|
|
1022
1206
|
return self.daemon_id == other.daemon_id
|
1023
1207
|
|
1024
1208
|
def __hash__(self):
|
1025
|
-
return hash(self.daemon_id)
|
1209
|
+
return hash(self.daemon_id)
|