visidata 3.1.1__py3-none-any.whl → 3.3__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 (99) hide show
  1. visidata/__init__.py +2 -2
  2. visidata/_input.py +106 -58
  3. visidata/_open.py +10 -7
  4. visidata/_types.py +2 -2
  5. visidata/aggregators.py +125 -16
  6. visidata/apps/vdsql/_ibis.py +8 -13
  7. visidata/basesheet.py +4 -3
  8. visidata/canvas.py +11 -7
  9. visidata/clipboard.py +11 -2
  10. visidata/cliptext.py +68 -23
  11. visidata/cmdlog.py +5 -1
  12. visidata/column.py +48 -33
  13. visidata/ddwplay.py +2 -2
  14. visidata/deprecated.py +96 -63
  15. visidata/errors.py +41 -5
  16. visidata/{features → experimental}/helloworld.py +1 -1
  17. visidata/experimental/liveupdate.py +1 -1
  18. visidata/expr.py +1 -0
  19. visidata/extensible.py +4 -0
  20. visidata/features/cmdpalette.py +64 -25
  21. visidata/features/describe.py +2 -2
  22. visidata/features/expand_cols.py +7 -5
  23. visidata/features/freeze.py +14 -2
  24. visidata/features/go_col.py +3 -3
  25. visidata/features/graph_zoom_y.py +47 -0
  26. visidata/features/incr.py +7 -3
  27. visidata/features/join.py +23 -12
  28. visidata/features/layout.py +8 -4
  29. visidata/features/melt.py +1 -0
  30. visidata/features/rank.py +103 -0
  31. visidata/features/reload_every.py +11 -8
  32. visidata/features/sysedit.py +14 -4
  33. visidata/features/transpose.py +1 -0
  34. visidata/features/window.py +12 -0
  35. visidata/form.py +10 -9
  36. visidata/freqtbl.py +47 -3
  37. visidata/fuzzymatch.py +11 -7
  38. visidata/graph.py +5 -3
  39. visidata/guides/AggregatorsSheet.md +84 -0
  40. visidata/guides/CommandsSheet.md +1 -0
  41. visidata/guides/MacrosSheet.md +1 -1
  42. visidata/guides/RankGuide.md +51 -0
  43. visidata/guides/TypesSheet.md +1 -1
  44. visidata/guides/WindowFunctionGuide.md +49 -0
  45. visidata/help.py +23 -6
  46. visidata/indexsheet.py +1 -1
  47. visidata/loaders/_pandas.py +3 -1
  48. visidata/loaders/archive.py +33 -6
  49. visidata/loaders/csv.py +12 -1
  50. visidata/loaders/eml.py +2 -0
  51. visidata/loaders/f5log.py +2 -2
  52. visidata/loaders/fec.py +6 -9
  53. visidata/loaders/fixed_width.py +2 -0
  54. visidata/loaders/hdf5.py +34 -10
  55. visidata/loaders/npy.py +54 -23
  56. visidata/loaders/orgmode.py +3 -2
  57. visidata/loaders/pandas_freqtbl.py +4 -0
  58. visidata/loaders/psv.py +13 -0
  59. visidata/loaders/sqlite.py +1 -1
  60. visidata/loaders/vds.py +3 -4
  61. visidata/macros.py +5 -4
  62. visidata/main.py +21 -11
  63. visidata/mainloop.py +8 -5
  64. visidata/man/parse_options.py +3 -2
  65. visidata/man/vd.1 +38 -17
  66. visidata/man/vd.txt +47 -17
  67. visidata/menu.py +10 -10
  68. visidata/metasheets.py +3 -3
  69. visidata/mouse.py +3 -0
  70. visidata/movement.py +6 -3
  71. visidata/pyobj.py +17 -9
  72. visidata/save.py +10 -2
  73. visidata/selection.py +29 -18
  74. visidata/settings.py +9 -5
  75. visidata/sheets.py +124 -48
  76. visidata/shell.py +2 -2
  77. visidata/sidebar.py +11 -8
  78. visidata/sort.py +89 -11
  79. visidata/statusbar.py +10 -9
  80. visidata/tests/test_cliptext.py +164 -0
  81. visidata/tests/test_commands.py +6 -2
  82. visidata/tests/test_menu.py +1 -1
  83. visidata/textsheet.py +34 -8
  84. visidata/themes/ascii8.py +2 -2
  85. visidata/themes/light.py +5 -0
  86. visidata/threads.py +38 -8
  87. visidata/utils.py +15 -1
  88. visidata/vendor/__init__.py +0 -0
  89. {visidata-3.1.1.data → visidata-3.3.data}/data/share/man/man1/vd.1 +38 -17
  90. {visidata-3.1.1.data → visidata-3.3.data}/data/share/man/man1/visidata.1 +38 -17
  91. {visidata-3.1.1.dist-info → visidata-3.3.dist-info}/METADATA +62 -15
  92. {visidata-3.1.1.dist-info → visidata-3.3.dist-info}/RECORD +98 -92
  93. {visidata-3.1.1.dist-info → visidata-3.3.dist-info}/WHEEL +1 -1
  94. {visidata-3.1.1.dist-info → visidata-3.3.dist-info}/entry_points.txt +1 -0
  95. visidata-3.1.1.data/scripts/vd +0 -6
  96. {visidata-3.1.1.data → visidata-3.3.data}/data/share/applications/visidata.desktop +0 -0
  97. {visidata-3.1.1.data → visidata-3.3.data}/scripts/vd2to3.vdx +0 -0
  98. {visidata-3.1.1.dist-info → visidata-3.3.dist-info}/LICENSE.gpl3 +0 -0
  99. {visidata-3.1.1.dist-info → visidata-3.3.dist-info}/top_level.txt +0 -0
visidata/__init__.py CHANGED
@@ -1,10 +1,10 @@
1
1
  'VisiData: a curses interface for exploring and arranging tabular data'
2
2
 
3
- __version__ = '3.1.1'
3
+ __version__ = '3.3'
4
4
  __version_info__ = 'VisiData v' + __version__
5
5
  __author__ = 'Saul Pwanson <vd@saul.pw>'
6
6
  __status__ = 'Production/Stable'
7
- __copyright__ = 'Copyright (c) 2016-2021 ' + __author__
7
+ __copyright__ = 'Copyright (c) 2016-2024 ' + __author__
8
8
 
9
9
 
10
10
  class EscapeException(BaseException):
visidata/_input.py CHANGED
@@ -4,7 +4,7 @@ import curses
4
4
  import visidata
5
5
 
6
6
  from visidata import EscapeException, ExpectedException, clipdraw, Sheet, VisiData, BaseSheet
7
- from visidata import vd, colors, dispwidth, ColorAttr
7
+ from visidata import vd, colors, dispwidth, ColorAttr, clipstr_start
8
8
  from visidata import AttrDict
9
9
 
10
10
 
@@ -112,7 +112,8 @@ def delchar(s, i, remove=1):
112
112
  'Delete `remove` characters from str `s` beginning at position `i`.'
113
113
  return s if i < 0 else s[:i] + s[i+remove:]
114
114
 
115
- def find_nonword(s, a, b, incr):
115
+ def find_word(s, a, b, incr):
116
+ '''Return first index of word boundary in s[a:b], going forward if incr is +1 and backward if incr is -1.'''
116
117
  if not s: return 0
117
118
  a = min(max(a, 0), len(s)-1)
118
119
  b = min(max(b, 0), len(s)-1)
@@ -124,9 +125,9 @@ def find_nonword(s, a, b, incr):
124
125
  b += incr
125
126
  return min(max(b, -1), len(s))
126
127
  else:
127
- while not s[a].isalnum() and a < b: # first skip non-word chars
128
+ while s[a].isalnum() and a < b: # first skip word chars
128
129
  a += incr
129
- while s[a].isalnum() and a < b:
130
+ while not s[a].isalnum() and a < b: # then skip non-word chars
130
131
  a += incr
131
132
  return min(max(a, 0), len(s))
132
133
 
@@ -173,15 +174,14 @@ class InputWidget:
173
174
  self.former_i = None
174
175
  self.just_completed = False
175
176
 
176
- def editline(self, scr, y, x, w, attr=ColorAttr(), updater=lambda val: None, bindings={}, clear=True) -> str:
177
+ def editline(self, scr, y, x, w, attr=ColorAttr(), updater=lambda val:None, bindings={}, clear=True) -> str:
177
178
  'If *clear* is True, clear whole editing area before displaying.'
178
179
  with EnableCursor():
179
180
  while True:
180
- vd.drawSheet(scr, vd.activeSheet)
181
- if updater:
181
+ if len(vd.pendingKeys) <= 3: #speed up paste of long strings by skipping redraws
182
+ vd.drawSheet(scr, vd.activeSheet)
182
183
  updater(self.value)
183
-
184
- vd.drawInputHelp(scr)
184
+ vd.drawInputHelp(scr)
185
185
 
186
186
  self.draw(scr, y, x, w, attr, clear=clear)
187
187
  ch = vd.getkeystroke(scr)
@@ -194,32 +194,59 @@ class InputWidget:
194
194
 
195
195
  def draw(self, scr, y, x, w, attr=ColorAttr(), clear=True):
196
196
  i = self.current_i # the onscreen offset within the field where v[i] is displayed
197
- left_truncchar = right_truncchar = self.truncchar
197
+ trunch = self.truncchar
198
+ tr_w = dispwidth(trunch)
199
+ fill_w = dispwidth(self.fillchar)
200
+
201
+ def _calc_display(dispval, i):
202
+ '''Return a formatted substring of *dispval* that fills the on-screen width *w*.'''
203
+ if i == len(dispval): # add a fillchar so the user perceives room to type
204
+ dispval += self.fillchar
205
+ dw = dispwidth(dispval)
206
+ if dw <= w: # entire value fits
207
+ dispval += self.fillchar*(w-dw)
208
+ return dispval, i
209
+ if w <= tr_w: # column is too narrow to hold a left and right truncation
210
+ return trunch, 0
211
+
212
+ dw = dispwidth(dispval[i:])
213
+ if dw + tr_w <= w and dw <= w//2: #cursor is within half-colwidth of end
214
+ #truncate the left and show the end
215
+ frag, n = clipstr_start(dispval, w-tr_w)
216
+ offset = len(dispval) - i
217
+ dispval = ' '*(w-tr_w - n) + trunch + frag
218
+ i = len(dispval) - offset
219
+ return dispval, i
220
+
221
+ # the remaining cases need the right side truncated, after the new dispval is returned
222
+ dw = dispwidth(dispval[:i+1])
223
+ if dw + tr_w <= w and dispwidth(dispval[:i]) <= w//2: #cursor is within half-colwidth of start
224
+ #truncate the right, and show the string start
225
+ pass
226
+ else: # truncate left and right sides
227
+ # Place the cursor at the midpoint of the available colwidth
228
+ left_w = (w - 2*tr_w)//2
229
+ # calculate the fragment to the left of the cursor
230
+ l_frag, n = clipstr_start(dispval[:i], left_w)
231
+ dispval = ' '*(left_w-n) + trunch + l_frag + dispval[i:]
232
+ i = left_w-n + len(trunch) + len(l_frag)
233
+ return dispval, i
198
234
 
199
235
  if self.display:
200
236
  dispval = clean_printable(self.value)
201
237
  else:
202
238
  dispval = '*' * len(self.value)
239
+ dispval, i = _calc_display(dispval, i)
203
240
 
204
- if len(dispval) < w: # entire value fits
205
- dispval += self.fillchar*(w-len(dispval)-1)
206
- elif i == len(dispval): # cursor after value (will append)
207
- i = w-1
208
- dispval = left_truncchar + dispval[len(dispval)-w+2:] + self.fillchar
209
- elif i >= len(dispval)-w//2: # cursor within halfwidth of end
210
- i = w-(len(dispval)-i)
211
- dispval = left_truncchar + dispval[len(dispval)-w+1:]
212
- elif i <= w//2: # cursor within halfwidth of beginning
213
- dispval = dispval[:w-1] + right_truncchar
214
- else:
215
- i = w//2 # visual cursor stays right in the middle
216
- k = 1 if w%2==0 else 0 # odd widths have one character more
217
- dispval = left_truncchar + dispval[self.current_i-w//2+1:self.current_i+w//2-k] + right_truncchar
218
-
219
- prew = clipdraw(scr, y, x, dispval[:i], attr, w, clear=clear, literal=True)
220
- clipdraw(scr, y, x+prew, dispval[i:], attr, w-prew+1, clear=clear, literal=True)
241
+ #clipdraw will truncate the right side of dispval with trunch as needed
242
+ clipdraw(scr, y, x, dispval, attr, w, clear=clear, literal=True)
243
+ if x+w < scr.getmaxyx()[1]:
244
+ #draw a space to indicate that the user can scroll right of the cell's final char
245
+ clipdraw(scr, y, x+w, ' ', attr, 1, clear=False, literal=True)
221
246
  if scr:
222
- scr.move(y, x+prew)
247
+ prew = dispwidth(dispval[:i])
248
+ if x+prew < scr.getmaxyx()[1]: #move cursor back to where the user is editing
249
+ scr.move(y, x+prew)
223
250
 
224
251
  def handle_key(self, ch:str, scr) -> bool:
225
252
  'Return True to accept current input. Raise EscapeException on Ctrl+C, Ctrl+Q, or ESC.'
@@ -249,17 +276,25 @@ class InputWidget:
249
276
  c = vd.prettykeys(c)
250
277
  i += len(c)
251
278
  v += c
252
- elif ch == '^O': self.value = vd.launchExternalEditor(v); return True # auto-accept after $EDITOR
279
+ elif ch == '^O':
280
+ edit_v = vd.launchExternalEditor(v)
281
+ if self.value == edit_v:
282
+ # leave cell unmodified when the editor exits with no change
283
+ raise EscapeException(ch)
284
+ else:
285
+ self.value = edit_v
286
+ return True
253
287
  elif ch == '^R': v = self.orig_value # ^Reload initial value
254
288
  elif ch == '^T': v = delchar(splice(v, i-2, v[i-1:i]), i) # swap chars
255
289
  elif ch == '^U': v = v[i:]; i = 0 # clear to beginning
256
290
  elif ch == '^V': v = splice(v, i, until_get_wch(scr)); i += 1 # literal character
257
- elif ch == '^W': j = find_nonword(v, 0, i-1, -1); v = v[:j+1] + v[i:]; i = j+1 # erase word
291
+ elif ch == '^W': j = find_word(v, 0, i-1, -1); v = v[:j+1] + v[i:]; i = j+1 # erase word
292
+ elif ch in ('KEY_DC5','kDC5','kDC3'): j = find_word(v, i, len(v), +1); v = v[:i] + v[j+1:] # erase word forward
258
293
  elif ch == '^Y': v = splice(v, i, str(vd.memory.clipval))
259
294
  elif ch == '^Z': vd.suspend()
260
295
  # CTRL+arrow
261
- elif ch == 'kLFT5': i = find_nonword(v, 0, i-1, -1)+1; # word left
262
- elif ch == 'kRIT5': i = find_nonword(v, i+1, len(v)-1, +1)+1; # word right
296
+ elif ch == 'kLFT5': i = find_word(v, 0, i-1, -1)+1; # word left
297
+ elif ch == 'kRIT5': i = find_word(v, i, len(v)-1, +1); # word right
263
298
  elif ch == 'kUP5': pass
264
299
  elif ch == 'kDN5': pass
265
300
  elif self.history and ch == 'KEY_UP': v, i = self.prev_history(v, i)
@@ -337,9 +372,9 @@ class InputWidget:
337
372
  @VisiData.api
338
373
  def editText(vd, y, x, w, attr=ColorAttr(), value='',
339
374
  help='',
340
- updater=None, bindings={},
375
+ updater=lambda val: None, bindings={},
341
376
  display=True, record=True, clear=True, **kwargs):
342
- 'Invoke modal single-line editor at (*y*, *x*) for *w* terminal chars. Use *display* is False for sensitive input like passphrases. If *record* is True, get input from the cmdlog in batch mode, and save input to the cmdlog if *display* is also True. Return new value as string.'
377
+ '''Invoke modal single-line editor at (*y*, *x*) for *w* terminal chars. Use *display* is False for sensitive input like passphrases. If *record* is True, get input from the cmdlog in batch mode, and save input to the cmdlog if *display* is also True. Return new value as string. Callers should handle curses.error, which will be raised if the terminal is resized during the edit, in a way that moves the editor coordinates offscreen.'''
343
378
  v = None
344
379
  if record and vd.cmdlog:
345
380
  v = vd.getCommandInput()
@@ -357,8 +392,8 @@ def editText(vd, y, x, w, attr=ColorAttr(), value='',
357
392
  try:
358
393
  widget = InputWidget(value=str(value), display=display, **kwargs)
359
394
 
360
- with vd.AddedHelp(vd.getHelpPane('input', module='visidata'), 'Input Keystrokes Help'), \
361
- vd.AddedHelp(help, 'Input Field Help'):
395
+ with vd.AddedHelp(vd.getHelpPane('input', module='visidata'), 'Input Keystrokes Help', 'inputkeys'), \
396
+ vd.AddedHelp(help, 'Input Field Help', 'inputfield'):
362
397
  v = widget.editline(vd.activeSheet._scr, y, x, w, attr=attr, updater=updater, bindings=bindings, clear=clear)
363
398
  except AcceptInput as e:
364
399
  v = e.args[0]
@@ -434,7 +469,6 @@ def inputMultiple(vd, updater=lambda val: None, record=True, **kwargs):
434
469
 
435
470
  assert False, type(previnput)
436
471
 
437
- y = sheet.windowHeight-1
438
472
  maxw = sheet.windowWidth//2
439
473
  attr = colors.color_edit_unfocused
440
474
 
@@ -460,9 +494,11 @@ def inputMultiple(vd, updater=lambda val: None, record=True, **kwargs):
460
494
 
461
495
  def _drawPrompt(val):
462
496
  for k, v in kwargs.items():
497
+ #recalculate y to adjust for screen resizes during input()
498
+ y = sheet.windowHeight-v.get('dy')-1
463
499
  maxw = min(sheet.windowWidth-1, max(dispwidth(v.get('prompt')), dispwidth(str(v.get('value', '')))))
464
- promptlen = clipdraw(scr, y-v.get('dy'), 0, v.get('prompt'), attr, w=maxw) #1947
465
- promptlen = clipdraw(scr, y-v.get('dy'), promptlen, v.get('value', ''), attr, w=maxw)
500
+ promptlen = clipdraw(scr, y, 0, v.get('prompt'), attr, w=maxw) #1947
501
+ promptlen = clipdraw(scr, y, promptlen, v.get('value', ''), attr, w=maxw)
466
502
 
467
503
  return updater(val)
468
504
 
@@ -549,27 +585,36 @@ def input(vd, prompt, type=None, defaultLast=False, history=[], dy=0, attr=None,
549
585
  return sheet.windowWidth-promptlen-rstatuslen-2
550
586
 
551
587
  w = kwargs.pop('w', _drawPrompt())
552
- ret = vd.editText(y, promptlen, w=w,
553
- attr=colors.color_edit_cell,
554
- options=vd.options,
555
- history=history,
556
- updater=_drawPrompt,
557
- **kwargs)
558
-
559
- if ret:
560
- if kwargs.get('record', True) and kwargs.get('display', True):
561
- vd.addInputHistory(ret, type=type)
562
- elif defaultLast:
563
- history or vd.fail("no previous input")
564
- ret = history[-1]
588
+ restarts = 0
589
+ while restarts < 100:
590
+ #recalculate y to handle resize events
591
+ y = sheet.windowHeight-dy-1
592
+ try:
593
+ ret = vd.editText(y, promptlen, w=w,
594
+ attr=colors.color_edit_cell,
595
+ options=vd.options,
596
+ history=history,
597
+ updater=_drawPrompt,
598
+ **kwargs)
599
+ if ret:
600
+ if kwargs.get('record', True) and kwargs.get('display', True):
601
+ vd.addInputHistory(ret, type=type)
602
+ elif defaultLast:
603
+ history or vd.fail("no previous input")
604
+ ret = history[-1]
565
605
 
566
- return ret
606
+ return ret
607
+ except curses.error:
608
+ vd.warning('restarting input due to resize')
609
+ restarts += 1
610
+ # if it keeps happening, it's probably not resize events, so give some debug output
611
+ vd.error(f'aborting input: y={y}, w={w}, windowHeight={sheet.windowHeight}, windowWidth={sheet.windowWidth}')
567
612
 
568
613
 
569
614
  @VisiData.api
570
615
  def confirm(vd, prompt, exc=EscapeException):
571
616
  'Display *prompt* on status line and demand input that starts with "Y" or "y" to proceed. Raise *exc* otherwise. Return True.'
572
- if vd.options.batch and not vd.options.interactive:
617
+ if vd.options.batch:
573
618
  return vd.fail('cannot confirm in batch mode: ' + prompt)
574
619
 
575
620
  yn = vd.input(prompt, value='no', record=False)[:1]
@@ -594,7 +639,7 @@ class CompleteKey:
594
639
  @Sheet.api
595
640
  def editCell(self, vcolidx=None, rowidx=None, value=None, **kwargs):
596
641
  '''Call vd.editText for the cell at (*rowidx*, *vcolidx*). Return the new value, properly typed.
597
-
642
+ - *vcolidx*: numeric index into ``self.availCols``. When None, use current column.
598
643
  - *rowidx*: numeric index into ``self.rows``. If negative, indicates the column name in the header.
599
644
  - *value*: if given, the starting input; otherwise the starting input is the cell value or column name as appropriate.
600
645
  - *kwargs*: passthrough args to ``vd.editText``.
@@ -604,7 +649,7 @@ def editCell(self, vcolidx=None, rowidx=None, value=None, **kwargs):
604
649
  vcolidx = self.cursorVisibleColIndex
605
650
  x, w = self._visibleColLayout.get(vcolidx, (0, 0))
606
651
 
607
- col = self.visibleCols[vcolidx]
652
+ col = self.availCols[vcolidx]
608
653
  if rowidx is None:
609
654
  rowidx = self.cursorRowIndex
610
655
 
@@ -626,7 +671,7 @@ def editCell(self, vcolidx=None, rowidx=None, value=None, **kwargs):
626
671
  'KEY_BTAB': acceptThenFunc('go-left', 'rename-col' if rowidx < 0 else 'edit-cell'),
627
672
  }
628
673
 
629
- if vcolidx >= self.nVisibleCols-1:
674
+ if vcolidx == self.nVisibleCols-1 or vcolidx >= self.nCols-1:
630
675
  bindings['^I'] = acceptThenFunc('go-down', 'go-leftmost', 'edit-cell')
631
676
 
632
677
  if vcolidx <= 0:
@@ -639,7 +684,10 @@ def editCell(self, vcolidx=None, rowidx=None, value=None, **kwargs):
639
684
  editargs = dict(value=value, options=self.options)
640
685
 
641
686
  editargs.update(kwargs) # update with user-specified args
642
- r = vd.editText(y, x, w, attr=colors.color_edit_cell, **editargs)
687
+ try:
688
+ r = vd.editText(y, x, w, attr=colors.color_edit_cell, **editargs)
689
+ except curses.error:
690
+ vd.fail(f'aborting edit due to resize')
643
691
 
644
692
  if rowidx >= 0: # if not header
645
693
  r = col.type(r) # convert input to column type, let exceptions be raised
visidata/_open.py CHANGED
@@ -81,6 +81,10 @@ def guess_extension(vd, path):
81
81
  def openPath(vd, p, filetype=None, create=False):
82
82
  '''Call ``open_<filetype>(p)`` or ``openurl_<p.scheme>(p, filetype)``. Return constructed but unloaded sheet of appropriate type.
83
83
  If True, *create* will return a new, blank **Sheet** if file does not exist.'''
84
+ # allow user to assign a filetype to a pathname: options.set('filetype', 'csv', '-')
85
+ filetype = filetype or vd.options.getonly('filetype', str(p), None) #1710
86
+ filetype = filetype or vd.options.getonly('filetype', 'global', None)
87
+
84
88
  if p.scheme and not p.has_fp():
85
89
  schemes = p.scheme.split('+')
86
90
  openfuncname = 'openurl_' + schemes[-1]
@@ -94,8 +98,10 @@ def openPath(vd, p, filetype=None, create=False):
94
98
  if not p.exists() and not create:
95
99
  return None
96
100
 
97
- if not filetype:
98
- filetype = p.ext or vd.options.filetype
101
+ # assign filetype from extension, but only for files, not directories
102
+ if not p.is_dir(): #2547
103
+ filetype = filetype or p.ext
104
+ filetype = filetype or vd.options.filetype
99
105
 
100
106
  filetype = filetype.lower()
101
107
 
@@ -147,15 +153,12 @@ def openSource(vd, p, filetype=None, create=False, **kwargs):
147
153
  if isinstance(p, BaseSheet):
148
154
  return p
149
155
 
150
- filetype = filetype or vd.options.getonly('filetype', str(p), '') #1710
151
- filetype = filetype or vd.options.getonly('filetype', 'global', '')
152
-
153
156
  vs = None
154
157
  if isinstance(p, str):
155
158
  if '://' in p:
156
159
  vs = vd.openPath(Path(p), filetype=filetype) # convert to Path and recurse
157
160
  elif p == '-':
158
- if sys.stdin.isatty():
161
+ if vd.stdinSource.fptext.isatty():
159
162
  vd.fail('cannot open stdin when it is a tty')
160
163
  vs = vd.openPath(vd.stdinSource, filetype=filetype)
161
164
  else:
@@ -180,7 +183,7 @@ def open_txt(vd, p):
180
183
  if delimiter and delimiter in next(fp): # peek at the first line
181
184
  return vd.open_tsv(p) # TSV often have .txt extension
182
185
  except StopIteration:
183
- return TableSheet(p.base_stem, columns=[SettableColumn(width=vd.options.default_width)], source=p)
186
+ return vd.newSheet(p.base_stem, 1, source=p)
184
187
  return TextSheet(p.base_stem, source=p)
185
188
 
186
189
 
visidata/_types.py CHANGED
@@ -1,7 +1,7 @@
1
1
  # VisiData uses Python native int, float, str, and adds simple anytype.
2
2
 
3
3
  import locale
4
- from visidata import options, TypedWrapper, vd, VisiData
4
+ from visidata import vd, VisiData
5
5
 
6
6
  vd.help_float_fmt = '''
7
7
  - fmt starting with `'%'` (like `%0.2f`) will use [:onclick https://docs.python.org/3.6/library/locale.html#locale.format_string]locale.format_string[/]
@@ -40,7 +40,7 @@ anytype.__name__ = ''
40
40
  @VisiData.global_api
41
41
  def numericFormatter(vd, fmtstr, typedval):
42
42
  try:
43
- fmtstr = fmtstr or options['disp_'+type(typedval).__name__+'_fmt']
43
+ fmtstr = fmtstr or vd.options['disp_'+type(typedval).__name__+'_fmt']
44
44
  if fmtstr[0] == '%':
45
45
  return locale.format_string(fmtstr, typedval, grouping=False)
46
46
  else:
visidata/aggregators.py CHANGED
@@ -3,9 +3,11 @@ import math
3
3
  import functools
4
4
  import collections
5
5
  import statistics
6
+ from copy import copy
7
+ import itertools
6
8
 
7
- from visidata import Progress, Sheet, Column, ColumnsSheet, VisiData
8
- from visidata import vd, anytype, vlen, asyncthread, wrapply, AttrDict, date, INPROGRESS
9
+ from visidata import Progress, Sheet, Column, ColumnsSheet, VisiData, SettableColumn
10
+ from visidata import vd, anytype, vlen, asyncthread, wrapply, AttrDict, date, INPROGRESS, dispwidth, stacktrace, TypedExceptionWrapper
9
11
 
10
12
  vd.help_aggregators = '''# Choose Aggregators
11
13
  Start typing an aggregator name or description.
@@ -76,7 +78,7 @@ Column.aggregators = property(aggregators_get, aggregators_set)
76
78
 
77
79
 
78
80
  class Aggregator:
79
- def __init__(self, name, type, funcValues=None, helpstr='foo'):
81
+ def __init__(self, name, type, funcValues=None, helpstr=''):
80
82
  'Define aggregator `name` that calls funcValues(values)'
81
83
  self.type = type
82
84
  self.funcValues = funcValues # funcValues(values)
@@ -92,6 +94,33 @@ class Aggregator:
92
94
  return None
93
95
  raise e
94
96
 
97
+ class ListAggregator(Aggregator):
98
+ '''A list aggregator is an aggregator that returns a list of values, generally
99
+ one value per input row, unlike ordinary aggregators that operate on rows
100
+ and return only a single value.
101
+ To implement a new list aggregator, subclass ListAggregator,
102
+ and override aggregate() and aggregate_list().'''
103
+ def __init__(self, name, type, helpstr='', listtype=None):
104
+ '''*listtype* determines the type of the column created by addcol_aggregate()
105
+ for list aggrs. If it is None, then the new column will match the type of the input column'''
106
+ super().__init__(name, type, helpstr=helpstr)
107
+ self.listtype = listtype
108
+
109
+ def aggregate(self, col, rows) -> list:
110
+ '''Return a list, which can be shorter than *rows*, because it filters out nulls and errors.
111
+ Override in subclass.'''
112
+ vals = self.aggregate_list(col, rows)
113
+ # filter out nulls and errors
114
+ vals = [ v for v in vals if not col.sheet.isNullFunc()(v) ]
115
+ return vals
116
+
117
+ def aggregate_list(self, col, row_group) -> list:
118
+ '''Return a list of results, which will be one result per input row.
119
+ *row_group* is an iterable that holds a "group" of rows to run the aggregator on.
120
+ rows in *row_group* are not necessarily in the same order they are in the sheet.
121
+ Override in subclass.'''
122
+ vals = [ col.getTypedValue(r) for r in row_group ]
123
+ return vals
95
124
 
96
125
  @VisiData.api
97
126
  def aggregator(vd, name, funcValues, helpstr='', *, type=None):
@@ -99,6 +128,14 @@ def aggregator(vd, name, funcValues, helpstr='', *, type=None):
99
128
  Use *type* to force type of aggregated column (default to use type of source column).'''
100
129
  vd.aggregators[name] = Aggregator(name, type, funcValues=funcValues, helpstr=helpstr)
101
130
 
131
+ @VisiData.api
132
+ def aggregator_list(vd, name, helpstr='', type=anytype, listtype=anytype):
133
+ '''Define simple aggregator *name* that calls ``funcValues(values)`` to aggregate *values*.
134
+ Use *type* to force type of aggregated column (default to use type of source column).
135
+ Use *listtype* to force the type of the new column created by addcol-aggregate.
136
+ If *listtype* is None, it will match the type of the source column.'''
137
+ vd.aggregators[name] = ListAggregator(name, type, helpstr=helpstr, listtype=listtype)
138
+
102
139
  ## specific aggregator implementations
103
140
 
104
141
  def mean(vals):
@@ -109,6 +146,16 @@ def mean(vals):
109
146
  def vsum(vals):
110
147
  return sum(vals, start=type(vals[0] if len(vals) else 0)()) #1996
111
148
 
149
+ def stdev(vals):
150
+ # because statistics.stdev can raise an exception, we put it in a wrapper.
151
+ # The wrapper lets the exception be seen as an error string in the stdev
152
+ # aggregator, shown at the bottom of the sheet as part of allAggregators.
153
+ try:
154
+ return statistics.stdev(vals)
155
+ except statistics.StatisticsError as e: #when vals holds only 1 element
156
+ e.stacktrace = stacktrace()
157
+ return TypedExceptionWrapper(None, exception=e)
158
+
112
159
  # http://code.activestate.com/recipes/511478-finding-the-percentile-of-the-values/
113
160
  def _percentile(N, percent, key=lambda x:x):
114
161
  """
@@ -140,10 +187,49 @@ class PercentileAggregator(Aggregator):
140
187
  def aggregate(self, col, rows):
141
188
  return _percentile(sorted(col.getValues(rows)), self.pct/100, key=float)
142
189
 
143
-
144
190
  def quantiles(q, helpstr):
145
191
  return [PercentileAggregator(round(100*i/q), helpstr) for i in range(1, q)]
146
192
 
193
+ def aggregate_groups(sheet, col, rows, aggr) -> list:
194
+ '''Returns a list, containing the result of the aggregator applied to each row.
195
+ *col* is a column whose values determine each row's rank within a group.
196
+ *rows* is a list of visidata rows.
197
+ *aggr* is an Aggregator object.
198
+ Rows are grouped by their key columns. Null key column cells are considered equal,
199
+ so nulls are grouped together. Cells with exceptions do not group together.
200
+ Each exception cell is grouped by itself, with only one row in the group.
201
+ '''
202
+ def _key_progress(prog):
203
+ def identity(val):
204
+ prog.addProgress(1)
205
+ return val
206
+ return identity
207
+
208
+ with Progress(gerund='ranking', total=4*sheet.nRows) as prog:
209
+ p = _key_progress(prog) # increment progress every time p() is called
210
+ # compile row data, for each row a list of tuples: (group_key, rank_key, rownum)
211
+ rowdata = [(sheet.rowkey(r), col.getTypedValue(r), p(rownum)) for rownum, r in enumerate(rows)]
212
+ # sort by row key and column value to prepare for grouping
213
+ try:
214
+ rowdata.sort(key=p)
215
+ except TypeError as e:
216
+ vd.fail(f'elements in a ranking column must be comparable: {e.args[0]}')
217
+ rowvals = []
218
+ #group by row key
219
+ for _, group in itertools.groupby(rowdata, key=lambda v: v[0]):
220
+ # within a group, the rows have already been sorted by col_val
221
+ group = list(group)
222
+ if isinstance(aggr, ListAggregator): # for list aggregators, each row gets its own value
223
+ aggr_vals = aggr.aggregate_list(col, [rows[rownum] for _, _, rownum in group])
224
+ rowvals += [(rownum, v) for (_, _, rownum), v in zip(group, aggr_vals)]
225
+ else: # for normal aggregators, each row in the group gets the same value
226
+ aggr_val = aggr.aggregate(col, [rows[rownum] for _, _, rownum in group])
227
+ rowvals += [(rownum, aggr_val) for _, _, rownum in group]
228
+ prog.addProgress(len(group))
229
+ # sort by unique rownum, to make rank results match the original row order
230
+ rowvals.sort(key=p)
231
+ rowvals = [ v for rownum, v in rowvals ]
232
+ return rowvals
147
233
 
148
234
  vd.aggregator('min', min, 'minimum value')
149
235
  vd.aggregator('max', max, 'maximum value')
@@ -154,8 +240,8 @@ vd.aggregator('mode', statistics.mode, 'mode of values')
154
240
  vd.aggregator('sum', vsum, 'sum of values')
155
241
  vd.aggregator('distinct', set, 'distinct values', type=vlen)
156
242
  vd.aggregator('count', lambda values: sum(1 for v in values), 'number of values', type=int)
157
- vd.aggregator('list', list, 'list of values', type=anytype)
158
- vd.aggregator('stdev', statistics.stdev, 'standard deviation of values', type=float)
243
+ vd.aggregator_list('list', 'list of values', type=anytype, listtype=None)
244
+ vd.aggregator('stdev', stdev, 'standard deviation of values', type=float)
159
245
 
160
246
  vd.aggregators['q3'] = quantiles(3, 'tertiles (33/66th pctile)')
161
247
  vd.aggregators['q4'] = quantiles(4, 'quartiles (25/50/75th pctile)')
@@ -205,10 +291,9 @@ def addAggregators(sheet, cols, aggrnames):
205
291
  for aggrname in aggrnames:
206
292
  aggrs = vd.aggregators.get(aggrname)
207
293
  aggrs = aggrs if isinstance(aggrs, list) else [aggrs]
208
- for aggr in aggrs:
209
- for c in cols:
210
- if not hasattr(c, 'aggregators'):
211
- c.aggregators = []
294
+ for c in cols:
295
+ vd.addUndo(setattr, c, 'aggregators', copy(c.aggregators))
296
+ for aggr in aggrs:
212
297
  if aggr and aggr not in c.aggregators:
213
298
  c.aggregators += [aggr]
214
299
 
@@ -243,7 +328,8 @@ def memo_aggregate(col, agg_choices, rows):
243
328
  for agg in aggs:
244
329
  aggval = agg.aggregate(col, rows)
245
330
  typedval = wrapply(agg.type or col.type, aggval)
246
- dispval = col.format(typedval)
331
+ # limit width to limit formatting time when typedval is a long list
332
+ dispval = col.format(typedval, width=1000)
247
333
  k = col.name+'_'+agg.name
248
334
  vd.status(f'{k}={dispval}')
249
335
  vd.memory[k] = typedval
@@ -254,17 +340,16 @@ def aggregator_choices(vd):
254
340
  return [
255
341
  AttrDict(key=agg, desc=v[0].helpstr if isinstance(v, list) else v.helpstr)
256
342
  for agg, v in vd.aggregators.items()
257
- if not agg.startswith('p') # skip all the percentiles, user should use q# instead
343
+ if not (agg.startswith('p') and agg[1:].isdigit()) # skip all the percentiles like 'p10', user should use q# instead
258
344
  ]
259
345
 
260
346
 
261
347
  @VisiData.api
262
- def chooseAggregators(vd):
348
+ def chooseAggregators(vd, prompt = 'choose aggregators: '):
263
349
  '''Return a list of aggregator name strings chosen or entered by the user. User-entered names may be invalid.'''
264
- prompt = 'choose aggregators: '
265
350
  def _fmt_aggr_summary(match, row, trigger_key):
266
351
  formatted_aggrname = match.formatted.get('key', row.key) if match else row.key
267
- r = ' '*(len(prompt)-3)
352
+ r = ' '*(dispwidth(prompt)-3)
268
353
  r += f'[:keystrokes]{trigger_key}[/] '
269
354
  r += formatted_aggrname
270
355
  if row.desc:
@@ -288,10 +373,34 @@ def chooseAggregators(vd):
288
373
  vd.warning(f'aggregator does not exist: {aggr}')
289
374
  return aggrs
290
375
 
291
- Sheet.addCommand('+', 'aggregate-col', 'addAggregators([cursorCol], chooseAggregators())', 'add aggregator to current column')
376
+ @Sheet.api
377
+ @asyncthread
378
+ def addcol_aggregate(sheet, col, aggrnames):
379
+ for aggrname in aggrnames:
380
+ aggrs = vd.aggregators.get(aggrname)
381
+ aggrs = aggrs if isinstance(aggrs, list) else [aggrs]
382
+ if not aggrs: continue
383
+ for aggr in aggrs:
384
+ rows = aggregate_groups(sheet, col, sheet.rows, aggr)
385
+ if isinstance(aggr, ListAggregator):
386
+ t = aggr.listtype or col.type
387
+ else:
388
+ t = aggr.type or col.type
389
+ c = SettableColumn(name=f'{col.name}_{aggr.name}', type=t)
390
+ sheet.addColumnAtCursor(c)
391
+ c.setValues(sheet.rows, *rows)
392
+
393
+ Sheet.addCommand('+', 'aggregate-col', 'addAggregators([cursorCol], chooseAggregators())', 'Add aggregator to current column')
292
394
  Sheet.addCommand('z+', 'memo-aggregate', 'cursorCol.memo_aggregate(chooseAggregators(), selectedRows or rows)', 'memo result of aggregator over values in selected rows for current column')
293
395
  ColumnsSheet.addCommand('g+', 'aggregate-cols', 'addAggregators(selectedRows or source[0].nonKeyVisibleCols, chooseAggregators())', 'add aggregators to selected source columns')
396
+ Sheet.addCommand('', 'addcol-aggregate', 'addcol_aggregate(cursorCol, chooseAggregators(prompt="aggregator for groups: "))', 'add column(s) with aggregator of rows grouped by key columns')
397
+
398
+ vd.addGlobals(
399
+ ListAggregator=ListAggregator
400
+ )
294
401
 
295
402
  vd.addMenuItems('''
296
403
  Column > Add aggregator > aggregate-col
404
+ Column > Add column > aggregate > addcol-aggregate
297
405
  ''')
406
+
@@ -1,4 +1,5 @@
1
1
  from copy import copy
2
+ import threading
2
3
  import functools
3
4
  import operator
4
5
  import re
@@ -80,24 +81,18 @@ vd.openurl_sqlite = vd.open_vdsql
80
81
  class IbisConnectionPool:
81
82
  def __init__(self, source, pool=None, total=0):
82
83
  self.source = source
83
- self.pool = pool if pool is not None else []
84
- self.total = total
84
+ self._local = threading.local()
85
+ self._local.connection = None
85
86
 
86
87
  def __copy__(self):
87
- return IbisConnectionPool(self.source, pool=self.pool, total=self.total)
88
+ return IbisConnectionPool(self.source)
88
89
 
89
90
  @contextmanager
90
91
  def get_conn(self):
91
- if not self.pool:
92
- import ibis
93
- r = ibis.connect(str(self.source))
94
- else:
95
- r = self.pool.pop(0)
96
-
97
- try:
98
- yield r
99
- finally:
100
- self.pool.append(r)
92
+ import ibis
93
+ if not hasattr(self._local, 'connection') or not self._local.connection:
94
+ self._local.connection = ibis.connect(str(self.source))
95
+ yield self._local.connection
101
96
 
102
97
 
103
98
  class IbisTableIndexSheet(IndexSheet):