meerschaum 2.3.0rc2__py3-none-any.whl → 2.3.0rc5__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 (34) hide show
  1. meerschaum/_internal/arguments/__init__.py +1 -1
  2. meerschaum/_internal/arguments/_parse_arguments.py +17 -12
  3. meerschaum/_internal/arguments/_parser.py +3 -6
  4. meerschaum/_internal/entry.py +42 -4
  5. meerschaum/_internal/shell/Shell.py +84 -72
  6. meerschaum/actions/delete.py +4 -0
  7. meerschaum/actions/show.py +5 -5
  8. meerschaum/actions/start.py +71 -1
  9. meerschaum/api/dash/callbacks/jobs.py +36 -44
  10. meerschaum/api/dash/jobs.py +24 -15
  11. meerschaum/api/routes/_actions.py +54 -6
  12. meerschaum/api/routes/_jobs.py +19 -1
  13. meerschaum/config/_jobs.py +1 -1
  14. meerschaum/config/_paths.py +1 -0
  15. meerschaum/config/_version.py +1 -1
  16. meerschaum/config/static/__init__.py +2 -0
  17. meerschaum/connectors/api/APIConnector.py +7 -1
  18. meerschaum/connectors/api/_actions.py +77 -1
  19. meerschaum/connectors/api/_jobs.py +13 -2
  20. meerschaum/connectors/api/_pipes.py +85 -84
  21. meerschaum/jobs/_Job.py +53 -12
  22. meerschaum/jobs/_SystemdExecutor.py +111 -30
  23. meerschaum/jobs/__init__.py +9 -2
  24. meerschaum/utils/daemon/Daemon.py +14 -0
  25. meerschaum/utils/daemon/StdinFile.py +1 -0
  26. meerschaum/utils/daemon/_names.py +15 -13
  27. {meerschaum-2.3.0rc2.dist-info → meerschaum-2.3.0rc5.dist-info}/METADATA +1 -1
  28. {meerschaum-2.3.0rc2.dist-info → meerschaum-2.3.0rc5.dist-info}/RECORD +34 -34
  29. {meerschaum-2.3.0rc2.dist-info → meerschaum-2.3.0rc5.dist-info}/LICENSE +0 -0
  30. {meerschaum-2.3.0rc2.dist-info → meerschaum-2.3.0rc5.dist-info}/NOTICE +0 -0
  31. {meerschaum-2.3.0rc2.dist-info → meerschaum-2.3.0rc5.dist-info}/WHEEL +0 -0
  32. {meerschaum-2.3.0rc2.dist-info → meerschaum-2.3.0rc5.dist-info}/entry_points.txt +0 -0
  33. {meerschaum-2.3.0rc2.dist-info → meerschaum-2.3.0rc5.dist-info}/top_level.txt +0 -0
  34. {meerschaum-2.3.0rc2.dist-info → meerschaum-2.3.0rc5.dist-info}/zip-safe +0 -0
@@ -24,13 +24,13 @@ from dash import Patch
24
24
  html, dcc = import_html(check_update=CHECK_UPDATE), import_dcc(check_update=CHECK_UPDATE)
25
25
  import dash_bootstrap_components as dbc
26
26
  from meerschaum.api.dash.components import alert_from_success_tuple, build_cards_grid
27
- from meerschaum.utils.daemon import Daemon
28
27
  from dash.exceptions import PreventUpdate
29
28
  from meerschaum.api.dash.jobs import (
30
29
  build_manage_job_buttons_div_children,
31
30
  build_status_children,
32
31
  build_process_timestamps_children,
33
32
  )
33
+ from meerschaum.jobs import Job
34
34
  from meerschaum.api.dash.users import is_session_authenticated
35
35
 
36
36
  @dash_app.callback(
@@ -53,15 +53,11 @@ def download_job_logs(n_clicks):
53
53
  raise PreventUpdate
54
54
 
55
55
  component_dict = json.loads(ctx[0]['prop_id'].split('.' + 'n_clicks')[0])
56
- daemon_id = component_dict['index']
57
- daemon = Daemon(daemon_id=daemon_id)
56
+ job_name = component_dict['index']
58
57
  now = datetime.now(timezone.utc)
59
- filename = (
60
- daemon.rotating_log.file_path.name[:(-1 * len('.log'))]
61
- + '_' + str(int(now.timestamp())) + '.log'
62
- )
58
+ filename = job_name + '_' + str(int(now.timestamp())) + '.log'
63
59
  return {
64
- 'content': daemon.log_text,
60
+ 'content': job.get_logs(),
65
61
  'filename': filename,
66
62
  }
67
63
 
@@ -73,12 +69,14 @@ def download_job_logs(n_clicks):
73
69
  Output({'type': 'process-timestamps-div', 'index': MATCH}, 'children'),
74
70
  Input({'type': 'manage-job-button', 'action': ALL, 'index': MATCH}, 'n_clicks'),
75
71
  State('session-store', 'data'),
72
+ State({'type': 'job-label-p', 'index': MATCH}, 'children'),
76
73
  prevent_initial_call = True,
77
74
  )
78
75
  def manage_job_button_click(
79
- n_clicks: Optional[int] = None,
80
- session_data: Optional[Dict[str, Any]] = None,
81
- ):
76
+ n_clicks: Optional[int] = None,
77
+ session_data: Optional[Dict[str, Any]] = None,
78
+ job_label: Optional[str] = None,
79
+ ):
82
80
  """
83
81
  Start, stop, pause, or delete the given job.
84
82
  """
@@ -102,20 +100,20 @@ def manage_job_button_click(
102
100
  raise PreventUpdate
103
101
 
104
102
  component_dict = json.loads(ctx[0]['prop_id'].split('.' + 'n_clicks')[0])
105
- daemon_id = component_dict['index']
103
+ job_name = component_dict['index']
106
104
  manage_job_action = component_dict['action']
107
105
  try:
108
- daemon = Daemon(daemon_id=daemon_id)
106
+ job = Job(job_name, job_label.replace('\n', ' ') if job_label else None)
109
107
  except Exception as e:
110
- daemon = None
111
- if daemon is None:
108
+ job = None
109
+ if job is None:
112
110
  raise PreventUpdate
113
111
 
114
112
  manage_functions = {
115
- 'start': functools.partial(daemon.run, allow_dirty_run=True),
116
- 'stop': daemon.quit,
117
- 'pause': daemon.pause,
118
- 'delete': daemon.cleanup,
113
+ 'start': job.start,
114
+ 'stop': job.stop,
115
+ 'pause': job.pause,
116
+ 'delete': job.delete,
119
117
  }
120
118
  if manage_job_action not in manage_functions:
121
119
  return (
@@ -125,7 +123,7 @@ def manage_job_button_click(
125
123
  dash.no_update,
126
124
  )
127
125
 
128
- old_status = daemon.status
126
+ old_status = job.status
129
127
  try:
130
128
  success, msg = manage_functions[manage_job_action]()
131
129
  except Exception as e:
@@ -136,15 +134,15 @@ def manage_job_button_click(
136
134
  check_interval_seconds = 0.01
137
135
  begin = time.perf_counter()
138
136
  while (time.perf_counter() - begin) < timeout_seconds:
139
- if daemon.status != old_status:
137
+ if job.status != old_status:
140
138
  break
141
139
  time.sleep(check_interval_seconds)
142
140
 
143
141
  return (
144
142
  alert_from_success_tuple((success, msg)),
145
- build_manage_job_buttons_div_children(daemon),
146
- build_status_children(daemon),
147
- build_process_timestamps_children(daemon),
143
+ build_manage_job_buttons_div_children(job),
144
+ build_status_children(job),
145
+ build_process_timestamps_children(job),
148
146
  )
149
147
 
150
148
  dash_app.clientside_callback(
@@ -165,7 +163,7 @@ dash_app.clientside_callback(
165
163
  }
166
164
 
167
165
  const triggered_id = dash_clientside.callback_context.triggered_id;
168
- const job_daemon_id = triggered_id["index"];
166
+ const job_name = triggered_id["index"];
169
167
 
170
168
  iframe = document.getElementById('webterm-iframe');
171
169
  if (!iframe){ return dash_clientside.no_update; }
@@ -174,7 +172,7 @@ dash_app.clientside_callback(
174
172
  {
175
173
  action: "show",
176
174
  subaction: "logs",
177
- subaction_text: job_daemon_id,
175
+ subaction_text: job_name,
178
176
  },
179
177
  url
180
178
  );
@@ -197,44 +195,38 @@ dash_app.clientside_callback(
197
195
  prevent_initial_call = True,
198
196
  )
199
197
  def refresh_jobs_on_interval(
200
- n_intervals: Optional[int] = None,
201
- session_data: Optional[Dict[str, Any]] = None,
202
- ):
198
+ n_intervals: Optional[int] = None,
199
+ session_data: Optional[Dict[str, Any]] = None,
200
+ ):
203
201
  """
204
202
  When the jobs refresh interval fires, rebuild the jobs' onscreen components.
205
203
  """
206
204
  session_id = session_data.get('session-id', None)
207
205
  is_authenticated = is_session_authenticated(session_id)
208
206
 
209
- daemon_ids = [
207
+ job_names = [
210
208
  component_dict['id']['index']
211
209
  for component_dict in dash.callback_context.outputs_grouping[0]
212
210
  ]
213
211
 
214
- ### NOTE: The daemon may have been deleted, but the card may still exist.
215
- daemons = []
216
- for daemon_id in daemon_ids:
217
- try:
218
- daemon = Daemon(daemon_id=daemon_id)
219
- except Exception as e:
220
- daemon = None
221
- daemons.append(daemon)
212
+ ### NOTE: The job may have been deleted, but the card may still exist.
213
+ jobs = [Job(name) for name in job_names]
222
214
 
223
215
  return (
224
216
  [
225
217
  (
226
- build_manage_job_buttons_div_children(daemon)
218
+ build_manage_job_buttons_div_children(job)
227
219
  if is_authenticated
228
220
  else []
229
221
  )
230
- for daemon in daemons
222
+ for job in jobs
231
223
  ],
232
224
  [
233
- build_status_children(daemon)
234
- for daemon in daemons
225
+ build_status_children(job)
226
+ for job in jobs
235
227
  ],
236
228
  [
237
- build_process_timestamps_children(daemon)
238
- for daemon in daemons
229
+ build_process_timestamps_children(job)
230
+ for job in jobs
239
231
  ],
240
232
  )
@@ -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(
@@ -18,7 +18,7 @@ from fastapi import WebSocket, WebSocketDisconnect
18
18
  from meerschaum.utils.misc import generate_password
19
19
  from meerschaum.jobs import Job
20
20
  from meerschaum.utils.warnings import warn
21
- from meerschaum.utils.typing import SuccessTuple, Union, List, Dict
21
+ from meerschaum.utils.typing import SuccessTuple, Union, List, Dict, Any
22
22
  from meerschaum.api import (
23
23
  fastapi, app, endpoints, get_api_connector, debug, manager, private, no_auth
24
24
  )
@@ -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,
@@ -152,3 +153,50 @@ async def do_action_websocket(websocket: WebSocket):
152
153
  job.delete()
153
154
  _ = _temp_jobs.pop(job_name, None)
154
155
  stop_event.set()
156
+
157
+
158
+ @app.post(actions_endpoint + "/{action}", tags=['Actions'])
159
+ def do_action_legacy(
160
+ action: str,
161
+ keywords: Dict[str, Any] = fastapi.Body(...),
162
+ curr_user = (
163
+ fastapi.Depends(manager) if not no_auth else None
164
+ ),
165
+ ) -> SuccessTuple:
166
+ """
167
+ Perform a Meerschaum action (if permissions allow).
168
+
169
+ Parameters
170
+ ----------
171
+ action: str
172
+ The action to perform.
173
+
174
+ keywords: Dict[str, Any]
175
+ The keywords dictionary to pass to the action.
176
+
177
+ Returns
178
+ -------
179
+ A `SuccessTuple`.
180
+ """
181
+ if curr_user is not None and curr_user.type != 'admin':
182
+ from meerschaum.config import get_config
183
+ allow_non_admin = get_config(
184
+ 'system', 'api', 'permissions', 'actions', 'non_admin', patch=True
185
+ )
186
+ if not allow_non_admin:
187
+ return False, (
188
+ "The administrator for this server has not allowed users to perform actions.\n\n"
189
+ + "Please contact the system administrator, or if you are running this server, "
190
+ + "open the configuration file with `edit config system` "
191
+ + "and search for 'permissions'. "
192
+ + "\nUnder the keys 'api:permissions:actions', "
193
+ + "you can allow non-admin users to perform actions."
194
+ )
195
+
196
+ if action not in actions:
197
+ return False, f"Invalid action '{action}'."
198
+
199
+ keywords['mrsm_instance'] = keywords.get('mrsm_instance', str(get_api_connector()))
200
+ _debug = keywords.get('debug', debug)
201
+ keywords.pop('debug', None)
202
+ return actions[action](debug=_debug, **keywords)
@@ -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,
@@ -185,6 +185,7 @@ paths = {
185
185
  'SYSTEMD_RESOURCES_PATH' : ('{DOT_CONFIG_DIR_PATH}', 'systemd'),
186
186
  'SYSTEMD_USER_RESOURCES_PATH' : ('{SYSTEMD_RESOURCES_PATH}', 'user'),
187
187
  'SYSTEMD_ROOT_RESOURCES_PATH' : ('{ROOT_DIR_PATH}', 'systemd'),
188
+ 'SYSTEMD_JOBS_RESOURCES_PATH' : ('{SYSTEMD_ROOT_RESOURCES_PATH}', 'services'),
188
189
  'SYSTEMD_LOGS_RESOURCES_PATH' : ('{SYSTEMD_ROOT_RESOURCES_PATH}', 'logs'),
189
190
  }
190
191
 
@@ -2,4 +2,4 @@
2
2
  Specify the Meerschaum release version.
3
3
  """
4
4
 
5
- __version__ = "2.3.0rc2"
5
+ __version__ = "2.3.0rc5"
@@ -90,6 +90,8 @@ STATIC_CONFIG: Dict[str, Any] = {
90
90
  'failure_key': '_argparse_exception',
91
91
  'and_key': '+',
92
92
  'escaped_and_key': '++',
93
+ 'pipeline_key': ':',
94
+ 'escaped_pipeline_key': '::',
93
95
  },
94
96
  'urls': {
95
97
  'get-pip.py': 'https://bootstrap.pypa.io/get-pip.py',
@@ -33,7 +33,12 @@ class APIConnector(Connector):
33
33
  delete,
34
34
  wget,
35
35
  )
36
- from ._actions import get_actions, do_action, do_action_async
36
+ from ._actions import (
37
+ get_actions,
38
+ do_action,
39
+ do_action_async,
40
+ do_action_legacy,
41
+ )
37
42
  from ._misc import get_mrsm_version, get_chaining_status
38
43
  from ._pipes import (
39
44
  register_pipe,
@@ -90,6 +95,7 @@ class APIConnector(Connector):
90
95
  get_job_is_blocking_on_stdin,
91
96
  get_job_began,
92
97
  get_job_ended,
98
+ get_job_paused,
93
99
  get_job_status,
94
100
  )
95
101
 
@@ -13,7 +13,7 @@ import asyncio
13
13
  from functools import partial
14
14
 
15
15
  import meerschaum as mrsm
16
- from meerschaum.utils.typing import SuccessTuple, List, Callable
16
+ from meerschaum.utils.typing import SuccessTuple, List, Callable, Optional
17
17
  from meerschaum.config.static import STATIC_CONFIG
18
18
 
19
19
  ACTIONS_ENDPOINT: str = STATIC_CONFIG['api']['endpoints']['actions']
@@ -74,3 +74,79 @@ async def do_action_async(
74
74
  break
75
75
 
76
76
  return True, "Success"
77
+
78
+
79
+ def do_action_legacy(
80
+ self,
81
+ action: Optional[List[str]] = None,
82
+ sysargs: Optional[List[str]] = None,
83
+ debug: bool = False,
84
+ **kw
85
+ ) -> SuccessTuple:
86
+ """
87
+ NOTE: This method is deprecated.
88
+ Please use `do_action()` or `do_action_async()`.
89
+
90
+ Execute a Meerschaum action remotely.
91
+
92
+ If `sysargs` are provided, parse those instead.
93
+ Otherwise infer everything from keyword arguments.
94
+
95
+ Examples
96
+ --------
97
+ >>> conn = mrsm.get_connector('api:main')
98
+ >>> conn.do_action(['show', 'pipes'])
99
+ (True, "Success")
100
+ >>> conn.do_action(['show', 'arguments'], name='test')
101
+ (True, "Success")
102
+ """
103
+ import sys, json
104
+ from meerschaum.utils.debug import dprint
105
+ from meerschaum.config.static import STATIC_CONFIG
106
+ from meerschaum.utils.misc import json_serialize_datetime
107
+ if action is None:
108
+ action = []
109
+
110
+ if sysargs is not None and action and action[0] == '':
111
+ from meerschaum._internal.arguments import parse_arguments
112
+ if debug:
113
+ dprint(f"Parsing sysargs:\n{sysargs}")
114
+ json_dict = parse_arguments(sysargs)
115
+ else:
116
+ json_dict = kw
117
+ json_dict['action'] = action
118
+ if 'noask' not in kw:
119
+ json_dict['noask'] = True
120
+ if 'yes' not in kw:
121
+ json_dict['yes'] = True
122
+ if debug:
123
+ json_dict['debug'] = debug
124
+
125
+ root_action = json_dict['action'][0]
126
+ del json_dict['action'][0]
127
+ r_url = f"{STATIC_CONFIG['api']['endpoints']['actions']}/{root_action}"
128
+
129
+ if debug:
130
+ from meerschaum.utils.formatting import pprint
131
+ dprint(f"Sending data to '{self.url + r_url}':")
132
+ pprint(json_dict, stream=sys.stderr)
133
+
134
+ response = self.post(
135
+ r_url,
136
+ data = json.dumps(json_dict, default=json_serialize_datetime),
137
+ debug = debug,
138
+ )
139
+ try:
140
+ response_list = json.loads(response.text)
141
+ if isinstance(response_list, dict) and 'detail' in response_list:
142
+ return False, response_list['detail']
143
+ except Exception as e:
144
+ print(f"Invalid response: {response}")
145
+ print(e)
146
+ return False, response.text
147
+ if debug:
148
+ dprint(response)
149
+ try:
150
+ return response_list[0], response_list[1]
151
+ except Exception as e:
152
+ return False, f"Failed to parse result from action '{root_action}'"
@@ -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.