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.
Files changed (61) hide show
  1. meerschaum/__init__.py +4 -1
  2. meerschaum/__main__.py +10 -5
  3. meerschaum/_internal/arguments/_parser.py +44 -15
  4. meerschaum/_internal/entry.py +35 -14
  5. meerschaum/_internal/shell/Shell.py +155 -53
  6. meerschaum/_internal/shell/updates.py +175 -0
  7. meerschaum/actions/api.py +12 -12
  8. meerschaum/actions/attach.py +95 -0
  9. meerschaum/actions/delete.py +35 -26
  10. meerschaum/actions/register.py +19 -5
  11. meerschaum/actions/show.py +119 -148
  12. meerschaum/actions/start.py +85 -75
  13. meerschaum/actions/stop.py +68 -39
  14. meerschaum/actions/sync.py +3 -3
  15. meerschaum/actions/upgrade.py +28 -36
  16. meerschaum/api/_events.py +18 -1
  17. meerschaum/api/_oauth2.py +2 -0
  18. meerschaum/api/_websockets.py +2 -2
  19. meerschaum/api/dash/jobs.py +5 -2
  20. meerschaum/api/routes/__init__.py +1 -0
  21. meerschaum/api/routes/_actions.py +122 -44
  22. meerschaum/api/routes/_jobs.py +340 -0
  23. meerschaum/api/routes/_pipes.py +25 -25
  24. meerschaum/config/_default.py +1 -0
  25. meerschaum/config/_formatting.py +1 -0
  26. meerschaum/config/_paths.py +5 -0
  27. meerschaum/config/_shell.py +84 -67
  28. meerschaum/config/_version.py +1 -1
  29. meerschaum/config/static/__init__.py +9 -0
  30. meerschaum/connectors/__init__.py +9 -11
  31. meerschaum/connectors/api/APIConnector.py +18 -1
  32. meerschaum/connectors/api/_actions.py +60 -71
  33. meerschaum/connectors/api/_jobs.py +260 -0
  34. meerschaum/connectors/api/_misc.py +1 -1
  35. meerschaum/connectors/api/_request.py +13 -9
  36. meerschaum/connectors/parse.py +23 -7
  37. meerschaum/core/Pipe/_sync.py +3 -0
  38. meerschaum/plugins/__init__.py +89 -5
  39. meerschaum/utils/daemon/Daemon.py +333 -149
  40. meerschaum/utils/daemon/FileDescriptorInterceptor.py +19 -10
  41. meerschaum/utils/daemon/RotatingFile.py +18 -7
  42. meerschaum/utils/daemon/StdinFile.py +110 -0
  43. meerschaum/utils/daemon/__init__.py +40 -27
  44. meerschaum/utils/formatting/__init__.py +83 -37
  45. meerschaum/utils/formatting/_jobs.py +118 -51
  46. meerschaum/utils/formatting/_shell.py +6 -0
  47. meerschaum/utils/jobs/_Job.py +684 -0
  48. meerschaum/utils/jobs/__init__.py +245 -0
  49. meerschaum/utils/misc.py +18 -17
  50. meerschaum/utils/packages/__init__.py +21 -15
  51. meerschaum/utils/packages/_packages.py +2 -2
  52. meerschaum/utils/prompt.py +20 -7
  53. meerschaum/utils/schedule.py +21 -15
  54. {meerschaum-2.2.6.dist-info → meerschaum-2.3.0.dev1.dist-info}/METADATA +9 -9
  55. {meerschaum-2.2.6.dist-info → meerschaum-2.3.0.dev1.dist-info}/RECORD +61 -54
  56. {meerschaum-2.2.6.dist-info → meerschaum-2.3.0.dev1.dist-info}/WHEEL +1 -1
  57. {meerschaum-2.2.6.dist-info → meerschaum-2.3.0.dev1.dist-info}/LICENSE +0 -0
  58. {meerschaum-2.2.6.dist-info → meerschaum-2.3.0.dev1.dist-info}/NOTICE +0 -0
  59. {meerschaum-2.2.6.dist-info → meerschaum-2.3.0.dev1.dist-info}/entry_points.txt +0 -0
  60. {meerschaum-2.2.6.dist-info → meerschaum-2.3.0.dev1.dist-info}/top_level.txt +0 -0
  61. {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
- from meerschaum.utils.typing import Optional, Dict, Any, SuccessTuple, Callable, List, Union
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 DAEMON_RESOURCES_PATH, LOGS_RESOURCES_PATH
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: Optional[List[str]] = None,
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: Optional[List[str]], default None
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
- propterties: Optional[Dict[str, Any]], default None
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
- raise Exception(
92
- f"Daemon '{self.daemon_id}' does not exist. "
93
- + "Pass a target to create a new Daemon."
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(f"Cannot create a Daemon without a target.")
208
+ error("Cannot create a Daemon without a target.")
98
209
  self.target = target
99
- if 'target_args' not in self.__dict__:
100
- self.target_args = target_args if target_args is not None else []
101
- if 'target_kw' not in self.__dict__:
102
- self.target_kw = target_kw if target_kw is not None else {}
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' : self.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
- self,
123
- keep_daemon_output: bool = True,
124
- allow_dirty_run: bool = False,
125
- ) -> Any:
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 = self.pid_lock,
158
- stdout = self.rotating_log,
159
- stderr = self.rotating_log,
160
- working_directory = os.getcwd(),
161
- detach_process = True,
162
- files_preserve = list(self.rotating_log.subfile_objects.values()),
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(f"Exception in daemon target function: {e}", stacklevel=3)
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
- if keep_daemon_output:
197
- self._capture_process_timestamp('ended')
198
- else:
199
- self.cleanup()
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
- self,
224
- process_key: str,
225
- write_properties: bool = True,
226
- ) -> None:
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
- self,
253
- keep_daemon_output: bool = True,
254
- allow_dirty_run: bool = False,
255
- debug: bool = False,
256
- ) -> SuccessTuple:
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
- + f"allow_dirty_run=True)"
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
- def kill(self, timeout: Optional[int] = 8) -> SuccessTuple:
306
- """Forcibly terminate a running daemon.
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._capture_process_timestamp('ended')
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._capture_process_timestamp('ended')
471
+ self._write_stop_file('quit')
351
472
  return signal_success, signal_msg
352
473
 
353
-
354
474
  def pause(
355
- self,
356
- timeout: Union[int, float, None] = None,
357
- check_timeout_interval: Union[float, int, None] = None,
358
- ) -> SuccessTuple:
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
- self,
420
- timeout: Union[int, float, None] = None,
421
- check_timeout_interval: Union[float, int, None] = None,
422
- ) -> SuccessTuple:
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
- def _handle_interrupt(self, signal_number: int, stack_frame: 'frame') -> None:
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
- _close_pools()
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
- import threading
494
- for thread in threading.enumerate():
495
- if thread.name == 'MainThread':
496
- continue
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 = True,
703
- write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled'),
704
- timestamp_format = get_config('jobs', 'logs', 'timestamps', '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
- """Read the log files and return their contents.
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
- """Read the PID file and return its contents.
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
- return json.load(file)
961
+ properties = json.load(file)
817
962
  except Exception as e:
818
- return {}
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
- _file_properties = self.read_properties()
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(_file_properties, self._properties)
853
- return self._properties
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 = False, f"No properties to write for daemon '{self.daemon_id}'."
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
- self,
896
- allow_dirty_run: bool = False,
897
- ) -> None:
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
- self,
966
- check_timeout_interval: Union[int, float, None] = None,
967
- ) -> Union[int, float]:
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)