meerschaum 2.2.7__py3-none-any.whl → 2.3.0__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 (70) hide show
  1. meerschaum/__init__.py +6 -1
  2. meerschaum/__main__.py +0 -5
  3. meerschaum/_internal/arguments/__init__.py +1 -1
  4. meerschaum/_internal/arguments/_parse_arguments.py +72 -6
  5. meerschaum/_internal/arguments/_parser.py +45 -15
  6. meerschaum/_internal/docs/index.py +265 -8
  7. meerschaum/_internal/entry.py +154 -24
  8. meerschaum/_internal/shell/Shell.py +264 -77
  9. meerschaum/actions/__init__.py +29 -17
  10. meerschaum/actions/api.py +12 -12
  11. meerschaum/actions/attach.py +113 -0
  12. meerschaum/actions/copy.py +68 -41
  13. meerschaum/actions/delete.py +112 -50
  14. meerschaum/actions/edit.py +3 -3
  15. meerschaum/actions/install.py +40 -32
  16. meerschaum/actions/pause.py +44 -27
  17. meerschaum/actions/restart.py +107 -0
  18. meerschaum/actions/show.py +130 -159
  19. meerschaum/actions/start.py +161 -100
  20. meerschaum/actions/stop.py +78 -42
  21. meerschaum/api/_events.py +25 -1
  22. meerschaum/api/_oauth2.py +2 -0
  23. meerschaum/api/_websockets.py +2 -2
  24. meerschaum/api/dash/callbacks/jobs.py +36 -44
  25. meerschaum/api/dash/jobs.py +89 -78
  26. meerschaum/api/routes/__init__.py +1 -0
  27. meerschaum/api/routes/_actions.py +148 -17
  28. meerschaum/api/routes/_jobs.py +407 -0
  29. meerschaum/api/routes/_pipes.py +5 -5
  30. meerschaum/config/_default.py +1 -0
  31. meerschaum/config/_jobs.py +1 -1
  32. meerschaum/config/_paths.py +7 -0
  33. meerschaum/config/_shell.py +8 -3
  34. meerschaum/config/_version.py +1 -1
  35. meerschaum/config/static/__init__.py +17 -0
  36. meerschaum/connectors/Connector.py +13 -7
  37. meerschaum/connectors/__init__.py +28 -15
  38. meerschaum/connectors/api/APIConnector.py +27 -1
  39. meerschaum/connectors/api/_actions.py +71 -6
  40. meerschaum/connectors/api/_jobs.py +368 -0
  41. meerschaum/connectors/api/_pipes.py +85 -84
  42. meerschaum/connectors/parse.py +27 -15
  43. meerschaum/core/Pipe/_bootstrap.py +16 -8
  44. meerschaum/jobs/_Executor.py +69 -0
  45. meerschaum/jobs/_Job.py +899 -0
  46. meerschaum/jobs/__init__.py +396 -0
  47. meerschaum/jobs/systemd.py +694 -0
  48. meerschaum/plugins/__init__.py +97 -12
  49. meerschaum/utils/daemon/Daemon.py +276 -30
  50. meerschaum/utils/daemon/FileDescriptorInterceptor.py +5 -5
  51. meerschaum/utils/daemon/RotatingFile.py +14 -7
  52. meerschaum/utils/daemon/StdinFile.py +121 -0
  53. meerschaum/utils/daemon/__init__.py +15 -7
  54. meerschaum/utils/daemon/_names.py +15 -13
  55. meerschaum/utils/formatting/__init__.py +2 -1
  56. meerschaum/utils/formatting/_jobs.py +115 -62
  57. meerschaum/utils/formatting/_shell.py +6 -0
  58. meerschaum/utils/misc.py +41 -22
  59. meerschaum/utils/packages/_packages.py +9 -6
  60. meerschaum/utils/process.py +9 -9
  61. meerschaum/utils/prompt.py +16 -8
  62. meerschaum/utils/venv/__init__.py +2 -2
  63. {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dist-info}/METADATA +22 -25
  64. {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dist-info}/RECORD +70 -61
  65. {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dist-info}/WHEEL +1 -1
  66. {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dist-info}/LICENSE +0 -0
  67. {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dist-info}/NOTICE +0 -0
  68. {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dist-info}/entry_points.txt +0 -0
  69. {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dist-info}/top_level.txt +0 -0
  70. {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dist-info}/zip-safe +0 -0
@@ -23,6 +23,8 @@ STATIC_CONFIG: Dict[str, Any] = {
23
23
  'pipes': '/pipes',
24
24
  'metadata': '/metadata',
25
25
  'actions': '/actions',
26
+ 'jobs': '/jobs',
27
+ 'logs': '/logs',
26
28
  'users': '/users',
27
29
  'login': '/login',
28
30
  'connectors': '/connectors',
@@ -40,6 +42,11 @@ STATIC_CONFIG: Dict[str, Any] = {
40
42
  },
41
43
  'webterm_job_name': '_webterm',
42
44
  'default_timeout': 600,
45
+ 'jobs': {
46
+ 'stdin_message': 'MRSM_STDIN',
47
+ 'stop_message': 'MRSM_STOP',
48
+ 'metadata_cache_seconds': 5,
49
+ },
43
50
  },
44
51
  'sql': {
45
52
  'internal_schema': '_mrsm_internal',
@@ -63,6 +70,9 @@ STATIC_CONFIG: Dict[str, Any] = {
63
70
  'noask': 'MRSM_NOASK',
64
71
  'id': 'MRSM_SERVER_ID',
65
72
  'daemon_id': 'MRSM_DAEMON_ID',
73
+ 'systemd_log_path': 'MRSM_SYSTEMD_LOG_PATH',
74
+ 'systemd_stdin_path': 'MRSM_SYSTEMD_STDIN_PATH',
75
+ 'systemd_result_path': 'MRSM_SYSTEMD_RESULT_PATH',
66
76
  'uri_regex': r'MRSM_([a-zA-Z0-9]*)_(\d*[a-zA-Z][a-zA-Z0-9-_+]*$)',
67
77
  'prefix': 'MRSM_',
68
78
  },
@@ -78,6 +88,10 @@ STATIC_CONFIG: Dict[str, Any] = {
78
88
  ),
79
89
  'underscore_standin': '<UNDERSCORE>', ### Temporary replacement for parsing.
80
90
  'failure_key': '_argparse_exception',
91
+ 'and_key': '+',
92
+ 'escaped_and_key': '++',
93
+ 'pipeline_key': ':',
94
+ 'escaped_pipeline_key': '::',
81
95
  },
82
96
  'urls': {
83
97
  'get-pip.py': 'https://bootstrap.pypa.io/get-pip.py',
@@ -129,6 +143,9 @@ STATIC_CONFIG: Dict[str, Any] = {
129
143
  },
130
144
  'exists_timeout_seconds': 5.0,
131
145
  },
146
+ 'jobs': {
147
+ 'check_restart_seconds': 1.0,
148
+ },
132
149
  'setup': {
133
150
  'name': 'meerschaum',
134
151
  'formal_name': 'Meerschaum',
@@ -21,11 +21,11 @@ class Connector(metaclass=abc.ABCMeta):
21
21
  The base connector class to hold connection attributes.
22
22
  """
23
23
  def __init__(
24
- self,
25
- type: Optional[str] = None,
26
- label: Optional[str] = None,
27
- **kw: Any
28
- ):
24
+ self,
25
+ type: Optional[str] = None,
26
+ label: Optional[str] = None,
27
+ **kw: Any
28
+ ):
29
29
  """
30
30
  Set the given keyword arguments as attributes.
31
31
 
@@ -101,7 +101,7 @@ class Connector(metaclass=abc.ABCMeta):
101
101
 
102
102
  ### load user config into self._attributes
103
103
  if self.type in conn_configs and self.label in conn_configs[self.type]:
104
- self._attributes.update(conn_configs[self.type][self.label])
104
+ self._attributes.update(conn_configs[self.type][self.label] or {})
105
105
 
106
106
  ### load system config into self._sys_config
107
107
  ### (deep copy so future Connectors don't inherit changes)
@@ -200,7 +200,13 @@ class Connector(metaclass=abc.ABCMeta):
200
200
  _type = self.__dict__.get('type', None)
201
201
  if _type is None:
202
202
  import re
203
- _type = re.sub(r'connector$', '', self.__class__.__name__.lower())
203
+ is_executor = self.__class__.__name__.lower().endswith('executor')
204
+ suffix_regex = (
205
+ r'connector$'
206
+ if not is_executor
207
+ else r'executor$'
208
+ )
209
+ _type = re.sub(suffix_regex, '', self.__class__.__name__.lower())
204
210
  self.__dict__['type'] = _type
205
211
  return _type
206
212
 
@@ -36,9 +36,9 @@ __all__ = (
36
36
  ### store connectors partitioned by
37
37
  ### type, label for reuse
38
38
  connectors: Dict[str, Dict[str, Connector]] = {
39
- 'api' : {},
40
- 'sql' : {},
41
- 'plugin': {},
39
+ 'api' : {},
40
+ 'sql' : {},
41
+ 'plugin' : {},
42
42
  }
43
43
  instance_types: List[str] = ['sql', 'api']
44
44
  _locks: Dict[str, RLock] = {
@@ -70,12 +70,12 @@ _loaded_plugin_connectors: bool = False
70
70
 
71
71
 
72
72
  def get_connector(
73
- type: str = None,
74
- label: str = None,
75
- refresh: bool = False,
76
- debug: bool = False,
77
- **kw: Any
78
- ) -> Connector:
73
+ type: str = None,
74
+ label: str = None,
75
+ refresh: bool = False,
76
+ debug: bool = False,
77
+ **kw: Any
78
+ ) -> Connector:
79
79
  """
80
80
  Return existing connector or create new connection and store for reuse.
81
81
 
@@ -127,10 +127,13 @@ def get_connector(
127
127
  global _loaded_plugin_connectors
128
128
  if isinstance(type, str) and not label and ':' in type:
129
129
  type, label = type.split(':', maxsplit=1)
130
+
130
131
  with _locks['_loaded_plugin_connectors']:
131
132
  if not _loaded_plugin_connectors:
132
133
  load_plugin_connectors()
134
+ _load_builtin_custom_connectors()
133
135
  _loaded_plugin_connectors = True
136
+
134
137
  if type is None and label is None:
135
138
  default_instance_keys = get_config('meerschaum', 'instance', patch=True)
136
139
  ### recursive call to get_connector
@@ -274,9 +277,7 @@ def is_connected(keys: str, **kw) -> bool:
274
277
  return False
275
278
 
276
279
 
277
- def make_connector(
278
- cls,
279
- ):
280
+ def make_connector(cls, _is_executor: bool = False):
280
281
  """
281
282
  Register a class as a `Connector`.
282
283
  The `type` will be the lower case of the class name, without the suffix `connector`.
@@ -302,7 +303,12 @@ def make_connector(
302
303
  >>>
303
304
  """
304
305
  import re
305
- typ = re.sub(r'connector$', '', cls.__name__.lower())
306
+ suffix_regex = (
307
+ r'connector$'
308
+ if not _is_executor
309
+ else r'executor$'
310
+ )
311
+ typ = re.sub(suffix_regex, '', cls.__name__.lower())
306
312
  with _locks['types']:
307
313
  types[typ] = cls
308
314
  with _locks['custom_types']:
@@ -338,8 +344,8 @@ def load_plugin_connectors():
338
344
 
339
345
 
340
346
  def get_connector_plugin(
341
- connector: Connector,
342
- ) -> Union[str, None, mrsm.Plugin]:
347
+ connector: Connector,
348
+ ) -> Union[str, None, mrsm.Plugin]:
343
349
  """
344
350
  Determine the plugin for a connector.
345
351
  This is useful for handling virtual environments for custom instance connectors.
@@ -365,3 +371,10 @@ def get_connector_plugin(
365
371
  )
366
372
  plugin = mrsm.Plugin(plugin_name)
367
373
  return plugin if plugin.is_installed() else None
374
+
375
+
376
+ def _load_builtin_custom_connectors():
377
+ """
378
+ Import custom connectors decorated with `@make_connector` or `@make_executor`.
379
+ """
380
+ import meerschaum.jobs.systemd
@@ -33,7 +33,12 @@ class APIConnector(Connector):
33
33
  delete,
34
34
  wget,
35
35
  )
36
- from ._actions import get_actions, do_action
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,
@@ -72,6 +77,27 @@ class APIConnector(Connector):
72
77
  get_user_attributes,
73
78
  )
74
79
  from ._uri import from_uri
80
+ from ._jobs import (
81
+ get_jobs,
82
+ get_job,
83
+ get_job_metadata,
84
+ get_job_properties,
85
+ get_job_exists,
86
+ delete_job,
87
+ start_job,
88
+ create_job,
89
+ stop_job,
90
+ pause_job,
91
+ get_logs,
92
+ get_job_stop_time,
93
+ monitor_logs,
94
+ monitor_logs_async,
95
+ get_job_is_blocking_on_stdin,
96
+ get_job_began,
97
+ get_job_ended,
98
+ get_job_paused,
99
+ get_job_status,
100
+ )
75
101
 
76
102
  def __init__(
77
103
  self,
@@ -7,22 +7,87 @@ Functions to interact with /mrsm/actions
7
7
  """
8
8
 
9
9
  from __future__ import annotations
10
- from meerschaum.utils.typing import SuccessTuple, Optional, List
11
10
 
12
- def get_actions(self) -> list:
11
+ import json
12
+ import asyncio
13
+ from functools import partial
14
+
15
+ import meerschaum as mrsm
16
+ from meerschaum.utils.typing import SuccessTuple, List, Callable, Optional
17
+ from meerschaum.config.static import STATIC_CONFIG
18
+
19
+ ACTIONS_ENDPOINT: str = STATIC_CONFIG['api']['endpoints']['actions']
20
+
21
+
22
+ def get_actions(self):
13
23
  """Get available actions from the API instance."""
14
- from meerschaum.config.static import STATIC_CONFIG
15
- return self.get(STATIC_CONFIG['api']['endpoints']['actions'])
24
+ return self.get(ACTIONS_ENDPOINT)
25
+
26
+
27
+ def do_action(self, sysargs: List[str]) -> SuccessTuple:
28
+ """
29
+ Execute a Meerschaum action remotely.
30
+ """
31
+ return asyncio.run(self.do_action_async(sysargs))
32
+
33
+
34
+ async def do_action_async(
35
+ self,
36
+ sysargs: List[str],
37
+ callback_function: Callable[[str], None] = partial(print, end=''),
38
+ ) -> SuccessTuple:
39
+ """
40
+ Monitor a job's log files and await a callback with the changes.
41
+ """
42
+ websockets, websockets_exceptions = mrsm.attempt_import('websockets', 'websockets.exceptions')
43
+ protocol = 'ws' if self.URI.startswith('http://') else 'wss'
44
+ port = self.port if 'port' in self.__dict__ else ''
45
+ uri = f"{protocol}://{self.host}:{port}{ACTIONS_ENDPOINT}/ws"
46
+ if sysargs and sysargs[0] == 'api' and len(sysargs) > 2:
47
+ sysargs = sysargs[2:]
48
+
49
+ sysargs_str = json.dumps(sysargs)
50
+
51
+ async with websockets.connect(uri) as websocket:
52
+ try:
53
+ await websocket.send(self.token or 'no-login')
54
+ response = await websocket.recv()
55
+ init_data = json.loads(response)
56
+ if not init_data.get('is_authenticated'):
57
+ return False, "Cannot authenticate with actions endpoint."
16
58
 
59
+ await websocket.send(sysargs_str)
60
+ except websockets_exceptions.ConnectionClosedOK:
61
+ return False, "Connection was closed."
17
62
 
18
- def do_action(
63
+ while True:
64
+ try:
65
+ line = await websocket.recv()
66
+ if asyncio.iscoroutinefunction(callback_function):
67
+ await callback_function(line)
68
+ else:
69
+ callback_function(line)
70
+ except KeyboardInterrupt:
71
+ await websocket.close()
72
+ break
73
+ except websockets_exceptions.ConnectionClosedOK:
74
+ break
75
+
76
+ return True, "Success"
77
+
78
+
79
+ def do_action_legacy(
19
80
  self,
20
81
  action: Optional[List[str]] = None,
21
82
  sysargs: Optional[List[str]] = None,
22
83
  debug: bool = False,
23
84
  **kw
24
85
  ) -> SuccessTuple:
25
- """Execute a Meerschaum action remotely.
86
+ """
87
+ NOTE: This method is deprecated.
88
+ Please use `do_action()` or `do_action_async()`.
89
+
90
+ Execute a Meerschaum action remotely.
26
91
 
27
92
  If `sysargs` are provided, parse those instead.
28
93
  Otherwise infer everything from keyword arguments.
@@ -0,0 +1,368 @@
1
+ #! /usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # vim:fenc=utf-8
4
+
5
+ """
6
+ Manage jobs via the Meerschaum API.
7
+ """
8
+
9
+ import asyncio
10
+ import time
11
+ import json
12
+ from datetime import datetime
13
+
14
+ import meerschaum as mrsm
15
+ from meerschaum.utils.typing import Dict, Any, SuccessTuple, List, Union, Callable
16
+ from meerschaum.jobs import Job
17
+ from meerschaum.config.static import STATIC_CONFIG
18
+ from meerschaum.utils.warnings import warn, dprint
19
+
20
+ JOBS_ENDPOINT: str = STATIC_CONFIG['api']['endpoints']['jobs']
21
+ LOGS_ENDPOINT: str = STATIC_CONFIG['api']['endpoints']['logs']
22
+ JOBS_STDIN_MESSAGE: str = STATIC_CONFIG['api']['jobs']['stdin_message']
23
+ JOBS_STOP_MESSAGE: str = STATIC_CONFIG['api']['jobs']['stop_message']
24
+ JOB_METADATA_CACHE_SECONDS: int = STATIC_CONFIG['api']['jobs']['metadata_cache_seconds']
25
+
26
+
27
+ def get_jobs(self, debug: bool = False) -> Dict[str, Job]:
28
+ """
29
+ Return a dictionary of remote jobs.
30
+ """
31
+ response = self.get(JOBS_ENDPOINT, debug=debug)
32
+ if not response:
33
+ warn(f"Failed to get remote jobs from {self}.")
34
+ return {}
35
+ return {
36
+ name: Job(
37
+ name,
38
+ job_meta['sysargs'],
39
+ executor_keys=str(self),
40
+ _properties=job_meta['daemon']['properties']
41
+ )
42
+ for name, job_meta in response.json().items()
43
+ }
44
+
45
+
46
+ def get_job(self, name: str, debug: bool = False) -> Job:
47
+ """
48
+ Return a single Job object.
49
+ """
50
+ metadata = self.get_job_metadata(name, debug=debug)
51
+ if not metadata:
52
+ raise ValueError(f"Job '{name}' does not exist.")
53
+
54
+ return Job(
55
+ name,
56
+ metadata['sysargs'],
57
+ executor_keys=str(self),
58
+ _properties=metadata['daemon']['properties'],
59
+ )
60
+
61
+
62
+ def get_job_metadata(self, name: str, debug: bool = False) -> Dict[str, Any]:
63
+ """
64
+ Return the metadata for a single job.
65
+ """
66
+ now = time.perf_counter()
67
+ _job_metadata_cache = self.__dict__.get('_job_metadata_cache', None)
68
+ _job_metadata_timestamp = (
69
+ _job_metadata_cache.get(name, {}).get('timestamp', None)
70
+ ) if _job_metadata_cache is not None else None
71
+
72
+ if (
73
+ _job_metadata_timestamp is not None
74
+ and (now - _job_metadata_timestamp) < JOB_METADATA_CACHE_SECONDS
75
+ ):
76
+ if debug:
77
+ dprint(f"Returning cached metadata for job '{name}'.")
78
+ return _job_metadata_cache[name]['metadata']
79
+
80
+ response = self.get(JOBS_ENDPOINT + f"/{name}", debug=debug)
81
+ if not response:
82
+ if debug:
83
+ msg = (
84
+ response.json()['detail']
85
+ if 'detail' in response.text
86
+ else response.text
87
+ )
88
+ warn(f"Failed to get metadata for job '{name}':\n{msg}")
89
+ return {}
90
+
91
+ metadata = response.json()
92
+ if _job_metadata_cache is None:
93
+ self._job_metadata_cache = {}
94
+
95
+ self._job_metadata_cache[name] = {
96
+ 'timestamp': now,
97
+ 'metadata': metadata,
98
+ }
99
+ return metadata
100
+
101
+ def get_job_properties(self, name: str, debug: bool = False) -> Dict[str, Any]:
102
+ """
103
+ Return the daemon properties for a single job.
104
+ """
105
+ metadata = self.get_job_metadata(name, debug=debug)
106
+ return metadata.get('daemon', {}).get('properties', {})
107
+
108
+ def get_job_status(self, name: str, debug: bool = False) -> str:
109
+ """
110
+ Return the job's status.
111
+ """
112
+ metadata = self.get_job_metadata(name, debug=debug)
113
+ return metadata.get('status', 'stopped')
114
+
115
+ def get_job_began(self, name: str, debug: bool = False) -> Union[str, None]:
116
+ """
117
+ Return a job's `began` timestamp, if it exists.
118
+ """
119
+ properties = self.get_job_properties(name, debug=debug)
120
+ began_str = properties.get('daemon', {}).get('began', None)
121
+ if began_str is None:
122
+ return None
123
+
124
+ return began_str
125
+
126
+ def get_job_ended(self, name: str, debug: bool = False) -> Union[str, None]:
127
+ """
128
+ Return a job's `ended` timestamp, if it exists.
129
+ """
130
+ properties = self.get_job_properties(name, debug=debug)
131
+ ended_str = properties.get('daemon', {}).get('ended', None)
132
+ if ended_str is None:
133
+ return None
134
+
135
+ return ended_str
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
+
148
+ def get_job_exists(self, name: str, debug: bool = False) -> bool:
149
+ """
150
+ Return whether a job exists.
151
+ """
152
+ response = self.get(JOBS_ENDPOINT + f'/{name}/exists', debug=debug)
153
+ if not response:
154
+ warn(f"Failed to determine whether job '{name}' exists.")
155
+ return False
156
+
157
+ return response.json()
158
+
159
+
160
+ def delete_job(self, name: str, debug: bool = False) -> SuccessTuple:
161
+ """
162
+ Delete a job.
163
+ """
164
+ response = self.delete(JOBS_ENDPOINT + f"/{name}", debug=debug)
165
+ if not response:
166
+ if 'detail' in response.text:
167
+ return False, response.json()['detail']
168
+
169
+ return False, response.text
170
+
171
+ return tuple(response.json())
172
+
173
+
174
+ def start_job(self, name: str, debug: bool = False) -> SuccessTuple:
175
+ """
176
+ Start a job.
177
+ """
178
+ response = self.post(JOBS_ENDPOINT + f"/{name}/start", debug=debug)
179
+ if not response:
180
+ if 'detail' in response.text:
181
+ return False, response.json()['detail']
182
+ return False, response.text
183
+
184
+ return tuple(response.json())
185
+
186
+
187
+ def create_job(self, name: str, sysargs: List[str], debug: bool = False) -> SuccessTuple:
188
+ """
189
+ Create a job.
190
+ """
191
+ response = self.post(JOBS_ENDPOINT + f"/{name}", json=sysargs, debug=debug)
192
+ if not response:
193
+ if 'detail' in response.text:
194
+ return False, response.json()['detail']
195
+ return False, response.text
196
+
197
+ return tuple(response.json())
198
+
199
+
200
+ def stop_job(self, name: str, debug: bool = False) -> SuccessTuple:
201
+ """
202
+ Stop a job.
203
+ """
204
+ response = self.post(JOBS_ENDPOINT + f"/{name}/stop", debug=debug)
205
+ if not response:
206
+ if 'detail' in response.text:
207
+ return False, response.json()['detail']
208
+ return False, response.text
209
+
210
+ return tuple(response.json())
211
+
212
+
213
+ def pause_job(self, name: str, debug: bool = False) -> SuccessTuple:
214
+ """
215
+ Pause a job.
216
+ """
217
+ response = self.post(JOBS_ENDPOINT + f"/{name}/pause", debug=debug)
218
+ if not response:
219
+ if 'detail' in response.text:
220
+ return False, response.json()['detail']
221
+ return False, response.text
222
+
223
+ return tuple(response.json())
224
+
225
+
226
+ def get_logs(self, name: str, debug: bool = False) -> str:
227
+ """
228
+ Return the logs for a job.
229
+ """
230
+ response = self.get(LOGS_ENDPOINT + f"/{name}")
231
+ if not response:
232
+ raise ValueError(f"Cannot fetch logs for job '{name}':\n{response.text}")
233
+
234
+ return response.json()
235
+
236
+
237
+ def get_job_stop_time(self, name: str, debug: bool = False) -> Union[datetime, None]:
238
+ """
239
+ Return the job's manual stop time.
240
+ """
241
+ response = self.get(JOBS_ENDPOINT + f"/{name}/stop_time")
242
+ if not response:
243
+ warn(f"Failed to get stop time for job '{name}':\n{response.text}")
244
+ return None
245
+
246
+ data = response.json()
247
+ if data is None:
248
+ return None
249
+
250
+ return datetime.fromisoformat(data)
251
+
252
+
253
+ async def monitor_logs_async(
254
+ self,
255
+ name: str,
256
+ callback_function: Callable[[Any], Any],
257
+ input_callback_function: Callable[[], str],
258
+ stop_callback_function: Callable[[SuccessTuple], str],
259
+ stop_on_exit: bool = False,
260
+ strip_timestamps: bool = False,
261
+ accept_input: bool = True,
262
+ debug: bool = False,
263
+ ):
264
+ """
265
+ Monitor a job's log files and await a callback with the changes.
266
+ """
267
+ from meerschaum.jobs import StopMonitoringLogs
268
+ from meerschaum.utils.formatting._jobs import strip_timestamp_from_line
269
+
270
+ websockets, websockets_exceptions = mrsm.attempt_import('websockets', 'websockets.exceptions')
271
+ protocol = 'ws' if self.URI.startswith('http://') else 'wss'
272
+ port = self.port if 'port' in self.__dict__ else ''
273
+ uri = f"{protocol}://{self.host}:{port}{LOGS_ENDPOINT}/{name}/ws"
274
+
275
+ async def _stdin_callback(client):
276
+ if input_callback_function is None:
277
+ return
278
+
279
+ if asyncio.iscoroutinefunction(input_callback_function):
280
+ data = await input_callback_function()
281
+ else:
282
+ data = input_callback_function()
283
+
284
+ await client.send(data)
285
+
286
+ async def _stop_callback(client):
287
+ try:
288
+ result = tuple(json.loads(await client.recv()))
289
+ except Exception as e:
290
+ warn(traceback.format_exc())
291
+ result = False, str(e)
292
+
293
+ if stop_callback_function is not None:
294
+ if asyncio.iscoroutinefunction(stop_callback_function):
295
+ await stop_callback_function(result)
296
+ else:
297
+ stop_callback_function(result)
298
+
299
+ if stop_on_exit:
300
+ raise StopMonitoringLogs
301
+
302
+ message_callbacks = {
303
+ JOBS_STDIN_MESSAGE: _stdin_callback,
304
+ JOBS_STOP_MESSAGE: _stop_callback,
305
+ }
306
+
307
+ async with websockets.connect(uri) as websocket:
308
+ try:
309
+ await websocket.send(self.token or 'no-login')
310
+ except websockets_exceptions.ConnectionClosedOK:
311
+ pass
312
+
313
+ while True:
314
+ try:
315
+ response = await websocket.recv()
316
+ callback = message_callbacks.get(response, None)
317
+ if callback is not None:
318
+ await callback(websocket)
319
+ continue
320
+
321
+ if strip_timestamps:
322
+ response = strip_timestamp_from_line(response)
323
+
324
+ if asyncio.iscoroutinefunction(callback_function):
325
+ await callback_function(response)
326
+ else:
327
+ callback_function(response)
328
+ except (KeyboardInterrupt, StopMonitoringLogs):
329
+ await websocket.close()
330
+ break
331
+
332
+
333
+ def monitor_logs(
334
+ self,
335
+ name: str,
336
+ callback_function: Callable[[Any], Any],
337
+ input_callback_function: Callable[[None], str],
338
+ stop_callback_function: Callable[[None], str],
339
+ stop_on_exit: bool = False,
340
+ strip_timestamps: bool = False,
341
+ accept_input: bool = True,
342
+ debug: bool = False,
343
+ ):
344
+ """
345
+ Monitor a job's log files and execute a callback with the changes.
346
+ """
347
+ return asyncio.run(
348
+ self.monitor_logs_async(
349
+ name,
350
+ callback_function,
351
+ input_callback_function=input_callback_function,
352
+ stop_callback_function=stop_callback_function,
353
+ stop_on_exit=stop_on_exit,
354
+ strip_timestamps=strip_timestamps,
355
+ accept_input=accept_input,
356
+ debug=debug
357
+ )
358
+ )
359
+
360
+ def get_job_is_blocking_on_stdin(self, name: str, debug: bool = False) -> bool:
361
+ """
362
+ Return whether a remote job is blocking on stdin.
363
+ """
364
+ response = self.get(JOBS_ENDPOINT + f'/{name}/is_blocking_on_stdin', debug=debug)
365
+ if not response:
366
+ return False
367
+
368
+ return response.json()