meerschaum 2.1.7__py3-none-any.whl → 2.2.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 (59) hide show
  1. meerschaum/__main__.py +1 -1
  2. meerschaum/_internal/arguments/_parser.py +3 -0
  3. meerschaum/_internal/entry.py +3 -2
  4. meerschaum/actions/install.py +7 -3
  5. meerschaum/actions/show.py +128 -42
  6. meerschaum/actions/sync.py +7 -3
  7. meerschaum/api/__init__.py +24 -14
  8. meerschaum/api/_oauth2.py +4 -4
  9. meerschaum/api/dash/callbacks/dashboard.py +93 -23
  10. meerschaum/api/dash/callbacks/jobs.py +55 -3
  11. meerschaum/api/dash/jobs.py +34 -8
  12. meerschaum/api/dash/keys.py +1 -1
  13. meerschaum/api/dash/pages/dashboard.py +14 -4
  14. meerschaum/api/dash/pipes.py +137 -26
  15. meerschaum/api/dash/plugins.py +25 -9
  16. meerschaum/api/resources/static/js/xterm.js +1 -1
  17. meerschaum/api/resources/templates/termpage.html +3 -0
  18. meerschaum/api/routes/_login.py +5 -4
  19. meerschaum/api/routes/_plugins.py +6 -3
  20. meerschaum/config/_dash.py +11 -0
  21. meerschaum/config/_default.py +3 -1
  22. meerschaum/config/_jobs.py +13 -4
  23. meerschaum/config/_paths.py +2 -0
  24. meerschaum/config/_sync.py +2 -3
  25. meerschaum/config/_version.py +1 -1
  26. meerschaum/config/stack/__init__.py +6 -7
  27. meerschaum/config/stack/grafana/__init__.py +1 -1
  28. meerschaum/config/static/__init__.py +4 -1
  29. meerschaum/connectors/__init__.py +2 -0
  30. meerschaum/connectors/api/_plugins.py +2 -1
  31. meerschaum/connectors/sql/SQLConnector.py +4 -2
  32. meerschaum/connectors/sql/_create_engine.py +9 -9
  33. meerschaum/connectors/sql/_instance.py +3 -1
  34. meerschaum/connectors/sql/_pipes.py +54 -38
  35. meerschaum/connectors/sql/_plugins.py +0 -2
  36. meerschaum/connectors/sql/_sql.py +7 -9
  37. meerschaum/core/User/_User.py +158 -16
  38. meerschaum/core/User/__init__.py +1 -1
  39. meerschaum/plugins/_Plugin.py +12 -3
  40. meerschaum/plugins/__init__.py +23 -1
  41. meerschaum/utils/daemon/Daemon.py +89 -36
  42. meerschaum/utils/daemon/FileDescriptorInterceptor.py +140 -0
  43. meerschaum/utils/daemon/RotatingFile.py +130 -14
  44. meerschaum/utils/daemon/__init__.py +3 -0
  45. meerschaum/utils/dtypes/__init__.py +9 -5
  46. meerschaum/utils/packages/__init__.py +21 -5
  47. meerschaum/utils/packages/_packages.py +18 -20
  48. meerschaum/utils/process.py +13 -10
  49. meerschaum/utils/schedule.py +276 -30
  50. meerschaum/utils/threading.py +1 -0
  51. meerschaum/utils/typing.py +1 -1
  52. {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dist-info}/METADATA +59 -62
  53. {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dist-info}/RECORD +59 -57
  54. {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dist-info}/WHEEL +1 -1
  55. {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dist-info}/LICENSE +0 -0
  56. {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dist-info}/NOTICE +0 -0
  57. {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dist-info}/entry_points.txt +0 -0
  58. {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dist-info}/top_level.txt +0 -0
  59. {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dist-info}/zip-safe +0 -0
@@ -7,22 +7,157 @@ User class definition
7
7
  """
8
8
 
9
9
  from __future__ import annotations
10
- from meerschaum.utils.typing import Optional, Dict, Any
11
-
12
- pwd_context = None
13
- def get_pwd_context():
14
- global pwd_context
15
- if pwd_context is None:
16
- from meerschaum.config.static import STATIC_CONFIG
17
- from meerschaum.utils.packages import attempt_import
18
- hash_config = STATIC_CONFIG['users']['password_hash']
19
- passlib_context = attempt_import('passlib.context')
20
- pwd_context = passlib_context.CryptContext(
21
- schemes = hash_config['schemes'],
22
- default = hash_config['default'],
23
- pbkdf2_sha256__default_rounds = hash_config['pbkdf2_sha256__default_rounds']
10
+ import os
11
+ import hashlib
12
+ import hmac
13
+ from binascii import b2a_base64, a2b_base64, Error as _BinAsciiError
14
+ from meerschaum.utils.typing import Optional, Dict, Any, Tuple
15
+ from meerschaum.config.static import STATIC_CONFIG
16
+ from meerschaum.utils.warnings import warn
17
+
18
+
19
+ __all__ = ('hash_password', 'verify_password', 'User')
20
+
21
+ def hash_password(
22
+ password: str,
23
+ salt: Optional[bytes] = None,
24
+ rounds: Optional[int] = None,
25
+ ) -> str:
26
+ """
27
+ Return an encoded hash string from the given password.
28
+
29
+ Parameters
30
+ ----------
31
+ password: str
32
+ The password to be hashed.
33
+
34
+ salt: Optional[str], default None
35
+ If provided, use these bytes for the salt in the hash.
36
+ Otherwise defaults to 16 random bytes.
37
+
38
+ rounds: Optional[int], default None
39
+ If provided, use this number of rounds to generate the hash.
40
+ Defaults to 3,000,000.
41
+
42
+ Returns
43
+ -------
44
+ An encoded hash string to be stored in a database.
45
+ See the `passlib` documentation on the string format:
46
+ https://passlib.readthedocs.io/en/stable/lib/passlib.hash.pbkdf2_digest.html#format-algorithm
47
+ """
48
+ hash_config = STATIC_CONFIG['users']['password_hash']
49
+ if password is None:
50
+ password = ''
51
+ if salt is None:
52
+ salt = os.urandom(hash_config['salt_bytes'])
53
+ if rounds is None:
54
+ rounds = hash_config['pbkdf2_sha256__default_rounds']
55
+
56
+ pw_hash = hashlib.pbkdf2_hmac(
57
+ hash_config['algorithm_name'],
58
+ password.encode('utf-8'),
59
+ salt,
60
+ rounds,
61
+ )
62
+ return (
63
+ f"$pbkdf2-{hash_config['algorithm_name']}"
64
+ + f"${hash_config['pbkdf2_sha256__default_rounds']}"
65
+ + '$' + ab64_encode(salt).decode('utf-8')
66
+ + '$' + ab64_encode(pw_hash).decode('utf-8')
67
+ )
68
+
69
+
70
+ def verify_password(
71
+ password: str,
72
+ password_hash: str,
73
+ ) -> bool:
74
+ """
75
+ Return `True` if the password matches the provided hash.
76
+
77
+ Parameters
78
+ ----------
79
+ password: str
80
+ The password to be checked.
81
+
82
+ password_hash: str
83
+ The encoded hash string as generated from `hash_password()`.
84
+
85
+ Returns
86
+ -------
87
+ A `bool` indicating whether `password` matches `password_hash`.
88
+ """
89
+ if password is None or password_hash is None:
90
+ return False
91
+ hash_config = STATIC_CONFIG['users']['password_hash']
92
+ try:
93
+ digest, rounds_str, encoded_salt, encoded_checksum = password_hash.split('$')[1:]
94
+ algorithm_name = digest.split('-')[-1]
95
+ salt = ab64_decode(encoded_salt)
96
+ checksum = ab64_decode(encoded_checksum)
97
+ rounds = int(rounds_str)
98
+ except Exception as e:
99
+ warn(f"Failed to extract context from password hash '{password_hash}'. Is it corrupted?")
100
+ return False
101
+
102
+ return hmac.compare_digest(
103
+ checksum,
104
+ hashlib.pbkdf2_hmac(
105
+ algorithm_name,
106
+ password.encode('utf-8'),
107
+ salt,
108
+ rounds,
24
109
  )
25
- return pwd_context
110
+ )
111
+
112
+ _BASE64_STRIP = b"=\n"
113
+ _BASE64_PAD1 = b"="
114
+ _BASE64_PAD2 = b"=="
115
+
116
+ def ab64_encode(data):
117
+ return b64s_encode(data).replace(b"+", b".")
118
+
119
+ def ab64_decode(data):
120
+ """
121
+ decode from shortened base64 format which omits padding & whitespace.
122
+ uses custom ``./`` altchars, but supports decoding normal ``+/`` altchars as well.
123
+ """
124
+ if isinstance(data, str):
125
+ # needs bytes for replace() call, but want to accept ascii-unicode ala a2b_base64()
126
+ try:
127
+ data = data.encode("ascii")
128
+ except UnicodeEncodeError:
129
+ raise ValueError("string argument should contain only ASCII characters")
130
+ return b64s_decode(data.replace(b".", b"+"))
131
+
132
+
133
+ def b64s_encode(data):
134
+ return b2a_base64(data).rstrip(_BASE64_STRIP)
135
+
136
+ def b64s_decode(data):
137
+ """
138
+ decode from shortened base64 format which omits padding & whitespace.
139
+ uses default ``+/`` altchars.
140
+ """
141
+ if isinstance(data, str):
142
+ # needs bytes for replace() call, but want to accept ascii-unicode ala a2b_base64()
143
+ try:
144
+ data = data.encode("ascii")
145
+ except UnicodeEncodeError as ue:
146
+ raise ValueError("string argument should contain only ASCII characters") from ue
147
+ off = len(data) & 3
148
+ if off == 0:
149
+ pass
150
+ elif off == 2:
151
+ data += _BASE64_PAD2
152
+ elif off == 3:
153
+ data += _BASE64_PAD1
154
+ else: # off == 1
155
+ raise ValueError("Invalid base64 input")
156
+ try:
157
+ return a2b_base64(data)
158
+ except _BinAsciiError as err:
159
+ raise TypeError(err) from err
160
+
26
161
 
27
162
  class User:
28
163
  """
@@ -42,7 +177,6 @@ class User:
42
177
  if password is None:
43
178
  password = ''
44
179
  self.password = password
45
- self.password_hash = get_pwd_context().hash(password)
46
180
  self.username = username
47
181
  self.email = email
48
182
  self.type = type
@@ -80,3 +214,11 @@ class User:
80
214
  @user_id.setter
81
215
  def user_id(self, user_id):
82
216
  self._user_id = user_id
217
+
218
+ @property
219
+ def password_hash(self):
220
+ _password_hash = self.__dict__.get('_password_hash', None)
221
+ if _password_hash is not None:
222
+ return _password_hash
223
+ self._password_hash = hash_password(self.password)
224
+ return self._password_hash
@@ -6,4 +6,4 @@
6
6
  Manager users' metadata via the User class
7
7
  """
8
8
 
9
- from meerschaum.core.User._User import User
9
+ from meerschaum.core.User._User import User, hash_password, verify_password
@@ -209,7 +209,7 @@ class Plugin:
209
209
  def parse_gitignore() -> 'Set[str]':
210
210
  gitignore_path = pathlib.Path(path) / '.gitignore'
211
211
  if not gitignore_path.exists():
212
- return set()
212
+ return set(default_patterns_to_ignore)
213
213
  with open(gitignore_path, 'r', encoding='utf-8') as f:
214
214
  gitignore_text = f.read()
215
215
  return set(pathspec.PathSpec.from_lines(
@@ -252,6 +252,7 @@ class Plugin:
252
252
 
253
253
  def install(
254
254
  self,
255
+ skip_deps: bool = False,
255
256
  force: bool = False,
256
257
  debug: bool = False,
257
258
  ) -> SuccessTuple:
@@ -263,6 +264,9 @@ class Plugin:
263
264
 
264
265
  Parameters
265
266
  ----------
267
+ skip_deps: bool, default False
268
+ If `True`, do not install dependencies.
269
+
266
270
  force: bool, default False
267
271
  If `True`, continue with installation, even if required packages fail to install.
268
272
 
@@ -366,7 +370,11 @@ class Plugin:
366
370
  plugin_installation_dir_path = path
367
371
  break
368
372
 
369
- success_msg = f"Successfully installed plugin '{self}'."
373
+ success_msg = (
374
+ f"Successfully installed plugin '{self}'"
375
+ + ("\n (skipped dependencies)" if skip_deps else "")
376
+ + "."
377
+ )
370
378
  success, abort = None, None
371
379
 
372
380
  if is_same_version and not force:
@@ -423,7 +431,8 @@ class Plugin:
423
431
  return success, msg
424
432
 
425
433
  ### attempt to install dependencies
426
- if not self.install_dependencies(force=force, debug=debug):
434
+ dependencies_installed = skip_deps or self.install_dependencies(force=force, debug=debug)
435
+ if not dependencies_installed:
427
436
  _ongoing_installations.remove(self.full_name)
428
437
  return False, f"Failed to install dependencies for plugin '{self}'."
429
438
 
@@ -247,6 +247,26 @@ def sync_plugins_symlinks(debug: bool = False, warn: bool = True) -> None:
247
247
  _warn(f"Unable to create lockfile {PLUGINS_INTERNAL_LOCK_PATH}:\n{e}")
248
248
 
249
249
  with _locks['internal_plugins']:
250
+
251
+ try:
252
+ from importlib.metadata import entry_points
253
+ except ImportError:
254
+ importlib_metadata = attempt_import('importlib_metadata', lazy=False)
255
+ entry_points = importlib_metadata.entry_points
256
+
257
+ ### NOTE: Allow plugins to be installed via `pip`.
258
+ packaged_plugin_paths = []
259
+ discovered_packaged_plugins_eps = entry_points(group='meerschaum.plugins')
260
+ for ep in discovered_packaged_plugins_eps:
261
+ module_name = ep.name
262
+ for package_file_path in ep.dist.files:
263
+ if package_file_path.suffix != '.py':
264
+ continue
265
+ if str(package_file_path) == f'{module_name}.py':
266
+ packaged_plugin_paths.append(package_file_path.locate())
267
+ elif str(package_file_path) == f'{module_name}/__init__.py':
268
+ packaged_plugin_paths.append(package_file_path.locate().parent)
269
+
250
270
  if is_symlink(PLUGINS_RESOURCES_PATH) or not PLUGINS_RESOURCES_PATH.exists():
251
271
  try:
252
272
  PLUGINS_RESOURCES_PATH.unlink()
@@ -255,7 +275,6 @@ def sync_plugins_symlinks(debug: bool = False, warn: bool = True) -> None:
255
275
 
256
276
  PLUGINS_RESOURCES_PATH.mkdir(exist_ok=True)
257
277
 
258
-
259
278
  existing_symlinked_paths = [
260
279
  (PLUGINS_RESOURCES_PATH / item)
261
280
  for item in os.listdir(PLUGINS_RESOURCES_PATH)
@@ -275,6 +294,7 @@ def sync_plugins_symlinks(debug: bool = False, warn: bool = True) -> None:
275
294
  for plugins_path in PLUGINS_DIR_PATHS
276
295
  ]
277
296
  ))
297
+ plugins_to_be_symlinked.extend(packaged_plugin_paths)
278
298
 
279
299
  ### Check for duplicates.
280
300
  seen_plugins = defaultdict(lambda: 0)
@@ -538,6 +558,8 @@ def get_plugins(*to_load, try_import: bool = True) -> Union[Tuple[Plugin], Plugi
538
558
  ]
539
559
  plugins = tuple(plugin for plugin in _plugins if plugin.is_installed(try_import=try_import))
540
560
  if len(to_load) == 1:
561
+ if len(plugins) == 0:
562
+ raise ValueError(f"Plugin '{to_load[0]}' is not installed.")
541
563
  return plugins[0]
542
564
  return plugins
543
565
 
@@ -15,9 +15,11 @@ import signal
15
15
  import sys
16
16
  import time
17
17
  import traceback
18
+ from functools import partial
18
19
  from datetime import datetime, timezone
19
20
  from meerschaum.utils.typing import Optional, Dict, Any, SuccessTuple, Callable, List, Union
20
21
  from meerschaum.config import get_config
22
+ from meerschaum.config.static import STATIC_CONFIG
21
23
  from meerschaum.config._paths import DAEMON_RESOURCES_PATH, LOGS_RESOURCES_PATH
22
24
  from meerschaum.config._patch import apply_patch_to_config
23
25
  from meerschaum.utils.warnings import warn, error
@@ -36,7 +38,7 @@ class Daemon:
36
38
  def __new__(
37
39
  cls,
38
40
  *args,
39
- daemon_id : Optional[str] = None,
41
+ daemon_id: Optional[str] = None,
40
42
  **kw
41
43
  ):
42
44
  """
@@ -129,7 +131,7 @@ class Daemon:
129
131
  keep_daemon_output: bool, default True
130
132
  If `False`, delete the daemon's output directory upon exiting.
131
133
 
132
- allow_dirty_run :
134
+ allow_dirty_run, bool, default False:
133
135
  If `True`, run the daemon, even if the `daemon_id` directory exists.
134
136
  This option is dangerous because if the same `daemon_id` runs twice,
135
137
  the last to finish will overwrite the output of the first.
@@ -138,8 +140,13 @@ class Daemon:
138
140
  -------
139
141
  Nothing — this will exit the parent process.
140
142
  """
141
- import platform, sys, os
143
+ import platform, sys, os, traceback
144
+ from meerschaum.config._paths import DAEMON_ERROR_LOG_PATH
145
+ from meerschaum.utils.warnings import warn
146
+ from meerschaum.config import get_config
142
147
  daemon = attempt_import('daemon')
148
+ lines = get_config('jobs', 'terminal', 'lines')
149
+ columns = get_config('jobs','terminal', 'columns')
143
150
 
144
151
  if platform.system() == 'Windows':
145
152
  return False, "Windows is no longer supported."
@@ -160,31 +167,45 @@ class Daemon:
160
167
  )
161
168
 
162
169
  log_refresh_seconds = get_config('jobs', 'logs', 'refresh_files_seconds')
163
- self._log_refresh_timer = RepeatTimer(log_refresh_seconds, self.rotating_log.refresh_files)
164
-
165
- with self._daemon_context:
166
- try:
167
- with open(self.pid_path, 'w+') as f:
168
- f.write(str(os.getpid()))
170
+ self._log_refresh_timer = RepeatTimer(
171
+ log_refresh_seconds,
172
+ partial(self.rotating_log.refresh_files, start_interception=True),
173
+ )
169
174
 
170
- self._log_refresh_timer.start()
171
- result = self.target(*self.target_args, **self.target_kw)
172
- self.properties['result'] = result
173
- except Exception as e:
174
- warn(e, stacklevel=3)
175
- result = e
176
- finally:
177
- self._log_refresh_timer.cancel()
178
- self.rotating_log.close()
179
- if self.pid is None and self.pid_path.exists():
180
- self.pid_path.unlink()
175
+ try:
176
+ os.environ['LINES'], os.environ['COLUMNS'] = str(int(lines)), str(int(columns))
177
+ with self._daemon_context:
178
+ os.environ[STATIC_CONFIG['environment']['daemon_id']] = self.daemon_id
179
+ self.rotating_log.refresh_files(start_interception=True)
180
+ try:
181
+ with open(self.pid_path, 'w+', encoding='utf-8') as f:
182
+ f.write(str(os.getpid()))
181
183
 
182
- if keep_daemon_output:
183
- self._capture_process_timestamp('ended')
184
- else:
185
- self.cleanup()
184
+ self._log_refresh_timer.start()
185
+ result = self.target(*self.target_args, **self.target_kw)
186
+ self.properties['result'] = result
187
+ except Exception as e:
188
+ warn(e, stacklevel=3)
189
+ result = e
190
+ finally:
191
+ self._log_refresh_timer.cancel()
192
+ self.rotating_log.close()
193
+ if self.pid is None and self.pid_path.exists():
194
+ self.pid_path.unlink()
195
+
196
+ if keep_daemon_output:
197
+ self._capture_process_timestamp('ended')
198
+ else:
199
+ self.cleanup()
200
+
201
+ return result
202
+ except Exception as e:
203
+ daemon_error = traceback.format_exc()
204
+ with open(DAEMON_ERROR_LOG_PATH, 'a+', encoding='utf-8') as f:
205
+ f.write(daemon_error)
186
206
 
187
- return result
207
+ if daemon_error:
208
+ warn(f"Encountered an error while starting the daemon '{self}':\n{daemon_error}")
188
209
 
189
210
 
190
211
  def _capture_process_timestamp(
@@ -444,6 +465,9 @@ class Daemon:
444
465
  Handle `SIGINT` within the Daemon context.
445
466
  This method is injected into the `DaemonContext`.
446
467
  """
468
+ # from meerschaum.utils.daemon.FileDescriptorInterceptor import STOP_READING_FD_EVENT
469
+ # STOP_READING_FD_EVENT.set()
470
+ self.rotating_log.stop_log_fd_interception(unused_only=False)
447
471
  timer = self.__dict__.get('_log_refresh_timer', None)
448
472
  if timer is not None:
449
473
  timer.cancel()
@@ -453,9 +477,17 @@ class Daemon:
453
477
  daemon_context.close()
454
478
 
455
479
  _close_pools()
456
-
457
- ### NOTE: SystemExit() does not work here.
458
- sys.exit(0)
480
+ import threading
481
+ for thread in threading.enumerate():
482
+ if thread.name == 'MainThread':
483
+ continue
484
+ try:
485
+ if thread.is_alive():
486
+ stack = traceback.format_stack(sys._current_frames()[thread.ident])
487
+ thread.join()
488
+ except Exception as e:
489
+ warn(traceback.format_exc())
490
+ raise KeyboardInterrupt()
459
491
 
460
492
 
461
493
  def _handle_sigterm(self, signal_number: int, stack_frame: 'frame') -> None:
@@ -472,8 +504,7 @@ class Daemon:
472
504
  daemon_context.close()
473
505
 
474
506
  _close_pools()
475
-
476
- raise SystemExit()
507
+ raise SystemExit(0)
477
508
 
478
509
 
479
510
  def _send_signal(
@@ -650,7 +681,12 @@ class Daemon:
650
681
  if '_rotating_log' in self.__dict__:
651
682
  return self._rotating_log
652
683
 
653
- self._rotating_log = RotatingFile(self.log_path, redirect_streams=True)
684
+ self._rotating_log = RotatingFile(
685
+ self.log_path,
686
+ redirect_streams = True,
687
+ write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled'),
688
+ timestamp_format = get_config('jobs', 'logs', 'timestamps', 'format'),
689
+ )
654
690
  return self._rotating_log
655
691
 
656
692
 
@@ -663,6 +699,8 @@ class Daemon:
663
699
  self.rotating_log.file_path,
664
700
  num_files_to_keep = self.rotating_log.num_files_to_keep,
665
701
  max_file_size = self.rotating_log.max_file_size,
702
+ write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled'),
703
+ timestamp_format = get_config('jobs', 'logs', 'timestamps', 'format'),
666
704
  )
667
705
  return new_rotating_log.read()
668
706
 
@@ -714,7 +752,7 @@ class Daemon:
714
752
  if not self.pid_path.exists():
715
753
  return None
716
754
  try:
717
- with open(self.pid_path, 'r') as f:
755
+ with open(self.pid_path, 'r', encoding='utf-8') as f:
718
756
  text = f.read()
719
757
  pid = int(text.rstrip())
720
758
  except Exception as e:
@@ -815,7 +853,7 @@ class Daemon:
815
853
  if self.properties is not None:
816
854
  try:
817
855
  self.path.mkdir(parents=True, exist_ok=True)
818
- with open(self.properties_path, 'w+') as properties_file:
856
+ with open(self.properties_path, 'w+', encoding='utf-8') as properties_file:
819
857
  json.dump(self.properties, properties_file)
820
858
  success, msg = True, 'Success'
821
859
  except Exception as e:
@@ -865,21 +903,36 @@ class Daemon:
865
903
  error(_write_pickle_success_tuple[1])
866
904
 
867
905
 
868
- def cleanup(self, keep_logs: bool = False) -> None:
869
- """Remove a daemon's directory after execution.
906
+ def cleanup(self, keep_logs: bool = False) -> SuccessTuple:
907
+ """
908
+ Remove a daemon's directory after execution.
870
909
 
871
910
  Parameters
872
911
  ----------
873
912
  keep_logs: bool, default False
874
913
  If `True`, skip deleting the daemon's log files.
914
+
915
+ Returns
916
+ -------
917
+ A `SuccessTuple` indicating success.
875
918
  """
876
919
  if self.path.exists():
877
920
  try:
878
921
  shutil.rmtree(self.path)
879
922
  except Exception as e:
880
- warn(e)
923
+ msg = f"Failed to clean up '{self.daemon_id}':\n{e}"
924
+ warn(msg)
925
+ return False, msg
881
926
  if not keep_logs:
882
927
  self.rotating_log.delete()
928
+ try:
929
+ if self.log_offset_path.exists():
930
+ self.log_offset_path.unlink()
931
+ except Exception as e:
932
+ msg = f"Failed to remove offset file for '{self.daemon_id}':\n{e}"
933
+ warn(msg)
934
+ return False, msg
935
+ return True, "Success"
883
936
 
884
937
 
885
938
  def get_timeout_seconds(self, timeout: Union[int, float, None] = None) -> Union[int, float]:
@@ -0,0 +1,140 @@
1
+ #! /usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # vim:fenc=utf-8
4
+
5
+ """
6
+ Intercept OS-level file descriptors.
7
+ """
8
+
9
+ import os
10
+ import select
11
+ import traceback
12
+ from threading import Event
13
+ from datetime import datetime
14
+ from meerschaum.utils.typing import Callable
15
+ from meerschaum.utils.warnings import warn
16
+
17
+ FD_CLOSED: int = 9
18
+ STOP_READING_FD_EVENT: Event = Event()
19
+
20
+ class FileDescriptorInterceptor:
21
+ """
22
+ A management class to intercept data written to a file descriptor.
23
+ """
24
+ def __init__(
25
+ self,
26
+ file_descriptor: int,
27
+ injection_hook: Callable[[], str],
28
+ ):
29
+ """
30
+ Parameters
31
+ ----------
32
+ file_descriptor: int
33
+ The OS file descriptor from which to read.
34
+
35
+ injection_hook: Callable[[], str]
36
+ A callable which returns a string to be injected into the written data.
37
+ """
38
+ self.stop_event = Event()
39
+ self.injection_hook = injection_hook
40
+ self.original_file_descriptor = file_descriptor
41
+ self.new_file_descriptor = os.dup(file_descriptor)
42
+ self.read_pipe, self.write_pipe = os.pipe()
43
+ self.signal_read_pipe, self.signal_write_pipe = os.pipe()
44
+ os.dup2(self.write_pipe, file_descriptor)
45
+
46
+ def start_interception(self):
47
+ """
48
+ Read from the file descriptor and write the modified data after injection.
49
+
50
+ NOTE: This is blocking and is meant to be run in a thread.
51
+ """
52
+ os.set_blocking(self.read_pipe, False)
53
+ os.set_blocking(self.signal_read_pipe, False)
54
+ is_first_read = True
55
+ while not self.stop_event.is_set():
56
+ try:
57
+ rlist, _, _ = select.select([self.read_pipe, self.signal_read_pipe], [], [], 0.1)
58
+ if self.signal_read_pipe in rlist:
59
+ break
60
+ if not rlist:
61
+ continue
62
+ data = os.read(self.read_pipe, 1024)
63
+ if not data:
64
+ break
65
+ except BlockingIOError:
66
+ continue
67
+ except OSError as e:
68
+ continue
69
+
70
+ first_char_is_newline = data[0] == b'\n'
71
+ last_char_is_newline = data[-1] == b'\n'
72
+
73
+ injected_str = self.injection_hook()
74
+ injected_bytes = injected_str.encode('utf-8')
75
+
76
+ if is_first_read:
77
+ data = b'\n' + data
78
+ is_first_read = False
79
+
80
+ modified_data = (
81
+ (data[:-1].replace(b'\n', b'\n' + injected_bytes) + b'\n')
82
+ if last_char_is_newline
83
+ else data.replace(b'\n', b'\n' + injected_bytes)
84
+ )
85
+ os.write(self.new_file_descriptor, modified_data)
86
+
87
+
88
+ def stop_interception(self):
89
+ """
90
+ Close the new file descriptors.
91
+ """
92
+ self.stop_event.set()
93
+ os.write(self.signal_write_pipe, b'\0')
94
+ try:
95
+ os.close(self.new_file_descriptor)
96
+ except OSError as e:
97
+ if e.errno != FD_CLOSED:
98
+ warn(
99
+ f"Error while trying to close the duplicated file descriptor:\n"
100
+ + f"{traceback.format_exc()}"
101
+ )
102
+
103
+ try:
104
+ os.close(self.write_pipe)
105
+ except OSError as e:
106
+ if e.errno != FD_CLOSED:
107
+ warn(
108
+ f"Error while trying to close the write-pipe "
109
+ + "to the intercepted file descriptor:\n"
110
+ + f"{traceback.format_exc()}"
111
+ )
112
+ try:
113
+ os.close(self.read_pipe)
114
+ except OSError as e:
115
+ if e.errno != FD_CLOSED:
116
+ warn(
117
+ f"Error while trying to close the read-pipe "
118
+ + "to the intercepted file descriptor:\n"
119
+ + f"{traceback.format_exc()}"
120
+ )
121
+
122
+ try:
123
+ os.close(self.signal_read_pipe)
124
+ except OSError as e:
125
+ if e.errno != FD_CLOSED:
126
+ warn(
127
+ f"Error while trying to close the signal-read-pipe "
128
+ + "to the intercepted file descriptor:\n"
129
+ + f"{traceback.format_exc()}"
130
+ )
131
+
132
+ try:
133
+ os.close(self.signal_write_pipe)
134
+ except OSError as e:
135
+ if e.errno != FD_CLOSED:
136
+ warn(
137
+ f"Error while trying to close the signal-write-pipe "
138
+ + "to the intercepted file descriptor:\n"
139
+ + f"{traceback.format_exc()}"
140
+ )