meerschaum 3.0.0rc4__py3-none-any.whl → 3.0.0rc7__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 (117) hide show
  1. meerschaum/_internal/arguments/_parser.py +14 -2
  2. meerschaum/_internal/cli/__init__.py +6 -0
  3. meerschaum/_internal/cli/daemons.py +103 -0
  4. meerschaum/_internal/cli/entry.py +220 -0
  5. meerschaum/_internal/cli/workers.py +434 -0
  6. meerschaum/_internal/docs/index.py +1 -2
  7. meerschaum/_internal/entry.py +44 -8
  8. meerschaum/_internal/shell/Shell.py +113 -19
  9. meerschaum/_internal/shell/__init__.py +4 -1
  10. meerschaum/_internal/static.py +3 -1
  11. meerschaum/_internal/term/TermPageHandler.py +1 -2
  12. meerschaum/_internal/term/__init__.py +40 -6
  13. meerschaum/_internal/term/tools.py +33 -8
  14. meerschaum/actions/__init__.py +6 -4
  15. meerschaum/actions/api.py +39 -11
  16. meerschaum/actions/attach.py +1 -0
  17. meerschaum/actions/delete.py +4 -2
  18. meerschaum/actions/edit.py +27 -8
  19. meerschaum/actions/login.py +8 -8
  20. meerschaum/actions/register.py +13 -7
  21. meerschaum/actions/reload.py +22 -5
  22. meerschaum/actions/restart.py +14 -0
  23. meerschaum/actions/show.py +69 -4
  24. meerschaum/actions/start.py +135 -14
  25. meerschaum/actions/stop.py +36 -3
  26. meerschaum/actions/sync.py +6 -1
  27. meerschaum/api/__init__.py +35 -13
  28. meerschaum/api/_events.py +2 -2
  29. meerschaum/api/_oauth2.py +47 -4
  30. meerschaum/api/dash/callbacks/dashboard.py +29 -0
  31. meerschaum/api/dash/callbacks/jobs.py +3 -2
  32. meerschaum/api/dash/callbacks/login.py +10 -1
  33. meerschaum/api/dash/callbacks/register.py +9 -2
  34. meerschaum/api/dash/pages/login.py +2 -2
  35. meerschaum/api/dash/pipes.py +72 -36
  36. meerschaum/api/dash/webterm.py +14 -6
  37. meerschaum/api/models/_pipes.py +7 -1
  38. meerschaum/api/resources/static/js/terminado.js +3 -0
  39. meerschaum/api/resources/static/js/xterm-addon-unicode11.js +2 -0
  40. meerschaum/api/resources/templates/termpage.html +1 -0
  41. meerschaum/api/routes/_jobs.py +23 -11
  42. meerschaum/api/routes/_login.py +73 -5
  43. meerschaum/api/routes/_pipes.py +6 -4
  44. meerschaum/api/routes/_webterm.py +3 -3
  45. meerschaum/config/__init__.py +60 -13
  46. meerschaum/config/_default.py +89 -61
  47. meerschaum/config/_edit.py +10 -8
  48. meerschaum/config/_formatting.py +2 -0
  49. meerschaum/config/_patch.py +4 -2
  50. meerschaum/config/_paths.py +127 -12
  51. meerschaum/config/_read_config.py +20 -10
  52. meerschaum/config/_version.py +1 -1
  53. meerschaum/config/environment.py +262 -0
  54. meerschaum/config/stack/__init__.py +7 -5
  55. meerschaum/connectors/_Connector.py +1 -2
  56. meerschaum/connectors/__init__.py +37 -2
  57. meerschaum/connectors/api/_APIConnector.py +1 -1
  58. meerschaum/connectors/api/_jobs.py +11 -0
  59. meerschaum/connectors/api/_pipes.py +7 -1
  60. meerschaum/connectors/instance/_plugins.py +9 -1
  61. meerschaum/connectors/instance/_tokens.py +20 -3
  62. meerschaum/connectors/instance/_users.py +8 -1
  63. meerschaum/connectors/parse.py +1 -1
  64. meerschaum/connectors/sql/_create_engine.py +3 -0
  65. meerschaum/connectors/sql/_pipes.py +93 -79
  66. meerschaum/connectors/sql/_users.py +8 -1
  67. meerschaum/connectors/valkey/_ValkeyConnector.py +3 -3
  68. meerschaum/connectors/valkey/_pipes.py +7 -5
  69. meerschaum/core/Pipe/__init__.py +45 -71
  70. meerschaum/core/Pipe/_attributes.py +66 -90
  71. meerschaum/core/Pipe/_cache.py +555 -0
  72. meerschaum/core/Pipe/_clear.py +0 -11
  73. meerschaum/core/Pipe/_data.py +0 -50
  74. meerschaum/core/Pipe/_deduplicate.py +0 -13
  75. meerschaum/core/Pipe/_delete.py +12 -21
  76. meerschaum/core/Pipe/_drop.py +11 -23
  77. meerschaum/core/Pipe/_dtypes.py +1 -1
  78. meerschaum/core/Pipe/_index.py +8 -14
  79. meerschaum/core/Pipe/_sync.py +12 -18
  80. meerschaum/core/Plugin/_Plugin.py +7 -1
  81. meerschaum/core/Token/_Token.py +1 -1
  82. meerschaum/core/User/_User.py +1 -2
  83. meerschaum/jobs/_Executor.py +88 -4
  84. meerschaum/jobs/_Job.py +135 -35
  85. meerschaum/jobs/systemd.py +7 -2
  86. meerschaum/plugins/__init__.py +277 -81
  87. meerschaum/utils/daemon/Daemon.py +195 -41
  88. meerschaum/utils/daemon/FileDescriptorInterceptor.py +0 -1
  89. meerschaum/utils/daemon/RotatingFile.py +63 -36
  90. meerschaum/utils/daemon/StdinFile.py +53 -13
  91. meerschaum/utils/daemon/__init__.py +18 -5
  92. meerschaum/utils/daemon/_names.py +6 -3
  93. meerschaum/utils/debug.py +34 -4
  94. meerschaum/utils/dtypes/__init__.py +5 -1
  95. meerschaum/utils/formatting/__init__.py +4 -1
  96. meerschaum/utils/formatting/_jobs.py +1 -1
  97. meerschaum/utils/formatting/_pipes.py +47 -46
  98. meerschaum/utils/formatting/_shell.py +16 -6
  99. meerschaum/utils/misc.py +18 -38
  100. meerschaum/utils/packages/__init__.py +15 -13
  101. meerschaum/utils/packages/_packages.py +1 -0
  102. meerschaum/utils/pipes.py +33 -5
  103. meerschaum/utils/process.py +1 -1
  104. meerschaum/utils/prompt.py +171 -144
  105. meerschaum/utils/sql.py +12 -2
  106. meerschaum/utils/threading.py +42 -0
  107. meerschaum/utils/venv/__init__.py +2 -0
  108. meerschaum/utils/warnings.py +19 -13
  109. {meerschaum-3.0.0rc4.dist-info → meerschaum-3.0.0rc7.dist-info}/METADATA +3 -1
  110. {meerschaum-3.0.0rc4.dist-info → meerschaum-3.0.0rc7.dist-info}/RECORD +116 -110
  111. meerschaum/config/_environment.py +0 -145
  112. {meerschaum-3.0.0rc4.dist-info → meerschaum-3.0.0rc7.dist-info}/WHEEL +0 -0
  113. {meerschaum-3.0.0rc4.dist-info → meerschaum-3.0.0rc7.dist-info}/entry_points.txt +0 -0
  114. {meerschaum-3.0.0rc4.dist-info → meerschaum-3.0.0rc7.dist-info}/licenses/LICENSE +0 -0
  115. {meerschaum-3.0.0rc4.dist-info → meerschaum-3.0.0rc7.dist-info}/licenses/NOTICE +0 -0
  116. {meerschaum-3.0.0rc4.dist-info → meerschaum-3.0.0rc7.dist-info}/top_level.txt +0 -0
  117. {meerschaum-3.0.0rc4.dist-info → meerschaum-3.0.0rc7.dist-info}/zip-safe +0 -0
@@ -136,17 +136,17 @@ def get_module_path(
136
136
 
137
137
 
138
138
  def manually_import_module(
139
- import_name: str,
140
- venv: Optional[str] = 'mrsm',
141
- check_update: bool = True,
142
- check_pypi: bool = False,
143
- install: bool = True,
144
- split: bool = True,
145
- warn: bool = True,
146
- color: bool = True,
147
- debug: bool = False,
148
- use_sys_modules: bool = True,
149
- ) -> Union['ModuleType', None]:
139
+ import_name: str,
140
+ venv: Optional[str] = 'mrsm',
141
+ check_update: bool = True,
142
+ check_pypi: bool = False,
143
+ install: bool = True,
144
+ split: bool = True,
145
+ warn: bool = True,
146
+ color: bool = True,
147
+ debug: bool = False,
148
+ use_sys_modules: bool = True,
149
+ ) -> Union['ModuleType', None]:
150
150
  """
151
151
  Manually import a module from a virtual environment (or the base environment).
152
152
 
@@ -1179,7 +1179,8 @@ def run_python_package(
1179
1179
  Either a return code integer or a `subprocess.Popen` object
1180
1180
  (or `None` if a `KeyboardInterrupt` occurs and as_proc is `True`).
1181
1181
  """
1182
- import sys, platform
1182
+ import sys
1183
+ import platform
1183
1184
  import subprocess
1184
1185
  from meerschaum.config._paths import VIRTENV_RESOURCES_PATH
1185
1186
  from meerschaum.utils.process import run_process
@@ -1206,7 +1207,7 @@ def run_python_package(
1206
1207
  capture_output=capture_output,
1207
1208
  **kw
1208
1209
  )
1209
- except Exception as e:
1210
+ except Exception:
1210
1211
  msg = f"Failed to execute {command}, will try again:\n{traceback.format_exc()}"
1211
1212
  warn(msg, color=False)
1212
1213
  stdout, stderr = (
@@ -1218,6 +1219,7 @@ def run_python_package(
1218
1219
  command,
1219
1220
  stdout=stdout,
1220
1221
  stderr=stderr,
1222
+ stdin=sys.stdin,
1221
1223
  env=env_dict,
1222
1224
  )
1223
1225
  to_return = proc if as_proc else proc.wait()
@@ -175,6 +175,7 @@ packages['api'] = {
175
175
  'httpx' : 'httpx>=0.28.1',
176
176
  'httpcore' : 'httpcore>=1.0.9',
177
177
  'valkey' : 'valkey>=6.1.0',
178
+ 'jose' : 'python-jose>=3.5.0',
178
179
  }
179
180
  packages['api'].update(packages['sql'])
180
181
  packages['api'].update(packages['formatting'])
meerschaum/utils/pipes.py CHANGED
@@ -14,7 +14,7 @@ import ast
14
14
  import copy
15
15
  import uuid
16
16
 
17
- from meerschaum.utils.typing import PipesDict, Optional, Any
17
+ from meerschaum.utils.typing import PipesDict, Optional
18
18
  import meerschaum as mrsm
19
19
 
20
20
 
@@ -96,9 +96,7 @@ def replace_pipes_syntax(text: str) -> Any:
96
96
  Parse a string containing the `{{ Pipe() }}` syntax.
97
97
  """
98
98
  from meerschaum.utils.warnings import warn
99
- from meerschaum.utils.sql import sql_item_name
100
99
  from meerschaum.utils.dtypes import json_serialize_value
101
- from meerschaum.utils.misc import parse_arguments_str
102
100
  pattern = r'\{\{\s*(?:mrsm\.)?Pipe\((.*?)\)((?:\.[\w]+|\[[^\]]+\])*)\s*\}\}'
103
101
 
104
102
  matches = list(re.finditer(pattern, text))
@@ -158,7 +156,6 @@ def replace_pipes_in_dict(
158
156
 
159
157
  debug: bool, default False
160
158
  Verbosity toggle.
161
-
162
159
 
163
160
  Returns
164
161
  -------
@@ -168,7 +165,7 @@ def replace_pipes_in_dict(
168
165
  def change_dict(d: Dict[Any, Any]) -> None:
169
166
  for k, v in d.items():
170
167
  if isinstance(v, dict):
171
- change_dict(v, func)
168
+ change_dict(v)
172
169
  elif isinstance(v, list):
173
170
  d[k] = [func(i) for i in v]
174
171
  elif isinstance(v, tuple):
@@ -183,3 +180,34 @@ def replace_pipes_in_dict(
183
180
  result = copy.deepcopy(pipes)
184
181
  change_dict(result)
185
182
  return result
183
+
184
+
185
+ def is_pipe_registered(
186
+ pipe: mrsm.Pipe,
187
+ pipes: PipesDict,
188
+ debug: bool = False
189
+ ) -> bool:
190
+ """
191
+ Check if a Pipe is inside the pipes dictionary.
192
+
193
+ Parameters
194
+ ----------
195
+ pipe: meerschaum.Pipe
196
+ The pipe to see if it's in the dictionary.
197
+
198
+ pipes: PipesDict
199
+ The dictionary to search inside.
200
+
201
+ debug: bool, default False
202
+ Verbosity toggle.
203
+
204
+ Returns
205
+ -------
206
+ A bool indicating whether the pipe is inside the dictionary.
207
+ """
208
+ from meerschaum.utils.debug import dprint
209
+ ck, mk, lk = pipe.connector_keys, pipe.metric_key, pipe.location_key
210
+ if debug:
211
+ dprint(f'{ck}, {mk}, {lk}')
212
+ dprint(f'{pipe}, {pipes}')
213
+ return ck in pipes and mk in pipes[ck] and lk in pipes[ck][mk]
@@ -178,7 +178,7 @@ def run_process(
178
178
  # make us tty's foreground again
179
179
  try:
180
180
  os.tcsetpgrp(sys.stdin.fileno(), old_pgrp)
181
- except Exception as e:
181
+ except Exception:
182
182
  pass
183
183
  # now restore the handler
184
184
  signal.signal(signal.SIGTTOU, hdlr)
@@ -14,7 +14,8 @@ from meerschaum.utils.typing import Any, Union, Optional, Tuple, List
14
14
 
15
15
 
16
16
  def prompt(
17
- question: str,
17
+ question: str = '',
18
+ *,
18
19
  icon: bool = True,
19
20
  default: Union[str, Tuple[str, str], None] = None,
20
21
  default_editable: Optional[str] = None,
@@ -22,6 +23,7 @@ def prompt(
22
23
  is_password: bool = False,
23
24
  wrap_lines: bool = True,
24
25
  noask: bool = False,
26
+ silent: bool = False,
25
27
  **kw: Any
26
28
  ) -> str:
27
29
  """
@@ -59,6 +61,9 @@ def prompt(
59
61
  noask: bool, default False
60
62
  If `True`, only print the question and return the default answer.
61
63
 
64
+ silent: bool, default False
65
+ If `True` do not print anything to the screen, but still block for input.
66
+
62
67
  Returns
63
68
  -------
64
69
  A `str` of the input provided by the user.
@@ -67,11 +72,24 @@ def prompt(
67
72
  from meerschaum.utils.packages import attempt_import
68
73
  from meerschaum.utils.formatting import ANSI, CHARSET, highlight_pipes, fill_ansi
69
74
  from meerschaum.config import get_config
70
- from meerschaum.utils.misc import filter_keywords
71
- from meerschaum.utils.daemon import running_in_daemon
75
+ from meerschaum.utils.misc import filter_keywords, remove_ansi
76
+ from meerschaum.utils.daemon import running_in_daemon, get_current_daemon
77
+
78
+ original_kwargs = {
79
+ 'question': question,
80
+ 'icon': icon,
81
+ 'default': default,
82
+ 'default_editable': default_editable,
83
+ 'detect_password': detect_password,
84
+ 'is_password': is_password,
85
+ 'wrap_lines': wrap_lines,
86
+ 'noask': noask,
87
+ 'silent': silent,
88
+ **kw
89
+ }
90
+
72
91
  noask = check_noask(noask)
73
- if not noask:
74
- prompt_toolkit = attempt_import('prompt_toolkit')
92
+ prompt_toolkit = attempt_import('prompt_toolkit')
75
93
  question_config = get_config('formatting', 'question', patch=True)
76
94
 
77
95
  ### if a default is provided, append it to the question.
@@ -103,27 +121,49 @@ def prompt(
103
121
  question += first_line
104
122
  if len(other_lines) > 0:
105
123
  question += '\n' + other_lines
106
- question += ' '
124
+
125
+ if not remove_ansi(question).endswith(' '):
126
+ question += ' '
127
+
128
+ prompt_kwargs = {
129
+ 'message': prompt_toolkit.formatted_text.ANSI(question) if not silent else '',
130
+ 'wrap_lines': wrap_lines,
131
+ 'default': default_editable or '',
132
+ **filter_keywords(prompt_toolkit.prompt, **kw)
133
+ }
134
+
135
+ printed_question = False
107
136
 
108
137
  if not running_in_daemon():
109
- answer = (
110
- prompt_toolkit.prompt(
111
- prompt_toolkit.formatted_text.ANSI(question),
112
- wrap_lines=wrap_lines,
113
- default=default_editable or '',
114
- **filter_keywords(prompt_toolkit.prompt, **kw)
115
- ) if not noask else ''
116
- )
138
+ answer = prompt_toolkit.prompt(**prompt_kwargs) if not noask else ''
139
+ printed_question = True
117
140
  else:
118
- print(question, end='\n', flush=True)
141
+ import json
142
+ daemon = get_current_daemon()
143
+ print('', end='', flush=True)
144
+ wrote_file = False
145
+ try:
146
+ with open(daemon.prompt_kwargs_file_path, 'w+', encoding='utf-8') as f:
147
+ json.dump(original_kwargs, f, separators=(',', ':'))
148
+ wrote_file = True
149
+ except Exception:
150
+ pass
151
+
152
+ if not silent and not wrote_file:
153
+ print(question, end='', flush=True)
154
+ printed_question = True
155
+
119
156
  try:
120
157
  answer = input() if not noask else ''
121
158
  except EOFError:
122
159
  answer = ''
123
- if noask:
124
- print(question)
160
+
161
+ if noask and not silent and not printed_question:
162
+ print(question, flush=True)
163
+
125
164
  if answer == '' and default is not None:
126
165
  return default_answer
166
+
127
167
  return answer
128
168
 
129
169
 
@@ -207,7 +247,7 @@ def yes_no(
207
247
 
208
248
  def choose(
209
249
  question: str,
210
- choices: List[Union[str, Tuple[str, str]]],
250
+ choices: Union[List[str], List[Tuple[str, str]]],
211
251
  default: Union[str, List[str], None] = None,
212
252
  numeric: bool = True,
213
253
  multiple: bool = False,
@@ -270,8 +310,9 @@ def choose(
270
310
  noask = check_noask(noask)
271
311
 
272
312
  ### Handle empty choices.
273
- if len(choices) == 0:
274
- _warn(f"No available choices. Returning default value '{default}'.", stacklevel=3)
313
+ if not choices:
314
+ if warn:
315
+ _warn(f"No available choices. Returning default value '{default}'.", stacklevel=3)
275
316
  return default
276
317
 
277
318
  ### If the default case is to include multiple answers, allow for multiple inputs.
@@ -279,18 +320,25 @@ def choose(
279
320
  multiple = True
280
321
 
281
322
  choices_indices = {}
282
- for i, c in enumerate(choices):
323
+ for i, c in enumerate(choices, start=1):
283
324
  if isinstance(c, tuple):
284
325
  i, c = c
285
326
  choices_indices[i] = c
286
327
 
328
+ choices_values_indices = {v: k for k, v in choices_indices.items()}
329
+ ordered_keys = list(choices_indices.keys())
330
+ numeric_map = {str(i): key for i, key in enumerate(ordered_keys, 1)}
331
+
287
332
  def _enforce_default(d):
288
- if d is not None and d not in choices and d not in choices_indices and warn:
289
- _warn(
290
- f"Default choice '{default}' is not contained in the choices {choices}. "
291
- + "Setting numeric = False.",
292
- stacklevel = 3
293
- )
333
+ if d is None:
334
+ return True
335
+ if d not in choices_values_indices and d not in choices_indices:
336
+ if warn:
337
+ _warn(
338
+ f"Default choice '{d}' is not contained in the choices. "
339
+ + "Setting numeric = False.",
340
+ stacklevel=3
341
+ )
294
342
  return False
295
343
  return True
296
344
 
@@ -300,163 +348,141 @@ def choose(
300
348
  numeric = False
301
349
  break
302
350
 
303
- _default = default
304
- _choices = list(choices_indices.values())
351
+ _choices = (
352
+ [str(k) for k in choices_indices] if numeric
353
+ else list(choices_indices.values())
354
+ )
305
355
  if multiple:
306
356
  question += f"\n Enter your choices, separated by '{delimiter}'.\n"
307
357
 
308
358
  altered_choices = {}
309
- altered_indices = {}
310
- altered_default_indices = {}
311
- delim_replacement = '_' if delimiter != '_' else '-'
312
- can_strip_start_spaces, can_strip_end_spaces = True, True
313
- for i, c in choices_indices.items():
314
- if isinstance(c, tuple):
315
- key, c = c
316
- if can_strip_start_spaces and c.startswith(' '):
317
- can_strip_start_spaces = False
318
- if can_strip_end_spaces and c.endswith(' '):
319
- can_strip_end_spaces = False
320
-
321
- if multiple:
322
- ### Check if the defaults have the delimiter.
323
- for i, d in enumerate(default if isinstance(default, list) else [default]):
324
- if d is None or delimiter not in d:
325
- continue
326
- new_d = d.replace(delimiter, delim_replacement)
327
- altered_choices[new_d] = d
328
- altered_default_indices[i] = new_d
329
- for i, new_d in altered_default_indices.items():
330
- if not isinstance(default, list):
331
- default = new_d
332
- break
333
- default[i] = new_d
334
-
359
+ if multiple and not numeric:
360
+ delim_replacement = '_' if delimiter != '_' else '-'
335
361
  ### Check if the choices have the delimiter.
336
362
  for i, c in choices_indices.items():
337
- if delimiter in c and not numeric and warn:
363
+ if delimiter not in c:
364
+ continue
365
+ if warn:
338
366
  _warn(
339
367
  f"The delimiter '{delimiter}' is contained within choice '{c}'.\n"
340
368
  + f"Replacing the string '{delimiter}' with '{delim_replacement}' in "
341
369
  + "the choice for correctly parsing input (will be replaced upon returning the prompt).",
342
- stacklevel = 3,
370
+ stacklevel=3,
343
371
  )
344
- new_c = c.replace(delimiter, delim_replacement)
345
- altered_choices[new_c] = c
346
- altered_indices[i] = new_c
347
- for i, new_c in altered_indices.items():
372
+ new_c = c.replace(delimiter, delim_replacement)
373
+ altered_choices[new_c] = c
348
374
  choices_indices[i] = new_c
349
- default = delimiter.join(default) if isinstance(default, list) else default
350
375
 
351
376
  question_options = []
377
+ default_tuple = None
352
378
  if numeric:
353
- _choices = [str(i + 1) for i, c in enumerate(choices)]
354
- _default = ''
379
+ _default_prompt_str = ''
355
380
  if default is not None:
356
- for d in (default.split(delimiter) if multiple else [default]):
357
- if d not in choices and d in choices_indices:
358
- d_index = d
359
- d_value = choices_indices[d]
360
- for _i, _option in enumerate(choices):
361
- if (
362
- isinstance(_option, tuple) and (
363
- _option[1] == d_value
364
- or
365
- _option[0] == d_index
366
- )
367
- ) or d_index == _i:
368
- d = _option
369
-
370
- _d = str(choices.index(d) + 1)
371
- _default += _d + delimiter
372
- _default = _default[:-1 * len(delimiter)]
373
- # question += '\n'
381
+ default_list = default if isinstance(default, list) else [default]
382
+ if multiple and isinstance(default, str):
383
+ default_list = default.split(delimiter)
384
+
385
+ _default_indices = []
386
+ for d in default_list:
387
+ key = None
388
+ if d in choices_values_indices: # is a value
389
+ key = choices_values_indices[d]
390
+ elif d in choices_indices: # is an index
391
+ key = d
392
+
393
+ if key in ordered_keys:
394
+ _default_indices.append(str(ordered_keys.index(key) + 1))
395
+
396
+ _default_prompt_str = delimiter.join(_default_indices)
397
+
374
398
  choices_digits = len(str(len(choices)))
375
- for i, c in enumerate(choices_indices.values()):
399
+ for choice_ix, c in enumerate(choices_indices.values(), start=1):
376
400
  question_options.append(
377
- f" {i + 1}. "
378
- + (" " * (choices_digits - len(str(i + 1))))
401
+ f" {choice_ix}. "
402
+ + (" " * (choices_digits - len(str(choice_ix))))
379
403
  + f"{c}\n"
380
404
  )
381
- default_tuple = (_default, default) if default is not None else None
405
+ default_tuple = (_default_prompt_str, default) if default is not None else None
382
406
  else:
383
407
  default_tuple = default
384
- # question += '\n'
385
408
  for c in choices_indices.values():
386
- question_options.append(f"{c}\n")
409
+ question_options.append(f"{c}\n")
387
410
 
388
411
  if 'completer' not in kw:
389
- WordCompleter = attempt_import('prompt_toolkit.completion').WordCompleter
390
- kw['completer'] = WordCompleter(choices_indices.values(), sentence=True)
412
+ WordCompleter = attempt_import('prompt_toolkit.completion', lazy=False).WordCompleter
413
+ kw['completer'] = WordCompleter(
414
+ [str(v) for v in choices_indices.values()] + [str(i) for i in choices_indices],
415
+ sentence=True,
416
+ )
391
417
 
392
- valid = False
393
- while not valid:
418
+ answers = []
419
+ while not answers:
394
420
  print_options(question_options, header='')
395
421
  answer = prompt(
396
422
  question,
397
- icon = icon,
398
- default = default_tuple,
399
- noask = noask,
423
+ icon=icon,
424
+ default=default_tuple,
425
+ noask=noask,
400
426
  **kw
401
427
  )
428
+ if not answer and default is not None:
429
+ answer = default if isinstance(default, str) else delimiter.join(default)
430
+
402
431
  if not answer:
432
+ if warn:
433
+ _warn("Please pick a valid choice.", stack=False)
403
434
  continue
404
- ### Split along the delimiter.
405
- _answers = [answer] if not multiple else [a for a in answer.split(delimiter)]
406
-
407
- ### Remove trailing spaces if possible.
408
- _answers = [(_a.rstrip(' ') if can_strip_end_spaces else _a) for _a in _answers]
409
-
410
- ### Remove leading spaces if possible.
411
- _answers = [(_a.lstrip(' ') if can_strip_start_spaces else _a) for _a in _answers]
412
-
413
- ### Remove empty strings.
414
- _answers = [_a for _a in _answers if _a]
415
-
416
- if multiple and len(_answers) == 0:
417
- _answers = default_tuple if isinstance(default_tuple, list) else [default_tuple]
418
- answers = [altered_choices.get(a, a) for a in _answers]
419
-
420
- valid = (len(answers) > 1 or not (len(answers) == 1 and answers[0] is None))
421
- for a in answers:
422
- if (
423
- a not in {_original for _new, _original in altered_choices.items()}
424
- and a not in _choices
425
- and a != default
426
- and not noask
427
- ):
428
- valid = False
429
- break
430
- if valid:
431
- break
432
- if warn:
433
- _warn("Please pick a valid choice.", stack=False)
435
+
436
+ _answers = [answer] if not multiple else [a.strip() for a in answer.split(delimiter)]
437
+ _answers = [a for a in _answers if a]
438
+
439
+ if numeric:
440
+ _raw_answers = list(_answers)
441
+ _answers = []
442
+ for _a in _raw_answers:
443
+ if _a in choices_values_indices:
444
+ _answers.append(str(choices_values_indices[_a]))
445
+ elif _a in numeric_map:
446
+ _answers.append(str(numeric_map[_a]))
447
+ else:
448
+ _answers.append(_a)
449
+
450
+ _processed_answers = [altered_choices.get(a, a) for a in _answers]
451
+
452
+ valid_answers = []
453
+ for a in _processed_answers:
454
+ if a in _choices:
455
+ valid_answers.append(a)
456
+
457
+ if len(valid_answers) != len(_processed_answers):
458
+ if warn:
459
+ _warn("Please pick a valid choice.", stack=False)
460
+ continue
461
+ answers = valid_answers
462
+
463
+ def get_key(key_str):
464
+ try:
465
+ return int(key_str)
466
+ except (ValueError, TypeError):
467
+ return key_str
434
468
 
435
469
  if not multiple:
470
+ answer = answers[0]
436
471
  if not numeric:
437
- return answer
438
- try:
439
- _answer = choices[int(answer) - 1]
440
- if as_indices and isinstance(_answer, tuple):
441
- return _answer[0]
442
- return _answer
443
- except Exception:
444
- _warn(f"Could not cast answer '{answer}' to an integer.", stacklevel=3)
472
+ return choices_values_indices.get(answer, answer) if as_indices else answer
473
+
474
+ key = get_key(answer)
475
+ return key if as_indices else choices_indices[key]
445
476
 
446
477
  if not numeric:
447
- return answers
478
+ return [choices_values_indices.get(a, a) for a in answers] if as_indices else answers
448
479
 
449
- _answers = []
480
+ final_answers = []
450
481
  for a in answers:
451
- try:
452
- _answer = choices[int(a) - 1]
453
- _answer_to_return = altered_choices.get(_answer, _answer)
454
- if isinstance(_answer_to_return, tuple) and as_indices:
455
- _answer_to_return = _answer_to_return[0]
456
- _answers.append(_answer_to_return)
457
- except Exception:
458
- _warn(f"Could not cast answer '{a}' to an integer.", stacklevel=3)
459
- return _answers
482
+ key = get_key(a)
483
+ final_answers.append(key if as_indices else choices_indices[key])
484
+ return final_answers
485
+
460
486
 
461
487
 
462
488
  def get_password(
@@ -570,6 +596,7 @@ def check_noask(noask: bool = False) -> bool:
570
596
  NOASK = STATIC_CONFIG['environment']['noask']
571
597
  if noask:
572
598
  return True
599
+
573
600
  return (
574
601
  os.environ.get(NOASK, 'false').lower()
575
602
  in ('1', 'true')
meerschaum/utils/sql.py CHANGED
@@ -1586,6 +1586,8 @@ def get_update_queries(
1586
1586
  datetime_col: Optional[str] = None,
1587
1587
  schema: Optional[str] = None,
1588
1588
  patch_schema: Optional[str] = None,
1589
+ target_cols_types: Optional[Dict[str, str]] = None,
1590
+ patch_cols_types: Optional[Dict[str, str]] = None,
1589
1591
  identity_insert: bool = False,
1590
1592
  null_indices: bool = True,
1591
1593
  cast_columns: bool = True,
@@ -1626,6 +1628,14 @@ def get_update_queries(
1626
1628
  If provided, use this schema when quoting the patch table.
1627
1629
  Defaults to `schema`.
1628
1630
 
1631
+ target_cols_types: Optional[Dict[str, Any]], default None
1632
+ If provided, use these as the columns-types dictionary for the target table.
1633
+ Default will infer from the database context.
1634
+
1635
+ patch_cols_types: Optional[Dict[str, Any]], default None
1636
+ If provided, use these as the columns-types dictionary for the target table.
1637
+ Default will infer from the database context.
1638
+
1629
1639
  identity_insert: bool, default False
1630
1640
  If `True`, include `SET IDENTITY_INSERT` queries before and after the update queries.
1631
1641
  Only applies for MSSQL upserts.
@@ -1672,14 +1682,14 @@ def get_update_queries(
1672
1682
  flavor=flavor,
1673
1683
  schema=schema,
1674
1684
  debug=debug,
1675
- )
1685
+ ) if not target_cols_types else target_cols_types
1676
1686
  patch_table_columns = get_table_cols_types(
1677
1687
  patch,
1678
1688
  connectable,
1679
1689
  flavor=flavor,
1680
1690
  schema=patch_schema,
1681
1691
  debug=debug,
1682
- )
1692
+ ) if not patch_cols_types else patch_cols_types
1683
1693
 
1684
1694
  patch_cols_str = ', '.join(
1685
1695
  [
@@ -10,6 +10,9 @@ from __future__ import annotations
10
10
  from meerschaum.utils.typing import Optional
11
11
 
12
12
  import threading
13
+ import ctypes
14
+ import signal
15
+
13
16
  Lock = threading.Lock
14
17
  RLock = threading.RLock
15
18
  Event = threading.Event
@@ -54,6 +57,45 @@ class Thread(threading.Thread):
54
57
  """Set the return to the result of the target."""
55
58
  self._return = self._target(*self._args, **self._kwargs)
56
59
 
60
+ def send_signal(self, signalnum):
61
+ """
62
+ Send a signal to the thread.
63
+ """
64
+ if not self.is_alive():
65
+ return
66
+
67
+ if signalnum == signal.SIGINT:
68
+ self.raise_exception(KeyboardInterrupt())
69
+ elif signalnum == signal.SIGTERM:
70
+ self.raise_exception(SystemExit())
71
+ else:
72
+ signal.pthread_kill(self.ident, signalnum)
73
+
74
+ def raise_exception(self, exc: BaseException):
75
+ """
76
+ Raise an exception in the thread.
77
+
78
+ This uses a CPython-specific implementation and is not guaranteed to be stable.
79
+ It may also be deprecated in future Python versions.
80
+ """
81
+ if not self.is_alive():
82
+ return
83
+
84
+ if not hasattr(ctypes.pythonapi, 'PyThreadState_SetAsyncExc'):
85
+ return
86
+
87
+ exc_class = exc if isinstance(exc, type) else type(exc)
88
+
89
+ ident = self.ident
90
+ if ident is None:
91
+ return
92
+
93
+ ret = ctypes.pythonapi.PyThreadState_SetAsyncExc(
94
+ ctypes.c_ulong(ident),
95
+ ctypes.py_object(exc_class)
96
+ )
97
+ if ret > 1:
98
+ ctypes.pythonapi.PyThreadState_SetAsyncExc(ident, 0)
57
99
 
58
100
  class Worker(threading.Thread):
59
101
  """Wrapper for `threading.Thread` for working with `queue.Queue` objects."""