meerschaum 2.2.0.dev3__py3-none-any.whl → 2.2.0rc2__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 (38) hide show
  1. meerschaum/__main__.py +1 -1
  2. meerschaum/_internal/entry.py +1 -1
  3. meerschaum/actions/show.py +128 -42
  4. meerschaum/api/dash/callbacks/dashboard.py +2 -7
  5. meerschaum/api/dash/pipes.py +33 -9
  6. meerschaum/api/dash/plugins.py +25 -9
  7. meerschaum/api/resources/templates/termpage.html +3 -0
  8. meerschaum/api/routes/_login.py +5 -4
  9. meerschaum/api/routes/_plugins.py +6 -3
  10. meerschaum/config/_dash.py +11 -0
  11. meerschaum/config/_default.py +3 -1
  12. meerschaum/config/_jobs.py +10 -4
  13. meerschaum/config/_paths.py +1 -0
  14. meerschaum/config/_sync.py +2 -3
  15. meerschaum/config/_version.py +1 -1
  16. meerschaum/config/stack/__init__.py +6 -6
  17. meerschaum/config/stack/grafana/__init__.py +1 -1
  18. meerschaum/config/static/__init__.py +3 -1
  19. meerschaum/connectors/sql/_plugins.py +0 -2
  20. meerschaum/core/User/_User.py +156 -16
  21. meerschaum/core/User/__init__.py +1 -1
  22. meerschaum/plugins/_Plugin.py +1 -1
  23. meerschaum/utils/daemon/Daemon.py +63 -34
  24. meerschaum/utils/daemon/FileDescriptorInterceptor.py +102 -0
  25. meerschaum/utils/daemon/RotatingFile.py +120 -14
  26. meerschaum/utils/daemon/__init__.py +1 -0
  27. meerschaum/utils/packages/__init__.py +9 -2
  28. meerschaum/utils/packages/_packages.py +3 -3
  29. meerschaum/utils/schedule.py +41 -47
  30. meerschaum/utils/threading.py +1 -0
  31. {meerschaum-2.2.0.dev3.dist-info → meerschaum-2.2.0rc2.dist-info}/METADATA +10 -9
  32. {meerschaum-2.2.0.dev3.dist-info → meerschaum-2.2.0rc2.dist-info}/RECORD +38 -36
  33. {meerschaum-2.2.0.dev3.dist-info → meerschaum-2.2.0rc2.dist-info}/WHEEL +1 -1
  34. {meerschaum-2.2.0.dev3.dist-info → meerschaum-2.2.0rc2.dist-info}/LICENSE +0 -0
  35. {meerschaum-2.2.0.dev3.dist-info → meerschaum-2.2.0rc2.dist-info}/NOTICE +0 -0
  36. {meerschaum-2.2.0.dev3.dist-info → meerschaum-2.2.0rc2.dist-info}/entry_points.txt +0 -0
  37. {meerschaum-2.2.0.dev3.dist-info → meerschaum-2.2.0rc2.dist-info}/top_level.txt +0 -0
  38. {meerschaum-2.2.0.dev3.dist-info → meerschaum-2.2.0rc2.dist-info}/zip-safe +0 -0
@@ -108,9 +108,7 @@ def get_plugin_version(
108
108
  plugins_tbl = get_tables(mrsm_instance=self, debug=debug)['plugins']
109
109
  from meerschaum.utils.packages import attempt_import
110
110
  sqlalchemy = attempt_import('sqlalchemy')
111
-
112
111
  query = sqlalchemy.select(plugins_tbl.c.version).where(plugins_tbl.c.plugin_name == plugin.name)
113
-
114
112
  return self.value(query, debug=debug)
115
113
 
116
114
  def get_plugin_user_id(
@@ -7,22 +7,155 @@ 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
+ hash_config = STATIC_CONFIG['users']['password_hash']
90
+ try:
91
+ digest, rounds_str, encoded_salt, encoded_checksum = password_hash.split('$')[1:]
92
+ algorithm_name = digest.split('-')[-1]
93
+ salt = ab64_decode(encoded_salt)
94
+ checksum = ab64_decode(encoded_checksum)
95
+ rounds = int(rounds_str)
96
+ except Exception as e:
97
+ warn(f"Failed to extract context from password hash '{password_hash}'. Is it corrupted?")
98
+ return False
99
+
100
+ return hmac.compare_digest(
101
+ checksum,
102
+ hashlib.pbkdf2_hmac(
103
+ algorithm_name,
104
+ password.encode('utf-8'),
105
+ salt,
106
+ rounds,
24
107
  )
25
- return pwd_context
108
+ )
109
+
110
+ _BASE64_STRIP = b"=\n"
111
+ _BASE64_PAD1 = b"="
112
+ _BASE64_PAD2 = b"=="
113
+
114
+ def ab64_encode(data):
115
+ return b64s_encode(data).replace(b"+", b".")
116
+
117
+ def ab64_decode(data):
118
+ """
119
+ decode from shortened base64 format which omits padding & whitespace.
120
+ uses custom ``./`` altchars, but supports decoding normal ``+/`` altchars as well.
121
+ """
122
+ if isinstance(data, str):
123
+ # needs bytes for replace() call, but want to accept ascii-unicode ala a2b_base64()
124
+ try:
125
+ data = data.encode("ascii")
126
+ except UnicodeEncodeError:
127
+ raise ValueError("string argument should contain only ASCII characters")
128
+ return b64s_decode(data.replace(b".", b"+"))
129
+
130
+
131
+ def b64s_encode(data):
132
+ return b2a_base64(data).rstrip(_BASE64_STRIP)
133
+
134
+ def b64s_decode(data):
135
+ """
136
+ decode from shortened base64 format which omits padding & whitespace.
137
+ uses default ``+/`` altchars.
138
+ """
139
+ if isinstance(data, str):
140
+ # needs bytes for replace() call, but want to accept ascii-unicode ala a2b_base64()
141
+ try:
142
+ data = data.encode("ascii")
143
+ except UnicodeEncodeError as ue:
144
+ raise ValueError("string argument should contain only ASCII characters") from ue
145
+ off = len(data) & 3
146
+ if off == 0:
147
+ pass
148
+ elif off == 2:
149
+ data += _BASE64_PAD2
150
+ elif off == 3:
151
+ data += _BASE64_PAD1
152
+ else: # off == 1
153
+ raise ValueError("Invalid base64 input")
154
+ try:
155
+ return a2b_base64(data)
156
+ except _BinAsciiError as err:
157
+ raise TypeError(err) from err
158
+
26
159
 
27
160
  class User:
28
161
  """
@@ -42,7 +175,6 @@ class User:
42
175
  if password is None:
43
176
  password = ''
44
177
  self.password = password
45
- self.password_hash = get_pwd_context().hash(password)
46
178
  self.username = username
47
179
  self.email = email
48
180
  self.type = type
@@ -80,3 +212,11 @@ class User:
80
212
  @user_id.setter
81
213
  def user_id(self, user_id):
82
214
  self._user_id = user_id
215
+
216
+ @property
217
+ def password_hash(self):
218
+ _password_hash = self.__dict__.get('_password_hash', None)
219
+ if _password_hash is not None:
220
+ return _password_hash
221
+ self._password_hash = hash_password(self.password)
222
+ 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(
@@ -15,6 +15,7 @@ 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
@@ -36,7 +37,7 @@ class Daemon:
36
37
  def __new__(
37
38
  cls,
38
39
  *args,
39
- daemon_id : Optional[str] = None,
40
+ daemon_id: Optional[str] = None,
40
41
  **kw
41
42
  ):
42
43
  """
@@ -129,7 +130,7 @@ class Daemon:
129
130
  keep_daemon_output: bool, default True
130
131
  If `False`, delete the daemon's output directory upon exiting.
131
132
 
132
- allow_dirty_run :
133
+ allow_dirty_run, bool, default False:
133
134
  If `True`, run the daemon, even if the `daemon_id` directory exists.
134
135
  This option is dangerous because if the same `daemon_id` runs twice,
135
136
  the last to finish will overwrite the output of the first.
@@ -138,8 +139,13 @@ class Daemon:
138
139
  -------
139
140
  Nothing — this will exit the parent process.
140
141
  """
141
- import platform, sys, os
142
+ import platform, sys, os, traceback
143
+ from meerschaum.config._paths import DAEMON_ERROR_LOG_PATH
144
+ from meerschaum.utils.warnings import warn
145
+ from meerschaum.config import get_config
142
146
  daemon = attempt_import('daemon')
147
+ lines = get_config('jobs', 'terminal', 'lines')
148
+ columns = get_config('jobs','terminal', 'columns')
143
149
 
144
150
  if platform.system() == 'Windows':
145
151
  return False, "Windows is no longer supported."
@@ -160,31 +166,43 @@ class Daemon:
160
166
  )
161
167
 
162
168
  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()))
169
-
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()
169
+ self._log_refresh_timer = RepeatTimer(
170
+ log_refresh_seconds,
171
+ partial(self.rotating_log.refresh_files, start_interception=True),
172
+ )
173
+ try:
174
+ os.environ['LINES'], os.environ['COLUMNS'] = str(int(lines)), str(int(columns))
175
+ with self._daemon_context:
176
+ self.rotating_log.refresh_files(start_interception=True)
177
+ try:
178
+ with open(self.pid_path, 'w+', encoding='utf-8') as f:
179
+ f.write(str(os.getpid()))
181
180
 
182
- if keep_daemon_output:
183
- self._capture_process_timestamp('ended')
184
- else:
185
- self.cleanup()
181
+ self._log_refresh_timer.start()
182
+ result = self.target(*self.target_args, **self.target_kw)
183
+ self.properties['result'] = result
184
+ except Exception as e:
185
+ warn(e, stacklevel=3)
186
+ result = e
187
+ finally:
188
+ self._log_refresh_timer.cancel()
189
+ self.rotating_log.close()
190
+ if self.pid is None and self.pid_path.exists():
191
+ self.pid_path.unlink()
192
+
193
+ if keep_daemon_output:
194
+ self._capture_process_timestamp('ended')
195
+ else:
196
+ self.cleanup()
197
+
198
+ return result
199
+ except Exception as e:
200
+ daemon_error = traceback.format_exc()
201
+ with open(DAEMON_ERROR_LOG_PATH, 'a+', encoding='utf-8') as f:
202
+ f.write(daemon_error)
186
203
 
187
- return result
204
+ if daemon_error:
205
+ warn(f"Encountered an error while starting the daemon '{self}':\n{daemon_error}")
188
206
 
189
207
 
190
208
  def _capture_process_timestamp(
@@ -453,9 +471,8 @@ class Daemon:
453
471
  daemon_context.close()
454
472
 
455
473
  _close_pools()
456
-
457
- ### NOTE: SystemExit() does not work here.
458
- sys.exit(0)
474
+ self.rotating_log.stop_log_fd_interception()
475
+ raise KeyboardInterrupt()
459
476
 
460
477
 
461
478
  def _handle_sigterm(self, signal_number: int, stack_frame: 'frame') -> None:
@@ -472,8 +489,7 @@ class Daemon:
472
489
  daemon_context.close()
473
490
 
474
491
  _close_pools()
475
-
476
- raise SystemExit()
492
+ raise SystemExit(1)
477
493
 
478
494
 
479
495
  def _send_signal(
@@ -650,7 +666,12 @@ class Daemon:
650
666
  if '_rotating_log' in self.__dict__:
651
667
  return self._rotating_log
652
668
 
653
- self._rotating_log = RotatingFile(self.log_path, redirect_streams=True)
669
+ self._rotating_log = RotatingFile(
670
+ self.log_path,
671
+ redirect_streams = True,
672
+ write_timestamps = True,
673
+ timestamp_format = get_config('jobs', 'logs', 'timestamp_format'),
674
+ )
654
675
  return self._rotating_log
655
676
 
656
677
 
@@ -663,6 +684,7 @@ class Daemon:
663
684
  self.rotating_log.file_path,
664
685
  num_files_to_keep = self.rotating_log.num_files_to_keep,
665
686
  max_file_size = self.rotating_log.max_file_size,
687
+ write_timestamps = True,
666
688
  )
667
689
  return new_rotating_log.read()
668
690
 
@@ -714,7 +736,7 @@ class Daemon:
714
736
  if not self.pid_path.exists():
715
737
  return None
716
738
  try:
717
- with open(self.pid_path, 'r') as f:
739
+ with open(self.pid_path, 'r', encoding='utf-8') as f:
718
740
  text = f.read()
719
741
  pid = int(text.rstrip())
720
742
  except Exception as e:
@@ -815,7 +837,7 @@ class Daemon:
815
837
  if self.properties is not None:
816
838
  try:
817
839
  self.path.mkdir(parents=True, exist_ok=True)
818
- with open(self.properties_path, 'w+') as properties_file:
840
+ with open(self.properties_path, 'w+', encoding='utf-8') as properties_file:
819
841
  json.dump(self.properties, properties_file)
820
842
  success, msg = True, 'Success'
821
843
  except Exception as e:
@@ -887,6 +909,13 @@ class Daemon:
887
909
  return False, msg
888
910
  if not keep_logs:
889
911
  self.rotating_log.delete()
912
+ try:
913
+ if self.log_offset_path.exists():
914
+ self.log_offset_path.unlink()
915
+ except Exception as e:
916
+ msg = f"Failed to remove offset file for '{self.daemon_id}':\n{e}"
917
+ warn(msg)
918
+ return False, msg
890
919
  return True, "Success"
891
920
 
892
921
 
@@ -0,0 +1,102 @@
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 traceback
11
+ from datetime import datetime
12
+ from meerschaum.utils.typing import Callable
13
+ from meerschaum.utils.warnings import warn
14
+
15
+ FD_CLOSED: int = 9
16
+
17
+ class FileDescriptorInterceptor:
18
+ """
19
+ A management class to intercept data written to a file descriptor.
20
+ """
21
+ def __init__(
22
+ self,
23
+ file_descriptor: int,
24
+ injection_hook: Callable[[], str],
25
+ ):
26
+ """
27
+ Parameters
28
+ ----------
29
+ file_descriptor: int
30
+ The OS file descriptor from which to read.
31
+
32
+ injection_hook: Callable[[], str]
33
+ A callable which returns a string to be injected into the written data.
34
+ """
35
+ self.injection_hook = injection_hook
36
+ self.original_file_descriptor = file_descriptor
37
+ self.new_file_descriptor = os.dup(file_descriptor)
38
+ self.read_pipe, self.write_pipe = os.pipe()
39
+ os.dup2(self.write_pipe, file_descriptor)
40
+
41
+ def start_interception(self):
42
+ """
43
+ Read from the file descriptor and write the modified data after injection.
44
+
45
+ NOTE: This is blocking and is meant to be run in a thread.
46
+ """
47
+ is_first_read = True
48
+ while True:
49
+ data = os.read(self.read_pipe, 1024)
50
+ if not data:
51
+ break
52
+
53
+ first_char_is_newline = data[0] == b'\n'
54
+ last_char_is_newline = data[-1] == b'\n'
55
+
56
+ injected_str = self.injection_hook()
57
+ injected_bytes = injected_str.encode('utf-8')
58
+
59
+ if is_first_read:
60
+ data = b'\n' + data
61
+ is_first_read = False
62
+
63
+ modified_data = (
64
+ (data[:-1].replace(b'\n', b'\n' + injected_bytes) + b'\n')
65
+ if last_char_is_newline
66
+ else data.replace(b'\n', b'\n' + injected_bytes)
67
+ )
68
+
69
+ os.write(self.new_file_descriptor, modified_data)
70
+
71
+ def stop_interception(self):
72
+ """
73
+ Restore the file descriptors and close the new pipes.
74
+ """
75
+ try:
76
+ os.dup2(self.new_file_descriptor, self.original_file_descriptor)
77
+ # os.close(self.new_file_descriptor)
78
+ except OSError as e:
79
+ if e.errno != FD_CLOSED:
80
+ warn(
81
+ f"Error while trying to close the duplicated file descriptor:\n"
82
+ + f"{traceback.format_exc()}"
83
+ )
84
+
85
+ try:
86
+ os.close(self.write_pipe)
87
+ except OSError as e:
88
+ if e.errno != FD_CLOSED:
89
+ warn(
90
+ f"Error while trying to close the write-pipe "
91
+ + "to the intercepted file descriptor:\n"
92
+ + f"{traceback.format_exc()}"
93
+ )
94
+ try:
95
+ os.close(self.read_pipe)
96
+ except OSError as e:
97
+ if e.errno != FD_CLOSED:
98
+ warn(
99
+ f"Error while trying to close the read-pipe "
100
+ + "to the intercepted file descriptor:\n"
101
+ + f"{traceback.format_exc()}"
102
+ )