meerschaum 2.3.0rc1__py3-none-any.whl → 2.3.0rc3__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 (30) hide show
  1. meerschaum/_internal/arguments/__init__.py +1 -1
  2. meerschaum/_internal/arguments/_parse_arguments.py +56 -2
  3. meerschaum/_internal/entry.py +63 -22
  4. meerschaum/_internal/shell/Shell.py +80 -15
  5. meerschaum/actions/delete.py +17 -7
  6. meerschaum/actions/start.py +2 -6
  7. meerschaum/api/dash/callbacks/jobs.py +36 -44
  8. meerschaum/api/dash/jobs.py +24 -15
  9. meerschaum/api/routes/_actions.py +6 -5
  10. meerschaum/api/routes/_jobs.py +19 -1
  11. meerschaum/config/_jobs.py +1 -1
  12. meerschaum/config/_version.py +1 -1
  13. meerschaum/config/static/__init__.py +2 -0
  14. meerschaum/connectors/api/APIConnector.py +1 -0
  15. meerschaum/connectors/api/_jobs.py +13 -2
  16. meerschaum/connectors/parse.py +0 -1
  17. meerschaum/jobs/_Job.py +24 -7
  18. meerschaum/jobs/_SystemdExecutor.py +118 -49
  19. meerschaum/jobs/__init__.py +41 -12
  20. meerschaum/utils/daemon/Daemon.py +14 -0
  21. meerschaum/utils/daemon/_names.py +1 -1
  22. meerschaum/utils/formatting/_jobs.py +2 -14
  23. {meerschaum-2.3.0rc1.dist-info → meerschaum-2.3.0rc3.dist-info}/METADATA +1 -1
  24. {meerschaum-2.3.0rc1.dist-info → meerschaum-2.3.0rc3.dist-info}/RECORD +30 -30
  25. {meerschaum-2.3.0rc1.dist-info → meerschaum-2.3.0rc3.dist-info}/LICENSE +0 -0
  26. {meerschaum-2.3.0rc1.dist-info → meerschaum-2.3.0rc3.dist-info}/NOTICE +0 -0
  27. {meerschaum-2.3.0rc1.dist-info → meerschaum-2.3.0rc3.dist-info}/WHEEL +0 -0
  28. {meerschaum-2.3.0rc1.dist-info → meerschaum-2.3.0rc3.dist-info}/entry_points.txt +0 -0
  29. {meerschaum-2.3.0rc1.dist-info → meerschaum-2.3.0rc3.dist-info}/top_level.txt +0 -0
  30. {meerschaum-2.3.0rc1.dist-info → meerschaum-2.3.0rc3.dist-info}/zip-safe +0 -0
@@ -15,12 +15,12 @@ from meerschaum.api.dash.users import is_session_authenticated
15
15
  from meerschaum.api import CHECK_UPDATE
16
16
  dbc = attempt_import('dash_bootstrap_components', lazy=False, check_update=CHECK_UPDATE)
17
17
  html, dcc = import_html(), import_dcc()
18
- dateutil_parser = attempt_import('dateutil.parser', check_update=CHECK_UPDATE)
19
18
  from meerschaum.jobs import (
20
19
  get_jobs,
21
20
  get_running_jobs,
22
21
  get_paused_jobs,
23
22
  get_stopped_jobs,
23
+ get_executor_keys_from_context,
24
24
  Job,
25
25
  )
26
26
  from meerschaum.config import get_config
@@ -33,20 +33,21 @@ STATUS_EMOJI: Dict[str, str] = {
33
33
  'dne': get_config('formatting', 'emoji', 'failure')
34
34
  }
35
35
 
36
+ EXECUTOR_KEYS: str = get_executor_keys_from_context()
37
+
36
38
  def get_jobs_cards(state: WebState):
37
39
  """
38
40
  Build cards and alerts lists for jobs.
39
41
  """
40
- jobs = get_jobs(include_hidden=False)
42
+ jobs = get_jobs(executor_keys=EXECUTOR_KEYS, include_hidden=False)
41
43
  session_id = state['session-store.data'].get('session-id', None)
42
44
  is_authenticated = is_session_authenticated(session_id)
43
45
 
44
46
  cards = []
45
47
 
46
48
  for name, job in jobs.items():
47
- d = job.daemon
48
49
  footer_children = html.Div(
49
- build_process_timestamps_children(d),
50
+ build_process_timestamps_children(job),
50
51
  id = {'type': 'process-timestamps-div', 'index': name},
51
52
  )
52
53
  follow_logs_button = dbc.DropdownMenuItem(
@@ -62,7 +63,7 @@ def get_jobs_cards(state: WebState):
62
63
  )
63
64
  header_children = [
64
65
  html.Div(
65
- build_status_children(d),
66
+ build_status_children(job),
66
67
  id={'type': 'manage-job-status-div', 'index': name},
67
68
  style={'float': 'left'},
68
69
  ),
@@ -83,15 +84,16 @@ def get_jobs_cards(state: WebState):
83
84
  html.H4(html.B(name), className="card-title"),
84
85
  html.Div(
85
86
  html.P(
86
- d.label,
87
+ job.label,
87
88
  className="card-text job-card-text",
88
89
  style={"word-wrap": "break-word"},
90
+ id={'type': 'job-label-p', 'index': name},
89
91
  ),
90
92
  style={"white-space": "pre-wrap"},
91
93
  ),
92
94
  html.Div(
93
95
  (
94
- build_manage_job_buttons_div_children(d)
96
+ build_manage_job_buttons_div_children(job)
95
97
  if is_authenticated
96
98
  else []
97
99
  ),
@@ -179,13 +181,14 @@ def build_manage_job_buttons(job: Job):
179
181
  },
180
182
  )
181
183
  buttons = []
182
- if job.status in ('stopped', 'paused'):
184
+ status = job.status
185
+ if status in ('stopped', 'paused'):
183
186
  buttons.append(start_button)
184
- if job.status == 'stopped':
187
+ if status == 'stopped':
185
188
  buttons.append(delete_button)
186
- if job.status in ('running',):
189
+ if status in ('running',):
187
190
  buttons.append(pause_button)
188
- if job.status in ('running', 'paused'):
191
+ if status in ('running', 'paused'):
189
192
  buttons.append(stop_button)
190
193
 
191
194
  return buttons
@@ -193,7 +196,7 @@ def build_manage_job_buttons(job: Job):
193
196
 
194
197
  def build_status_children(job: Job) -> List[html.P]:
195
198
  """
196
- Return the status HTML component for this daemon.
199
+ Return the status HTML component for this Job.
197
200
  """
198
201
  if job is None:
199
202
  return STATUS_EMOJI['dne']
@@ -217,10 +220,16 @@ def build_process_timestamps_children(job: Job) -> List[dbc.Row]:
217
220
  return []
218
221
 
219
222
  children = []
220
- for timestamp_key, timestamp_val in sorted_dict(
221
- job.daemon.properties.get('process', {})
223
+ for timestamp_key, timestamp in sorted_dict(
224
+ {
225
+ 'began': job.began,
226
+ 'paused': job.paused,
227
+ 'ended': job.ended,
228
+ }
222
229
  ).items():
223
- timestamp = dateutil_parser.parse(timestamp_val)
230
+ if timestamp is None:
231
+ continue
232
+
224
233
  timestamp_str = timestamp.strftime('%Y-%m-%d %H:%M UTC')
225
234
  children.append(
226
235
  dbc.Row(
@@ -26,6 +26,7 @@ from meerschaum.actions import actions
26
26
  import meerschaum.core
27
27
  from meerschaum.config import get_config
28
28
  from meerschaum._internal.arguments._parse_arguments import parse_dict_to_sysargs, parse_arguments
29
+ from meerschaum.api.routes._jobs import clean_sysargs
29
30
 
30
31
  actions_endpoint = endpoints['actions']
31
32
 
@@ -115,11 +116,11 @@ async def do_action_websocket(websocket: WebSocket):
115
116
  if not auth_success:
116
117
  await websocket.close()
117
118
 
118
- sysargs = await websocket.receive_json()
119
- kwargs = parse_arguments(sysargs)
120
- _ = kwargs.pop('executor_keys', None)
121
- _ = kwargs.pop('shell', None)
122
- sysargs = parse_dict_to_sysargs(kwargs)
119
+ sysargs = clean_sysargs(await websocket.receive_json())
120
+ # kwargs = parse_arguments(sysargs)
121
+ # _ = kwargs.pop('executor_keys', None)
122
+ # _ = kwargs.pop('shell', None)
123
+ # sysargs = parse_dict_to_sysargs(kwargs)
123
124
 
124
125
  job = Job(
125
126
  job_name,
@@ -107,6 +107,24 @@ def get_job(
107
107
  }
108
108
 
109
109
 
110
+ def clean_sysargs(sysargs: List[str]) -> List[str]:
111
+ """
112
+ Remove the executor flag or leading `api {label}` action.
113
+ """
114
+ clean_sysargs = []
115
+ executor_flag = False
116
+ for arg in sysargs:
117
+ if arg in ('-e', '--executor', 'api'):
118
+ executor_flag = True
119
+ continue
120
+ if executor_flag:
121
+ executor_flag = False
122
+ continue
123
+
124
+ clean_sysargs.append(arg)
125
+ return clean_sysargs
126
+
127
+
110
128
  @app.post(endpoints['jobs'] + '/{name}', tags=['Jobs'])
111
129
  def create_job(
112
130
  name: str,
@@ -118,7 +136,7 @@ def create_job(
118
136
  """
119
137
  Create and start a new job.
120
138
  """
121
- job = Job(name, sysargs, executor_keys=EXECUTOR_KEYS)
139
+ job = Job(name, clean_sysargs(sysargs), executor_keys=EXECUTOR_KEYS)
122
140
  if job.exists():
123
141
  raise fastapi.HTTPException(
124
142
  status_code=409,
@@ -7,7 +7,7 @@ Default configuration for jobs.
7
7
  """
8
8
 
9
9
  default_jobs_config = {
10
- 'timeout_seconds': 8,
10
+ 'timeout_seconds': 4,
11
11
  'check_timeout_interval_seconds': 0.1,
12
12
  'terminal': {
13
13
  'lines': 40,
@@ -2,4 +2,4 @@
2
2
  Specify the Meerschaum release version.
3
3
  """
4
4
 
5
- __version__ = "2.3.0rc1"
5
+ __version__ = "2.3.0rc3"
@@ -88,6 +88,8 @@ STATIC_CONFIG: Dict[str, Any] = {
88
88
  ),
89
89
  'underscore_standin': '<UNDERSCORE>', ### Temporary replacement for parsing.
90
90
  'failure_key': '_argparse_exception',
91
+ 'and_key': '+',
92
+ 'escaped_and_key': '++',
91
93
  },
92
94
  'urls': {
93
95
  'get-pip.py': 'https://bootstrap.pypa.io/get-pip.py',
@@ -90,6 +90,7 @@ class APIConnector(Connector):
90
90
  get_job_is_blocking_on_stdin,
91
91
  get_job_began,
92
92
  get_job_ended,
93
+ get_job_paused,
93
94
  get_job_status,
94
95
  )
95
96
 
@@ -112,7 +112,7 @@ def get_job_status(self, name: str, debug: bool = False) -> str:
112
112
  metadata = self.get_job_metadata(name, debug=debug)
113
113
  return metadata.get('status', 'stopped')
114
114
 
115
- def get_job_began(self, name: str, debug: bool = False) -> str:
115
+ def get_job_began(self, name: str, debug: bool = False) -> Union[str, None]:
116
116
  """
117
117
  Return a job's `began` timestamp, if it exists.
118
118
  """
@@ -123,7 +123,7 @@ def get_job_began(self, name: str, debug: bool = False) -> str:
123
123
 
124
124
  return began_str
125
125
 
126
- def get_job_ended(self, name: str, debug: bool = False) -> str:
126
+ def get_job_ended(self, name: str, debug: bool = False) -> Union[str, None]:
127
127
  """
128
128
  Return a job's `ended` timestamp, if it exists.
129
129
  """
@@ -134,6 +134,17 @@ def get_job_ended(self, name: str, debug: bool = False) -> str:
134
134
 
135
135
  return ended_str
136
136
 
137
+ def get_job_paused(self, name: str, debug: bool = False) -> Union[str, None]:
138
+ """
139
+ Return a job's `paused` timestamp, if it exists.
140
+ """
141
+ properties = self.get_job_properties(name, debug=debug)
142
+ paused_str = properties.get('daemon', {}).get('paused', None)
143
+ if paused_str is None:
144
+ return None
145
+
146
+ return paused_str
147
+
137
148
  def get_job_exists(self, name: str, debug: bool = False) -> bool:
138
149
  """
139
150
  Return whether a job exists.
@@ -120,7 +120,6 @@ def parse_repo_keys(keys: Optional[str] = None, **kw):
120
120
  def parse_executor_keys(keys: Optional[str] = None, **kw):
121
121
  """Parse the executor keys into an APIConnector or string."""
122
122
  from meerschaum.jobs import get_executor_keys_from_context
123
- from meerschaum.config import get_config
124
123
  if keys is None:
125
124
  keys = get_executor_keys_from_context()
126
125
 
meerschaum/jobs/_Job.py CHANGED
@@ -91,6 +91,14 @@ class Job:
91
91
  if isinstance(sysargs, str):
92
92
  sysargs = shlex.split(sysargs)
93
93
 
94
+ and_key = STATIC_CONFIG['system']['arguments']['and_key']
95
+ escaped_and_key = STATIC_CONFIG['system']['arguments']['escaped_and_key']
96
+ if sysargs:
97
+ sysargs = [
98
+ (arg if arg != escaped_and_key else and_key)
99
+ for arg in sysargs
100
+ ]
101
+
94
102
  ### NOTE: 'local' and 'systemd' executors are being coalesced.
95
103
  if executor_keys is None:
96
104
  from meerschaum.jobs import get_executor_keys_from_context
@@ -732,7 +740,7 @@ class Job:
732
740
  The datetime when the job began running.
733
741
  """
734
742
  if self.executor is not None:
735
- began_str = self.executor.get_job_began(name)
743
+ began_str = self.executor.get_job_began(self.name)
736
744
  if began_str is None:
737
745
  return None
738
746
  return (
@@ -754,6 +762,8 @@ class Job:
754
762
  """
755
763
  if self.executor is not None:
756
764
  ended_str = self.executor.get_job_ended(self.name)
765
+ if ended_str is None:
766
+ return None
757
767
  return (
758
768
  datetime.fromisoformat(ended_str)
759
769
  .astimezone(timezone.utc)
@@ -771,6 +781,16 @@ class Job:
771
781
  """
772
782
  The datetime when the job was suspended while running.
773
783
  """
784
+ if self.executor is not None:
785
+ paused_str = self.executor.get_job_paused(self.name)
786
+ if paused_str is None:
787
+ return None
788
+ return (
789
+ datetime.fromisoformat(paused_str)
790
+ .astimezone(timezone.utc)
791
+ .replace(tzinfo=None)
792
+ )
793
+
774
794
  paused_str = self.daemon.properties.get('process', {}).get('paused', None)
775
795
  if paused_str is None:
776
796
  return None
@@ -788,11 +808,8 @@ class Job:
788
808
  if not self.daemon.stop_path.exists():
789
809
  return None
790
810
 
791
- try:
792
- with open(self.daemon.stop_path, 'r', encoding='utf-8') as f:
793
- stop_data = json.load(f)
794
- except Exception as e:
795
- warn(f"Failed to read stop file for {self}:\n{e}")
811
+ stop_data = self.daemon._read_stop_file()
812
+ if not stop_data:
796
813
  return None
797
814
 
798
815
  stop_time_str = stop_data.get('stop_time', None)
@@ -831,7 +848,7 @@ class Job:
831
848
  """
832
849
  Return the job's Daemon label (joined sysargs).
833
850
  """
834
- return shlex.join(self.sysargs)
851
+ return shlex.join(self.sysargs).replace(' + ', '\n+ ')
835
852
 
836
853
  def __str__(self) -> str:
837
854
  sysargs = self.sysargs
@@ -19,9 +19,10 @@ from functools import partial
19
19
  import meerschaum as mrsm
20
20
  from meerschaum.jobs import Job, Executor, make_executor
21
21
  from meerschaum.utils.typing import Dict, Any, List, SuccessTuple, Union, Optional, Callable
22
+ from meerschaum.config import get_config
22
23
  from meerschaum.config.static import STATIC_CONFIG
23
24
  from meerschaum.utils.warnings import warn, dprint
24
- from meerschaum._internal.arguments._parse_arguments import parse_arguments, parse_dict_to_sysargs
25
+ from meerschaum._internal.arguments._parse_arguments import parse_arguments
25
26
 
26
27
  JOB_METADATA_CACHE_SECONDS: int = STATIC_CONFIG['api']['jobs']['metadata_cache_seconds']
27
28
 
@@ -36,11 +37,11 @@ class SystemdExecutor(Executor):
36
37
  """
37
38
  Return a list of existing jobs, including hidden ones.
38
39
  """
39
- from meerschaum.config.paths import SYSTEMD_USER_RESOURCES_PATH
40
+ from meerschaum.config.paths import SYSTEMD_ROOT_RESOURCES_PATH
40
41
  return [
41
42
  service_name[len('mrsm-'):(-1 * len('.service'))]
42
- for service_name in os.listdir(SYSTEMD_USER_RESOURCES_PATH)
43
- if service_name.startswith('mrsm-')
43
+ for service_name in os.listdir(SYSTEMD_ROOT_RESOURCES_PATH)
44
+ if service_name.startswith('mrsm-') and service_name.endswith('.service')
44
45
  ]
45
46
 
46
47
  def get_job_exists(self, name: str, debug: bool = False) -> bool:
@@ -51,10 +52,10 @@ class SystemdExecutor(Executor):
51
52
  if debug:
52
53
  dprint(f'Existing services: {user_services}')
53
54
  return name in user_services
54
-
55
+
55
56
  def get_jobs(self, debug: bool = False) -> Dict[str, Job]:
56
57
  """
57
- Return a dictionary of `systemd` Jobs (excluding hidden jobs).
58
+ Return a dictionary of `systemd` Jobs (including hidden jobs).
58
59
  """
59
60
  user_services = self.get_job_names(debug=debug)
60
61
  jobs = {
@@ -64,7 +65,6 @@ class SystemdExecutor(Executor):
64
65
  return {
65
66
  name: job
66
67
  for name, job in jobs.items()
67
- if not job.hidden
68
68
  }
69
69
 
70
70
  def get_service_name(self, name: str, debug: bool = False) -> str:
@@ -73,13 +73,20 @@ class SystemdExecutor(Executor):
73
73
  """
74
74
  return f"mrsm-{name.replace(' ', '-')}.service"
75
75
 
76
- def get_service_file_path(self, name: str, debug: bool = False) -> pathlib.Path:
76
+ def get_service_symlink_file_path(self, name: str, debug: bool = False) -> pathlib.Path:
77
77
  """
78
- Return the path to a Job's service file.
78
+ Return the path to where to create the service symlink.
79
79
  """
80
80
  from meerschaum.config.paths import SYSTEMD_USER_RESOURCES_PATH
81
81
  return SYSTEMD_USER_RESOURCES_PATH / self.get_service_name(name, debug=debug)
82
82
 
83
+ def get_service_file_path(self, name: str, debug: bool = False) -> pathlib.Path:
84
+ """
85
+ Return the path to a Job's service file.
86
+ """
87
+ from meerschaum.config.paths import SYSTEMD_ROOT_RESOURCES_PATH
88
+ return SYSTEMD_ROOT_RESOURCES_PATH / self.get_service_name(name, debug=debug)
89
+
83
90
  def get_service_logs_path(self, name: str, debug: bool = False) -> pathlib.Path:
84
91
  """
85
92
  Return the path to direct service logs to.
@@ -135,7 +142,8 @@ class SystemdExecutor(Executor):
135
142
  STATIC_CONFIG['environment']['systemd_log_path']: service_logs_path.as_posix(),
136
143
  STATIC_CONFIG['environment']['systemd_result_path']: result_path.as_posix(),
137
144
  STATIC_CONFIG['environment']['systemd_stdin_path']: socket_path.as_posix(),
138
-
145
+ 'LINES': get_config('jobs', 'terminal', 'lines'),
146
+ 'COLUMNS': get_config('jobs', 'terminal', 'columns'),
139
147
  })
140
148
  environment_lines = [
141
149
  f"Environment={key}={val}"
@@ -150,8 +158,7 @@ class SystemdExecutor(Executor):
150
158
  "\n"
151
159
  "[Service]\n"
152
160
  f"ExecStart={exec_str}\n"
153
- "KillSignal=SIGINT\n"
154
- "TimeoutStopSpec=8\n"
161
+ "KillSignal=SIGTERM\n"
155
162
  "Restart=always\n"
156
163
  "RestartPreventExitStatus=0\n"
157
164
  f"SyslogIdentifier={service_name}\n"
@@ -180,23 +187,11 @@ class SystemdExecutor(Executor):
180
187
  )
181
188
  return socket_text
182
189
 
183
- @staticmethod
184
- def clean_sysargs(sysargs: List[str]) -> List[str]:
185
- """
186
- Return a sysargs list with the executor key set to 'local'.
187
- """
188
- kwargs = parse_arguments(sysargs)
189
- _ = kwargs.pop('executor_keys', None)
190
- _ = kwargs.pop('systemd', None)
191
- return parse_dict_to_sysargs(kwargs)
192
-
193
190
  def get_hidden_job(self, name: str, sysargs: Optional[List[str]] = None, debug: bool = False):
194
191
  """
195
192
  Return the hidden "sister" job to store a job's parameters.
196
193
  """
197
194
  hidden_name = f'.systemd-{self.get_service_name(name, debug=debug)}'
198
- if sysargs:
199
- sysargs = self.clean_sysargs(sysargs)
200
195
 
201
196
  return Job(
202
197
  hidden_name,
@@ -266,7 +261,10 @@ class SystemdExecutor(Executor):
266
261
  return None
267
262
 
268
263
  psutil = mrsm.attempt_import('psutil')
269
- return psutil.Process(pid)
264
+ try:
265
+ return psutil.Process(pid)
266
+ except Exception:
267
+ return None
270
268
 
271
269
  def get_job_status(self, name: str, debug: bool = False) -> str:
272
270
  """
@@ -278,13 +276,19 @@ class SystemdExecutor(Executor):
278
276
  debug=debug,
279
277
  )
280
278
 
279
+ if output == 'activating':
280
+ return 'running'
281
+
281
282
  if output == 'active':
282
283
  process = self.get_job_process(name, debug=debug)
283
284
  if process is None:
284
285
  return 'stopped'
285
286
 
286
- if process.status() == 'stopped':
287
- return 'paused'
287
+ try:
288
+ if process.status() == 'stopped':
289
+ return 'paused'
290
+ except Exception:
291
+ return 'stopped'
288
292
 
289
293
  return 'running'
290
294
 
@@ -309,12 +313,15 @@ class SystemdExecutor(Executor):
309
313
  return None
310
314
 
311
315
  pid_str = output[len('MainPID='):]
316
+ if pid_str == '0':
317
+ return None
318
+
312
319
  if is_int(pid_str):
313
320
  return int(pid_str)
314
321
 
315
322
  return None
316
323
 
317
- def get_job_began(self, name: str, debug: bool = False) -> Union[datetime, None]:
324
+ def get_job_began(self, name: str, debug: bool = False) -> Union[str, None]:
318
325
  """
319
326
  Return when a job began running.
320
327
  """
@@ -330,10 +337,62 @@ class SystemdExecutor(Executor):
330
337
  if not output.startswith('ActiveEnterTimestamp'):
331
338
  return None
332
339
 
340
+ dt_str = output.split('=')[-1]
341
+ if not dt_str:
342
+ return None
343
+
333
344
  dateutil_parser = mrsm.attempt_import('dateutil.parser')
334
- dt = dateutil_parser.parse(output.split('=')[-1])
345
+ try:
346
+ dt = dateutil_parser.parse(dt_str)
347
+ except Exception as e:
348
+ warn(f"Cannot parse '{output}' as a datetime:\n{e}")
349
+ return None
350
+
335
351
  return dt.astimezone(timezone.utc).isoformat()
336
352
 
353
+ def get_job_ended(self, name: str, debug: bool = False) -> Union[str, None]:
354
+ """
355
+ Return when a job began running.
356
+ """
357
+ output = self.run_command(
358
+ [
359
+ 'show',
360
+ self.get_service_name(name, debug=debug),
361
+ '--property=InactiveEnterTimestamp'
362
+ ],
363
+ as_output=True,
364
+ debug=debug,
365
+ )
366
+ if not output.startswith('InactiveEnterTimestamp'):
367
+ return None
368
+
369
+ dt_str = output.split('=')[-1]
370
+ if not dt_str:
371
+ return None
372
+
373
+ dateutil_parser = mrsm.attempt_import('dateutil.parser')
374
+
375
+ try:
376
+ dt = dateutil_parser.parse(dt_str)
377
+ except Exception as e:
378
+ warn(f"Cannot parse '{output}' as a datetime:\n{e}")
379
+ return None
380
+ return dt.astimezone(timezone.utc).isoformat()
381
+
382
+ def get_job_paused(self, name: str, debug: bool = False) -> Union[str, None]:
383
+ """
384
+ Return when a job was paused.
385
+ """
386
+ job = self.get_hidden_job(name, debug=debug)
387
+ if self.get_job_status(name, debug=debug) != 'paused':
388
+ return None
389
+
390
+ stop_time = job.stop_time
391
+ if stop_time is None:
392
+ return None
393
+
394
+ return stop_time.isoformat()
395
+
337
396
  def get_job_result(self, name: str, debug: bool = False) -> SuccessTuple:
338
397
  """
339
398
  Return the job's result SuccessTuple.
@@ -438,19 +497,19 @@ class SystemdExecutor(Executor):
438
497
  """
439
498
  Create a job as a service to be run by `systemd`.
440
499
  """
441
-
500
+ from meerschaum.utils.misc import make_symlink
442
501
  service_name = self.get_service_name(name, debug=debug)
443
502
  service_file_path = self.get_service_file_path(name, debug=debug)
444
- service_socket_path = self.get_service_socket_path(name, debug=debug)
445
- socket_path = self.get_socket_path(name, debug=debug)
503
+ service_symlink_file_path = self.get_service_symlink_file_path(name, debug=debug)
446
504
  socket_stdin = self.get_job_stdin_file(name, debug=debug)
447
505
  _ = socket_stdin.file_handler
448
506
 
449
- job = self.get_hidden_job(name, sysargs, debug=debug)
450
-
451
- clean_sysargs = self.clean_sysargs(sysargs)
452
507
  with open(service_file_path, 'w+', encoding='utf-8') as f:
453
- f.write(self.get_service_file_text(name, clean_sysargs, debug=debug))
508
+ f.write(self.get_service_file_text(name, sysargs, debug=debug))
509
+
510
+ symlink_success, symlink_msg = make_symlink(service_file_path, service_symlink_file_path)
511
+ if not symlink_success:
512
+ return symlink_success, symlink_msg
454
513
 
455
514
  commands = [
456
515
  ['daemon-reload'],
@@ -494,6 +553,19 @@ class SystemdExecutor(Executor):
494
553
  """
495
554
  job = self.get_hidden_job(name, debug=debug)
496
555
  job.daemon._write_stop_file('quit')
556
+ sigint_success, sigint_msg = self.run_command(
557
+ ['kill', '-s', 'SIGINT', self.get_service_name(name, debug=debug)],
558
+ debug=debug,
559
+ )
560
+
561
+ check_timeout_interval = get_config('jobs', 'check_timeout_interval_seconds')
562
+ loop_start = time.perf_counter()
563
+ while (time.perf_counter() - loop_start) < get_config('jobs', 'timeout_seconds'):
564
+ if self.get_job_status(name, debug=debug) == 'stopped':
565
+ return True, 'Success'
566
+
567
+ time.sleep(check_timeout_interval)
568
+
497
569
  return self.run_command(
498
570
  ['stop', self.get_service_name(name, debug=debug)],
499
571
  debug=debug,
@@ -516,28 +588,25 @@ class SystemdExecutor(Executor):
516
588
  """
517
589
  from meerschaum.config.paths import SYSTEMD_LOGS_RESOURCES_PATH
518
590
 
519
- stop_success, stop_msg = self.stop_job(name, debug=debug)
520
- if not stop_success:
521
- return stop_success, stop_msg
522
-
523
- disable_success, disable_msg = self.run_command(
591
+ _ = self.stop_job(name, debug=debug)
592
+ _ = self.run_command(
524
593
  ['disable', self.get_service_name(name, debug=debug)],
525
594
  debug=debug,
526
595
  )
527
- if not disable_success:
528
- return disable_success, disable_msg
529
596
 
530
- service_file_path = self.get_service_file_path(name, debug=debug)
531
- service_socket_path = self.get_service_socket_path(name, debug=debug)
532
- socket_path = self.get_socket_path(name, debug=debug)
533
- result_path = self.get_result_path(name, debug=debug)
534
597
  service_logs_path = self.get_service_logs_path(name, debug=debug)
535
598
  logs_paths = [
536
599
  (SYSTEMD_LOGS_RESOURCES_PATH / name)
537
600
  for name in os.listdir(SYSTEMD_LOGS_RESOURCES_PATH)
538
601
  if name.startswith(service_logs_path.name + '.')
539
602
  ]
540
- paths = [service_file_path, service_socket_path, socket_path, result_path] + logs_paths
603
+ paths = [
604
+ self.get_service_file_path(name, debug=debug),
605
+ self.get_service_symlink_file_path(name, debug=debug),
606
+ self.get_socket_path(name, debug=debug),
607
+ self.get_result_path(name, debug=debug),
608
+ ] + logs_paths
609
+
541
610
  for path in paths:
542
611
  if path.exists():
543
612
  try:
@@ -547,7 +616,7 @@ class SystemdExecutor(Executor):
547
616
  return False, str(e)
548
617
 
549
618
  job = self.get_hidden_job(name, debug=debug)
550
- job.delete()
619
+ _ = job.delete()
551
620
 
552
621
  return self.run_command(['daemon-reload'], debug=debug)
553
622