meerschaum 2.2.4__py3-none-any.whl → 2.2.5.dev2__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 (37) hide show
  1. meerschaum/_internal/arguments/_parse_arguments.py +23 -14
  2. meerschaum/_internal/arguments/_parser.py +4 -1
  3. meerschaum/_internal/entry.py +2 -4
  4. meerschaum/_internal/shell/Shell.py +0 -3
  5. meerschaum/actions/__init__.py +5 -1
  6. meerschaum/actions/backup.py +43 -0
  7. meerschaum/actions/bootstrap.py +32 -7
  8. meerschaum/actions/delete.py +62 -0
  9. meerschaum/actions/edit.py +98 -15
  10. meerschaum/actions/python.py +44 -2
  11. meerschaum/actions/show.py +26 -0
  12. meerschaum/actions/uninstall.py +24 -29
  13. meerschaum/api/_oauth2.py +17 -0
  14. meerschaum/api/routes/_login.py +23 -7
  15. meerschaum/config/__init__.py +16 -6
  16. meerschaum/config/_edit.py +1 -1
  17. meerschaum/config/_paths.py +3 -0
  18. meerschaum/config/_version.py +1 -1
  19. meerschaum/config/stack/__init__.py +3 -1
  20. meerschaum/core/Pipe/_fetch.py +25 -21
  21. meerschaum/core/Pipe/_sync.py +89 -59
  22. meerschaum/plugins/bootstrap.py +333 -0
  23. meerschaum/utils/daemon/Daemon.py +14 -3
  24. meerschaum/utils/daemon/FileDescriptorInterceptor.py +21 -14
  25. meerschaum/utils/daemon/RotatingFile.py +21 -18
  26. meerschaum/utils/formatting/__init__.py +22 -10
  27. meerschaum/utils/packages/_packages.py +1 -1
  28. meerschaum/utils/prompt.py +64 -21
  29. meerschaum/utils/yaml.py +32 -1
  30. {meerschaum-2.2.4.dist-info → meerschaum-2.2.5.dev2.dist-info}/METADATA +5 -2
  31. {meerschaum-2.2.4.dist-info → meerschaum-2.2.5.dev2.dist-info}/RECORD +37 -35
  32. {meerschaum-2.2.4.dist-info → meerschaum-2.2.5.dev2.dist-info}/WHEEL +1 -1
  33. {meerschaum-2.2.4.dist-info → meerschaum-2.2.5.dev2.dist-info}/LICENSE +0 -0
  34. {meerschaum-2.2.4.dist-info → meerschaum-2.2.5.dev2.dist-info}/NOTICE +0 -0
  35. {meerschaum-2.2.4.dist-info → meerschaum-2.2.5.dev2.dist-info}/entry_points.txt +0 -0
  36. {meerschaum-2.2.4.dist-info → meerschaum-2.2.5.dev2.dist-info}/top_level.txt +0 -0
  37. {meerschaum-2.2.4.dist-info → meerschaum-2.2.5.dev2.dist-info}/zip-safe +0 -0
@@ -0,0 +1,333 @@
1
+ #! /usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # vim:fenc=utf-8
4
+
5
+ """
6
+ Define the bootstrapping wizard for creating plugins.
7
+ """
8
+
9
+ import re
10
+ import pathlib
11
+ import meerschaum as mrsm
12
+ from meerschaum.utils.typing import Any, SuccessTuple, Dict, List
13
+ from meerschaum.utils.warnings import warn, info
14
+ from meerschaum.utils.prompt import prompt, choose, yes_no
15
+ from meerschaum.utils.formatting._shell import clear_screen
16
+
17
+ FEATURE_CHOICES: Dict[str, str] = {
18
+ 'fetch' : 'Fetch data\n (e.g. extracting from a remote API)\n',
19
+ 'connector': 'Custom connector\n (e.g. manage credentials)\n',
20
+ 'action' : 'New actions\n (e.g. `mrsm sing song`)\n',
21
+ 'api' : 'New API endpoints\n (e.g. `POST /my/new/endpoint`)\n',
22
+ 'web' : 'New web console page\n (e.g. `/dash/my-web-app`)\n',
23
+ }
24
+
25
+ IMPORTS_LINES: Dict[str, str] = {
26
+ 'stdlib': (
27
+ "from datetime import datetime, timedelta, timezone\n"
28
+ ),
29
+ 'default': (
30
+ "import meerschaum as mrsm\n"
31
+ "from meerschaum.config import get_plugin_config, write_plugin_config\n"
32
+ ),
33
+ 'connector': (
34
+ "from meerschaum.connectors import Connector, make_connector\n"
35
+ ),
36
+ 'action': (
37
+ "from meerschaum.actions import make_action\n"
38
+ ),
39
+ 'api': (
40
+ "from meerschaum.plugins import api_plugin\n"
41
+ ),
42
+ 'web': (
43
+ "from meerschaum.plugins import web_page, dash_plugin\n"
44
+ ),
45
+ 'api+web': (
46
+ "from meerschaum.plugins import api_plugin, web_page, dash_plugin\n"
47
+ ),
48
+ }
49
+
50
+ ### TODO: Add feature for custom connectors.
51
+ FEATURE_LINES: Dict[str, str] = {
52
+ 'header': (
53
+ "#! /usr/bin/env python3\n"
54
+ "# -*- coding: utf-8 -*-\n\n"
55
+ "\"\"\"\n"
56
+ "Implement the plugin '{plugin_name}'.\n\n"
57
+ "See the Writing Plugins guide for more information:\n"
58
+ "https://meerschaum.io/reference/plugins/writing-plugins/\n"
59
+ "\"\"\"\n\n"
60
+ ),
61
+ 'default': (
62
+ "__version__ = '0.0.1'\n"
63
+ "\n# Add any dependencies to `required` (similar to `requirements.txt`).\n"
64
+ "required: list[str] = []\n\n\n"
65
+ ),
66
+ 'setup': (
67
+ "def setup(**kwargs) -> mrsm.SuccessTuple:\n"
68
+ " \"\"\"Executed during installation and `mrsm setup plugin {plugin_name}`.\"\"\"\n"
69
+ " return True, \"Success\"\n\n\n"
70
+ ),
71
+ 'register': (
72
+ "def register(pipe: mrsm.Pipe):\n"
73
+ " \"\"\"Return the default parameters for a new pipe.\"\"\"\n"
74
+ " return {{\n"
75
+ " 'columns': {{\n"
76
+ " 'datetime': {dt_col_name},\n"
77
+ " }}\n"
78
+ " }}\n\n\n"
79
+ ),
80
+ 'fetch': (
81
+ "def fetch(\n"
82
+ " pipe: mrsm.Pipe,\n"
83
+ " begin: datetime | None = None,\n"
84
+ " end: datetime | None = None,\n"
85
+ " **kwargs\n"
86
+ "):\n"
87
+ " \"\"\"Return or yield dataframes.\"\"\"\n"
88
+ " docs = []\n"
89
+ " # populate docs with dictionaries (rows).\n"
90
+ " return docs\n\n\n"
91
+ ),
92
+ 'connector': (
93
+ "@make_connector\n"
94
+ "class {plugin_name_capitalized}Connector(Connector):\n"
95
+ " \"\"\"Implement '{plugin_name_lower}' connectors.\"\"\"\n\n"
96
+ " REQUIRED_ATTRIBUTES: list[str] = []\n"
97
+ " \n"
98
+ " def fetch(\n"
99
+ " self,\n"
100
+ " pipe: mrsm.Pipe,\n"
101
+ " begin: datetime | None = None,\n"
102
+ " end: datetime | None = None,\n"
103
+ " **kwargs\n"
104
+ " ):\n"
105
+ " \"\"\"Return or yield dataframes.\"\"\"\n"
106
+ " docs = []\n"
107
+ " # populate docs with dictionaries (rows).\n"
108
+ " return docs\n\n\n"
109
+ ),
110
+ 'action': (
111
+ "@make_action\n"
112
+ "def {action_name}(**kwargs) -> mrsm.SuccessTuple:\n"
113
+ " \"\"\"Run `mrsm {action_spaces}` to trigger.\"\"\"\n"
114
+ " return True, \"Success\"\n\n\n"
115
+ ),
116
+ 'api': (
117
+ "@api_plugin\n"
118
+ "def init_app(fastapi_app):\n"
119
+ " \"\"\"Add new endpoints to the FastAPI app.\"\"\"\n\n"
120
+ " import fastapi\n"
121
+ " from meerschaum.api import manager\n\n"
122
+ " @fastapi_app.get('/{plugin_name}')\n"
123
+ " def get_my_endpoint(curr_user=fastapi.Depends(manager)):\n"
124
+ " return {{'message': \"Hello from plugin '{plugin_name}'!\"}}\n\n\n"
125
+ ),
126
+ 'web': (
127
+ "@dash_plugin\n"
128
+ "def init_dash(dash_app):\n"
129
+ " \"\"\"Initialize the Plotly Dash application.\"\"\"\n\n"
130
+ " import dash.html as html\n"
131
+ " import dash.dcc as dcc\n"
132
+ " from dash import Input, Output, State, no_update\n"
133
+ " import dash_bootstrap_components as dbc\n\n"
134
+ " # Create a new page at the path `/dash/{plugin_name}`.\n"
135
+ " @web_page('{plugin_name}', login_required=False)\n"
136
+ " def page_layout():\n"
137
+ " \"\"\"Return the layout objects for this page.\"\"\"\n"
138
+ " return dbc.Container([\n"
139
+ " dcc.Location(id='{plugin_name}-location'),\n"
140
+ " html.Div(id='output-div'),\n"
141
+ " ])\n\n"
142
+ " @dash_app.callback(\n"
143
+ " Output('output-div', 'children'),\n"
144
+ " Input('{plugin_name}-location', 'pathname'),\n"
145
+ " )\n"
146
+ " def render_page_on_url_change(pathname: str):\n"
147
+ " \"\"\"Reload page contents when the URL path changes.\"\"\"\n"
148
+ " return html.H1(\"Hello from plugin '{plugin_name}'!\")\n\n\n"
149
+ ),
150
+ }
151
+
152
+ def bootstrap_plugin(
153
+ plugin_name: str,
154
+ debug: bool = False,
155
+ **kwargs: Any
156
+ ) -> SuccessTuple:
157
+ """
158
+ Prompt the user for features and create a plugin file.
159
+ """
160
+ from meerschaum.utils.misc import edit_file
161
+ plugins_dir_path = _get_plugins_dir_path()
162
+ clear_screen(debug=debug)
163
+ info(
164
+ "Answer the questions below to pick out features.\n"
165
+ + " See the Writing Plugins guide for documentation:\n"
166
+ + " https://meerschaum.io/reference/plugins/writing-plugins/ "
167
+ + "for documentation.\n"
168
+ )
169
+
170
+ plugin = mrsm.Plugin(plugin_name)
171
+ if plugin.is_installed():
172
+ uninstall_success, uninstall_msg = _ask_to_uninstall(plugin)
173
+ if not uninstall_success:
174
+ return uninstall_success, uninstall_msg
175
+ clear_screen(debug=debug)
176
+
177
+ features: List[str] = choose(
178
+ "Which of the following features would you like to add to your plugin?",
179
+ list(FEATURE_CHOICES.items()),
180
+ default = 'fetch',
181
+ multiple = True,
182
+ as_indices = True,
183
+ **kwargs
184
+ )
185
+
186
+ clear_screen(debug=debug)
187
+
188
+ action_name = ''
189
+ if 'action' in features:
190
+ action_name = _get_action_name()
191
+ clear_screen(debug=debug)
192
+ action_spaces = action_name.replace('_', ' ')
193
+
194
+ dt_col_name = None
195
+ if 'fetch' in features:
196
+ dt_col_name = _get_quoted_dt_col_name()
197
+ clear_screen(debug=debug)
198
+
199
+ plugin_labels = {
200
+ 'plugin_name': plugin_name,
201
+ 'plugin_name_capitalized': re.split(
202
+ r'[-_]', plugin_name.lower()
203
+ )[0].capitalize(),
204
+ 'plugin_name_lower': plugin_name.lower(),
205
+ 'action_name': action_name,
206
+ 'action_spaces': action_spaces,
207
+ 'dt_col_name': dt_col_name,
208
+ }
209
+
210
+ body_text = ""
211
+ body_text += FEATURE_LINES['header'].format(**plugin_labels)
212
+ body_text += IMPORTS_LINES['stdlib'].format(**plugin_labels)
213
+ body_text += IMPORTS_LINES['default'].format(**plugin_labels)
214
+ if 'connector' in features:
215
+ body_text += IMPORTS_LINES['connector'].format(**plugin_labels)
216
+ if 'action' in features:
217
+ body_text += IMPORTS_LINES['action'].format(**plugin_labels)
218
+ if 'api' in features and 'web' in features:
219
+ body_text += IMPORTS_LINES['api+web'].format(**plugin_labels)
220
+ elif 'api' in features:
221
+ body_text += IMPORTS_LINES['api'].format(**plugin_labels)
222
+ elif 'web' in features:
223
+ body_text += IMPORTS_LINES['web'].format(**plugin_labels)
224
+
225
+ body_text += "\n"
226
+ body_text += FEATURE_LINES['default'].format(**plugin_labels)
227
+ body_text += FEATURE_LINES['setup'].format(**plugin_labels)
228
+
229
+ if 'fetch' in features:
230
+ body_text += FEATURE_LINES['register'].format(**plugin_labels)
231
+ body_text += FEATURE_LINES['fetch'].format(**plugin_labels)
232
+
233
+ if 'connector' in features:
234
+ body_text += FEATURE_LINES['connector'].format(**plugin_labels)
235
+
236
+ if 'action' in features:
237
+ body_text += FEATURE_LINES['action'].format(**plugin_labels)
238
+
239
+ if 'api' in features:
240
+ body_text += FEATURE_LINES['api'].format(**plugin_labels)
241
+
242
+ if 'web' in features:
243
+ body_text += FEATURE_LINES['web'].format(**plugin_labels)
244
+
245
+ try:
246
+ plugin_path = plugins_dir_path / (plugin_name + '.py')
247
+ with open(plugin_path, 'w+', encoding='utf-8') as f:
248
+ f.write(body_text.rstrip())
249
+ except Exception as e:
250
+ error_msg = f"Failed to write file '{plugin_path}':\n{e}"
251
+ return False, error_msg
252
+
253
+ clear_screen(debug=debug)
254
+ mrsm.pprint((True, f"Successfully created file '{plugin_path}'."))
255
+ try:
256
+ _ = prompt(
257
+ f"Press [Enter] to edit plugin '{plugin_name}',"
258
+ + " [CTRL+C] to skip.",
259
+ icon = False,
260
+ )
261
+ except (KeyboardInterrupt, Exception):
262
+ return True, "Success"
263
+
264
+ edit_file(plugin_path, debug=debug)
265
+ return True, "Success"
266
+
267
+
268
+ def _get_plugins_dir_path() -> pathlib.Path:
269
+ from meerschaum.config.paths import PLUGINS_DIR_PATHS
270
+
271
+ if not PLUGINS_DIR_PATHS:
272
+ raise EnvironmentError("No plugin dir path could be found.")
273
+
274
+ if len(PLUGINS_DIR_PATHS) == 1:
275
+ return PLUGINS_DIR_PATHS[0]
276
+
277
+ return pathlib.Path(
278
+ choose(
279
+ "In which directory do you want to write your plugin?",
280
+ [path.as_posix() for path in PLUGINS_DIR_PATHS],
281
+ numeric = True,
282
+ multiple = False,
283
+ default = PLUGINS_DIR_PATHS[0].as_posix(),
284
+ )
285
+ )
286
+
287
+
288
+ def _ask_to_uninstall(plugin: mrsm.Plugin, **kwargs: Any) -> SuccessTuple:
289
+ from meerschaum._internal.entry import entry
290
+ warn(f"Plugin '{plugin}' is already installed!", stack=False)
291
+ uninstall_plugin = yes_no(
292
+ f"Do you want to first uninstall '{plugin}'?",
293
+ default = 'n',
294
+ **kwargs
295
+ )
296
+ if not uninstall_plugin:
297
+ return False, f"Plugin '{plugin}' already exists."
298
+
299
+ return entry(['uninstall', 'plugin', plugin.name, '-f'])
300
+
301
+
302
+ def _get_action_name() -> str:
303
+ while True:
304
+ try:
305
+ action_name = prompt(
306
+ "What is name of your action?\n "
307
+ + "(separate subactions with spaces, e.g. `sing song`):"
308
+ ).replace(' ', '_')
309
+ except KeyboardInterrupt:
310
+ return False, "Aborted plugin creation."
311
+
312
+ if action_name:
313
+ break
314
+ warn("Please enter an action.", stack=False)
315
+ return action_name
316
+
317
+
318
+ def _get_quoted_dt_col_name() -> str:
319
+ try:
320
+ dt_col_name = prompt(
321
+ "Enter the datetime column name ([CTRL+C] to skip):"
322
+ )
323
+ except (Exception, KeyboardInterrupt):
324
+ dt_col_name = None
325
+
326
+ if dt_col_name is None:
327
+ dt_col_name = 'None'
328
+ elif '"' in dt_col_name or "'" in dt_col_name:
329
+ dt_col_name = f"\"\"\"{dt_col_name}\"\"\""
330
+ else:
331
+ dt_col_name = f"\"{dt_col_name}\""
332
+
333
+ return dt_col_name
@@ -185,7 +185,7 @@ class Daemon:
185
185
  result = self.target(*self.target_args, **self.target_kw)
186
186
  self.properties['result'] = result
187
187
  except Exception as e:
188
- warn(e, stacklevel=3)
188
+ warn(f"Exception in daemon target function: {e}", stacklevel=3)
189
189
  result = e
190
190
  finally:
191
191
  self._log_refresh_timer.cancel()
@@ -203,9 +203,20 @@ class Daemon:
203
203
  daemon_error = traceback.format_exc()
204
204
  with open(DAEMON_ERROR_LOG_PATH, 'a+', encoding='utf-8') as f:
205
205
  f.write(daemon_error)
206
+ warn(f"Encountered an error while running the daemon '{self}':\n{daemon_error}")
207
+ finally:
208
+ self._cleanup_on_exit()
206
209
 
207
- if daemon_error:
208
- warn(f"Encountered an error while starting the daemon '{self}':\n{daemon_error}")
210
+ def _cleanup_on_exit(self):
211
+ """Perform cleanup operations when the daemon exits."""
212
+ try:
213
+ self._log_refresh_timer.cancel()
214
+ self.rotating_log.close()
215
+ if self.pid is None and self.pid_path.exists():
216
+ self.pid_path.unlink()
217
+ self._capture_process_timestamp('ended')
218
+ except Exception as e:
219
+ warn(f"Error during daemon cleanup: {e}")
209
220
 
210
221
 
211
222
  def _capture_process_timestamp(
@@ -65,24 +65,31 @@ class FileDescriptorInterceptor:
65
65
  except BlockingIOError:
66
66
  continue
67
67
  except OSError as e:
68
- continue
68
+ from meerschaum.utils.warnings import warn
69
+ warn(f"OSError in FileDescriptorInterceptor: {e}")
70
+ break
69
71
 
70
- first_char_is_newline = data[0] == b'\n'
71
- last_char_is_newline = data[-1] == b'\n'
72
+ try:
73
+ first_char_is_newline = data[0] == b'\n'
74
+ last_char_is_newline = data[-1] == b'\n'
72
75
 
73
- injected_str = self.injection_hook()
74
- injected_bytes = injected_str.encode('utf-8')
76
+ injected_str = self.injection_hook()
77
+ injected_bytes = injected_str.encode('utf-8')
75
78
 
76
- if is_first_read:
77
- data = b'\n' + data
78
- is_first_read = False
79
+ if is_first_read:
80
+ data = b'\n' + data
81
+ is_first_read = False
79
82
 
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)
83
+ modified_data = (
84
+ (data[:-1].replace(b'\n', b'\n' + injected_bytes) + b'\n')
85
+ if last_char_is_newline
86
+ else data.replace(b'\n', b'\n' + injected_bytes)
87
+ )
88
+ os.write(self.new_file_descriptor, modified_data)
89
+ except Exception as e:
90
+ from meerschaum.utils.warnings import warn
91
+ warn(f"Error in FileDescriptorInterceptor data processing: {e}")
92
+ break
86
93
 
87
94
 
88
95
  def stop_interception(self):
@@ -355,26 +355,29 @@ class RotatingFile(io.IOBase):
355
355
  As such, if data is larger than max_file_size, then the corresponding subfile
356
356
  may exceed this limit.
357
357
  """
358
- self.file_path.parent.mkdir(exist_ok=True, parents=True)
359
- if isinstance(data, bytes):
360
- data = data.decode('utf-8')
361
-
362
- prefix_str = self.get_timestamp_prefix_str() if self.write_timestamps else ""
363
- suffix_str = "\n" if self.write_timestamps else ""
364
- self.refresh_files(
365
- potential_new_len = len(prefix_str + data + suffix_str),
366
- start_interception = self.write_timestamps,
367
- )
368
358
  try:
369
- if prefix_str:
370
- self._current_file_obj.write(prefix_str)
371
- self._current_file_obj.write(data)
372
- if suffix_str:
373
- self._current_file_obj.write(suffix_str)
359
+ self.file_path.parent.mkdir(exist_ok=True, parents=True)
360
+ if isinstance(data, bytes):
361
+ data = data.decode('utf-8')
362
+
363
+ prefix_str = self.get_timestamp_prefix_str() if self.write_timestamps else ""
364
+ suffix_str = "\n" if self.write_timestamps else ""
365
+ self.refresh_files(
366
+ potential_new_len = len(prefix_str + data + suffix_str),
367
+ start_interception = self.write_timestamps,
368
+ )
369
+ try:
370
+ if prefix_str:
371
+ self._current_file_obj.write(prefix_str)
372
+ self._current_file_obj.write(data)
373
+ if suffix_str:
374
+ self._current_file_obj.write(suffix_str)
375
+ except Exception as e:
376
+ warn(f"Failed to write to subfile:\n{traceback.format_exc()}")
377
+ self.flush()
378
+ self.delete(unused_only=True)
374
379
  except Exception as e:
375
- warn(f"Failed to write to subfile:\n{traceback.format_exc()}")
376
- self.flush()
377
- self.delete(unused_only=True)
380
+ warn(f"Unexpected error in RotatingFile.write: {e}")
378
381
 
379
382
 
380
383
  def delete(self, unused_only: bool = False) -> None:
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  import platform
11
11
  import os
12
12
  import sys
13
- from meerschaum.utils.typing import Optional, Union, Any
13
+ from meerschaum.utils.typing import Optional, Union, Any, Dict
14
14
  from meerschaum.utils.formatting._shell import make_header
15
15
  from meerschaum.utils.formatting._pprint import pprint
16
16
  from meerschaum.utils.formatting._pipes import (
@@ -298,6 +298,7 @@ def print_options(
298
298
  header: Optional[str] = None,
299
299
  num_cols: Optional[int] = None,
300
300
  adjust_cols: bool = True,
301
+ sort_options: bool = False,
301
302
  **kw
302
303
  ) -> None:
303
304
  """
@@ -339,6 +340,8 @@ def print_options(
339
340
  _options = []
340
341
  for o in options:
341
342
  _options.append(str(o))
343
+ if sort_options:
344
+ _options = sorted(_options)
342
345
  _header = f"Available {name}" if header is None else header
343
346
 
344
347
  if num_cols is None:
@@ -349,7 +352,7 @@ def print_options(
349
352
  print()
350
353
  print(make_header(_header))
351
354
  ### print actions
352
- for option in sorted(_options):
355
+ for option in _options:
353
356
  if not nopretty:
354
357
  print(" - ", end="")
355
358
  print(option)
@@ -385,7 +388,7 @@ def print_options(
385
388
 
386
389
  if _header is not None:
387
390
  table = Table(
388
- title = '\n' + _header,
391
+ title = ('\n' + _header) if header else header,
389
392
  box = box.SIMPLE,
390
393
  show_header = False,
391
394
  show_footer = False,
@@ -397,13 +400,22 @@ def print_options(
397
400
  for i in range(num_cols):
398
401
  table.add_column()
399
402
 
400
- chunks = iterate_chunks(
401
- [Text.from_ansi(highlight_pipes(o)) for o in sorted(_options)],
402
- num_cols,
403
- fillvalue=''
404
- )
405
- for c in chunks:
406
- table.add_row(*c)
403
+ if len(_options) < 12:
404
+ # If fewer than 12 items, use a single column
405
+ for option in _options:
406
+ table.add_row(Text.from_ansi(highlight_pipes(option)))
407
+ else:
408
+ # Otherwise, use multiple columns as before
409
+ num_rows = (len(_options) + num_cols - 1) // num_cols
410
+ for i in range(num_rows):
411
+ row = []
412
+ for j in range(num_cols):
413
+ index = i + j * num_rows
414
+ if index < len(_options):
415
+ row.append(Text.from_ansi(highlight_pipes(_options[index])))
416
+ else:
417
+ row.append('')
418
+ table.add_row(*row)
407
419
 
408
420
  get_console().print(table)
409
421
  return None
@@ -42,6 +42,7 @@ packages: Dict[str, Dict[str, str]] = {
42
42
  'requests' : 'requests>=2.23.0',
43
43
  'binaryornot' : 'binaryornot>=0.4.4',
44
44
  'pyvim' : 'pyvim>=3.0.2',
45
+ 'ptpython' : 'ptpython>=3.0.27',
45
46
  'aiofiles' : 'aiofiles>=0.6.0',
46
47
  'packaging' : 'packaging>=21.3.0',
47
48
  'prompt_toolkit' : 'prompt-toolkit>=3.0.39',
@@ -74,7 +75,6 @@ packages: Dict[str, Dict[str, str]] = {
74
75
  'litecli' : 'litecli>=1.5.0',
75
76
  'mssqlcli' : 'mssql-cli>=1.0.0',
76
77
  'gadwall' : 'gadwall>=0.2.0',
77
- 'ptpython' : 'ptpython>=3.0.27',
78
78
  },
79
79
  'stack': {
80
80
  'compose' : 'docker-compose>=1.29.2',