visidata 3.1__py3-none-any.whl → 3.2__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.
- visidata/__init__.py +2 -2
- visidata/_input.py +70 -36
- visidata/_open.py +9 -6
- visidata/_types.py +2 -2
- visidata/aggregators.py +125 -16
- visidata/apps/vdsql/_ibis.py +8 -13
- visidata/basesheet.py +4 -1
- visidata/canvas.py +11 -7
- visidata/clipboard.py +11 -2
- visidata/cliptext.py +65 -23
- visidata/cmdlog.py +5 -1
- visidata/column.py +6 -2
- visidata/ddwplay.py +2 -2
- visidata/deprecated.py +91 -63
- visidata/errors.py +41 -5
- visidata/{features → experimental}/helloworld.py +1 -1
- visidata/expr.py +1 -0
- visidata/extensible.py +4 -0
- visidata/features/cmdpalette.py +3 -3
- visidata/features/describe.py +2 -2
- visidata/features/expand_cols.py +8 -5
- visidata/features/freeze.py +14 -2
- visidata/features/go_col.py +2 -1
- visidata/features/graph_zoom_y.py +47 -0
- visidata/features/incr.py +7 -3
- visidata/features/join.py +23 -12
- visidata/features/layout.py +8 -3
- visidata/features/melt.py +1 -0
- visidata/features/rank.py +103 -0
- visidata/features/reload_every.py +9 -6
- visidata/features/sysedit.py +14 -4
- visidata/features/transpose.py +1 -0
- visidata/features/window.py +12 -0
- visidata/form.py +4 -4
- visidata/freqtbl.py +47 -3
- visidata/fuzzymatch.py +8 -5
- visidata/graph.py +5 -3
- visidata/guides/AggregatorsSheet.md +84 -0
- visidata/guides/MacrosSheet.md +1 -1
- visidata/guides/RankGuide.md +51 -0
- visidata/guides/TypesSheet.md +1 -1
- visidata/guides/WindowFunctionGuide.md +49 -0
- visidata/help.py +3 -4
- visidata/indexsheet.py +1 -1
- visidata/loaders/_pandas.py +3 -1
- visidata/loaders/archive.py +6 -3
- visidata/loaders/csv.py +5 -1
- visidata/loaders/eml.py +2 -0
- visidata/loaders/f5log.py +2 -2
- visidata/loaders/fec.py +6 -9
- visidata/loaders/fixed_width.py +2 -0
- visidata/loaders/hdf5.py +34 -10
- visidata/loaders/npy.py +54 -23
- visidata/loaders/orgmode.py +3 -2
- visidata/loaders/pandas_freqtbl.py +4 -0
- visidata/loaders/psv.py +13 -0
- visidata/loaders/sqlite.py +1 -1
- visidata/loaders/vds.py +3 -4
- visidata/macros.py +4 -3
- visidata/main.py +11 -5
- visidata/mainloop.py +7 -4
- visidata/man/parse_options.py +3 -2
- visidata/man/vd.1 +26 -14
- visidata/man/vd.txt +25 -14
- visidata/menu.py +9 -9
- visidata/metasheets.py +3 -3
- visidata/mouse.py +1 -0
- visidata/pyobj.py +17 -9
- visidata/save.py +5 -1
- visidata/selection.py +29 -18
- visidata/settings.py +2 -2
- visidata/sheets.py +52 -24
- visidata/shell.py +2 -2
- visidata/sidebar.py +4 -2
- visidata/sort.py +89 -11
- visidata/statusbar.py +10 -9
- visidata/tests/test_cliptext.py +151 -0
- visidata/tests/test_commands.py +5 -2
- visidata/tests/test_menu.py +1 -1
- visidata/textsheet.py +34 -8
- visidata/themes/ascii8.py +2 -2
- visidata/themes/light.py +5 -0
- visidata/threads.py +16 -8
- visidata/undo.py +1 -1
- {visidata-3.1.data → visidata-3.2.data}/data/share/man/man1/vd.1 +26 -14
- {visidata-3.1.data → visidata-3.2.data}/data/share/man/man1/visidata.1 +26 -14
- {visidata-3.1.dist-info → visidata-3.2.dist-info}/METADATA +62 -15
- {visidata-3.1.dist-info → visidata-3.2.dist-info}/RECORD +95 -93
- {visidata-3.1.dist-info → visidata-3.2.dist-info}/WHEEL +1 -1
- {visidata-3.1.dist-info → visidata-3.2.dist-info}/entry_points.txt +1 -0
- visidata/features/errors_guide.py +0 -26
- visidata/loaders/api_bitio.py +0 -102
- visidata/stored_prop.py +0 -38
- visidata-3.1.data/scripts/vd +0 -6
- /visidata/{guides/SortGuide.md → vendor/__init__.py} +0 -0
- {visidata-3.1.data → visidata-3.2.data}/data/share/applications/visidata.desktop +0 -0
- {visidata-3.1.data → visidata-3.2.data}/scripts/vd2to3.vdx +0 -0
- {visidata-3.1.dist-info → visidata-3.2.dist-info}/LICENSE.gpl3 +0 -0
- {visidata-3.1.dist-info → visidata-3.2.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.
|
3
|
+
__version__ = '3.2'
|
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-
|
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
|
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
|
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:
|
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
|
-
|
181
|
-
|
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,31 +194,57 @@ 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
|
-
|
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
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
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:
|
247
|
+
prew = dispwidth(dispval[:i])
|
222
248
|
scr.move(y, x+prew)
|
223
249
|
|
224
250
|
def handle_key(self, ch:str, scr) -> bool:
|
@@ -249,17 +275,25 @@ class InputWidget:
|
|
249
275
|
c = vd.prettykeys(c)
|
250
276
|
i += len(c)
|
251
277
|
v += c
|
252
|
-
elif ch == '^O':
|
278
|
+
elif ch == '^O':
|
279
|
+
edit_v = vd.launchExternalEditor(v)
|
280
|
+
if self.value == edit_v:
|
281
|
+
# leave cell unmodified when the editor exits with no change
|
282
|
+
raise EscapeException(ch)
|
283
|
+
else:
|
284
|
+
self.value = edit_v
|
285
|
+
return True
|
253
286
|
elif ch == '^R': v = self.orig_value # ^Reload initial value
|
254
287
|
elif ch == '^T': v = delchar(splice(v, i-2, v[i-1:i]), i) # swap chars
|
255
288
|
elif ch == '^U': v = v[i:]; i = 0 # clear to beginning
|
256
289
|
elif ch == '^V': v = splice(v, i, until_get_wch(scr)); i += 1 # literal character
|
257
|
-
elif ch == '^W': j =
|
290
|
+
elif ch == '^W': j = find_word(v, 0, i-1, -1); v = v[:j+1] + v[i:]; i = j+1 # erase word
|
291
|
+
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
292
|
elif ch == '^Y': v = splice(v, i, str(vd.memory.clipval))
|
259
293
|
elif ch == '^Z': vd.suspend()
|
260
294
|
# CTRL+arrow
|
261
|
-
elif ch == 'kLFT5': i =
|
262
|
-
elif ch == 'kRIT5': i =
|
295
|
+
elif ch == 'kLFT5': i = find_word(v, 0, i-1, -1)+1; # word left
|
296
|
+
elif ch == 'kRIT5': i = find_word(v, i, len(v)-1, +1); # word right
|
263
297
|
elif ch == 'kUP5': pass
|
264
298
|
elif ch == 'kDN5': pass
|
265
299
|
elif self.history and ch == 'KEY_UP': v, i = self.prev_history(v, i)
|
@@ -337,7 +371,7 @@ class InputWidget:
|
|
337
371
|
@VisiData.api
|
338
372
|
def editText(vd, y, x, w, attr=ColorAttr(), value='',
|
339
373
|
help='',
|
340
|
-
updater=None, bindings={},
|
374
|
+
updater=lambda val: None, bindings={},
|
341
375
|
display=True, record=True, clear=True, **kwargs):
|
342
376
|
'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.'
|
343
377
|
v = None
|
@@ -569,7 +603,7 @@ def input(vd, prompt, type=None, defaultLast=False, history=[], dy=0, attr=None,
|
|
569
603
|
@VisiData.api
|
570
604
|
def confirm(vd, prompt, exc=EscapeException):
|
571
605
|
'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
|
606
|
+
if vd.options.batch:
|
573
607
|
return vd.fail('cannot confirm in batch mode: ' + prompt)
|
574
608
|
|
575
609
|
yn = vd.input(prompt, value='no', record=False)[:1]
|
@@ -594,7 +628,7 @@ class CompleteKey:
|
|
594
628
|
@Sheet.api
|
595
629
|
def editCell(self, vcolidx=None, rowidx=None, value=None, **kwargs):
|
596
630
|
'''Call vd.editText for the cell at (*rowidx*, *vcolidx*). Return the new value, properly typed.
|
597
|
-
|
631
|
+
- *vcolidx*: numeric index into ``self.availCols``. When None, use current column.
|
598
632
|
- *rowidx*: numeric index into ``self.rows``. If negative, indicates the column name in the header.
|
599
633
|
- *value*: if given, the starting input; otherwise the starting input is the cell value or column name as appropriate.
|
600
634
|
- *kwargs*: passthrough args to ``vd.editText``.
|
@@ -604,7 +638,7 @@ def editCell(self, vcolidx=None, rowidx=None, value=None, **kwargs):
|
|
604
638
|
vcolidx = self.cursorVisibleColIndex
|
605
639
|
x, w = self._visibleColLayout.get(vcolidx, (0, 0))
|
606
640
|
|
607
|
-
col = self.
|
641
|
+
col = self.availCols[vcolidx]
|
608
642
|
if rowidx is None:
|
609
643
|
rowidx = self.cursorRowIndex
|
610
644
|
|
@@ -626,7 +660,7 @@ def editCell(self, vcolidx=None, rowidx=None, value=None, **kwargs):
|
|
626
660
|
'KEY_BTAB': acceptThenFunc('go-left', 'rename-col' if rowidx < 0 else 'edit-cell'),
|
627
661
|
}
|
628
662
|
|
629
|
-
if vcolidx
|
663
|
+
if vcolidx == self.nVisibleCols-1 or vcolidx >= self.nCols-1:
|
630
664
|
bindings['^I'] = acceptThenFunc('go-down', 'go-leftmost', 'edit-cell')
|
631
665
|
|
632
666
|
if vcolidx <= 0:
|
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
|
-
|
98
|
-
|
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
|
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:
|
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
|
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='
|
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.
|
158
|
-
vd.aggregator('stdev',
|
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
|
209
|
-
|
210
|
-
|
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
|
-
|
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
|
-
|
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 = ' '*(
|
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.
|
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
|
+
|
visidata/apps/vdsql/_ibis.py
CHANGED
@@ -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.
|
84
|
-
self.
|
84
|
+
self._local = threading.local()
|
85
|
+
self._local.connection = None
|
85
86
|
|
86
87
|
def __copy__(self):
|
87
|
-
return IbisConnectionPool(self.source
|
88
|
+
return IbisConnectionPool(self.source)
|
88
89
|
|
89
90
|
@contextmanager
|
90
91
|
def get_conn(self):
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
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):
|
visidata/basesheet.py
CHANGED
@@ -76,6 +76,8 @@ class DrawablePane(Extensible):
|
|
76
76
|
|
77
77
|
try:
|
78
78
|
self.sheet = self
|
79
|
+
if cmd.deprecated:
|
80
|
+
vd.deprecated_warn(cmd.longname, cmd.deprecated, 'a different command')
|
79
81
|
code = compile(cmd.execstr, cmd.longname, 'exec')
|
80
82
|
exec(code, vdglobals, LazyChainMap(vd, self))
|
81
83
|
return False
|
@@ -105,6 +107,7 @@ class BaseSheet(DrawablePane):
|
|
105
107
|
precious = True # False for a few discardable metasheets
|
106
108
|
defer = False # False for not deferring changes until save
|
107
109
|
guide = '' # default to show in sidebar
|
110
|
+
icon = '›'
|
108
111
|
|
109
112
|
def _obj_options(self):
|
110
113
|
return vd.OptionsObject(vd._options, obj=self)
|
@@ -212,7 +215,7 @@ class BaseSheet(DrawablePane):
|
|
212
215
|
try:
|
213
216
|
for hookfunc in vd.beforeExecHooks:
|
214
217
|
hookfunc(self, cmd, '', keystrokes)
|
215
|
-
escaped =
|
218
|
+
escaped = self.execCommand2(cmd, vdglobals=vdglobals)
|
216
219
|
except Exception as e:
|
217
220
|
vd.debug(cmd.execstr)
|
218
221
|
err = vd.exceptionCaught(e)
|
visidata/canvas.py
CHANGED
@@ -11,7 +11,7 @@ from visidata.bezier import bezier
|
|
11
11
|
vd.theme_option('disp_graph_labels', True, 'show axes and legend on graph')
|
12
12
|
vd.theme_option('plot_colors', 'green red yellow cyan magenta white 38 136 168', 'list of distinct colors to use for plotting distinct objects')
|
13
13
|
vd.theme_option('disp_canvas_charset', ''.join(chr(0x2800+i) for i in range(256)), 'charset to render 2x4 blocks on canvas')
|
14
|
-
vd.theme_option('
|
14
|
+
vd.theme_option('disp_graph_pixel_random', False, 'randomly choose attr from set of pixels instead of most common')
|
15
15
|
vd.theme_option('disp_zoom_incr', 2.0, 'amount to multiply current zoomlevel when zooming')
|
16
16
|
vd.theme_option('color_graph_hidden', '238 blue', 'color of legend for hidden attribute')
|
17
17
|
vd.theme_option('color_graph_selected', 'bold', 'color of selected graph points')
|
@@ -258,7 +258,7 @@ class Plotter(BaseSheet):
|
|
258
258
|
disp_canvas_charset += (256 - len(disp_canvas_charset)) * disp_canvas_charset[-1]
|
259
259
|
if self.pixels:
|
260
260
|
cursorBBox = self.plotterCursorBox
|
261
|
-
getPixelAttr = self.getPixelAttrRandom if self.options.
|
261
|
+
getPixelAttr = self.getPixelAttrRandom if self.options.disp_graph_pixel_random else self.getPixelAttrMost
|
262
262
|
|
263
263
|
for char_y in range(0, self.plotheight//4):
|
264
264
|
for char_x in range(0, self.plotwidth//2):
|
@@ -304,8 +304,8 @@ class Plotter(BaseSheet):
|
|
304
304
|
def _overlaps(a, b):
|
305
305
|
a_x1, _, a_txt, _, _ = a
|
306
306
|
b_x1, _, b_txt, _, _ = b
|
307
|
-
a_x2 = a_x1 +
|
308
|
-
b_x2 = b_x1 +
|
307
|
+
a_x2 = a_x1 + dispwidth(a_txt)
|
308
|
+
b_x2 = b_x1 + dispwidth(b_txt)
|
309
309
|
if a_x1 < b_x1 < a_x2 or a_x1 < b_x2 < a_x2 or \
|
310
310
|
b_x1 < a_x1 < b_x2 or b_x1 < a_x2 < b_x2:
|
311
311
|
return True
|
@@ -325,10 +325,10 @@ class Plotter(BaseSheet):
|
|
325
325
|
for pix_x, pix_y, txt, attr, row in self.labels:
|
326
326
|
if attr in self.hiddenAttrs:
|
327
327
|
continue
|
328
|
-
if row is not None:
|
329
|
-
pix_x -= len(txt)/2*2
|
330
328
|
char_y = int(pix_y/4)
|
331
329
|
char_x = int(pix_x/2)
|
330
|
+
if row is not None:
|
331
|
+
char_x -= math.ceil(dispwidth(txt)/2)*2
|
332
332
|
o = (char_x, char_y, txt, attr, row)
|
333
333
|
_mark_overlap_text(labels_by_line[char_y], o)
|
334
334
|
|
@@ -356,6 +356,7 @@ class Canvas(Plotter):
|
|
356
356
|
rightMarginPixels = 4*2
|
357
357
|
topMarginPixels = 0*4
|
358
358
|
bottomMarginPixels = 1*4 # reserve bottom line for x axis
|
359
|
+
guide = '# Canvas\n'
|
359
360
|
|
360
361
|
def __init__(self, *names, **kwargs):
|
361
362
|
self.left_margin = self.leftMarginPixels
|
@@ -384,6 +385,9 @@ class Canvas(Plotter):
|
|
384
385
|
def reset(self):
|
385
386
|
'clear everything in preparation for a fresh reload()'
|
386
387
|
self.polylines.clear()
|
388
|
+
self.canvasBox = None
|
389
|
+
self.visibleBox = None
|
390
|
+
self.cursorBox = None
|
387
391
|
self.left_margin = self.leftMarginPixels
|
388
392
|
self.legends.clear()
|
389
393
|
self.legendwidth = 0
|
@@ -727,7 +731,7 @@ class Canvas(Plotter):
|
|
727
731
|
def plot_elements(self, invert_y=False):
|
728
732
|
'plots points and lines and text onto the plotter'
|
729
733
|
|
730
|
-
self.resetBounds()
|
734
|
+
self.resetBounds(refresh=False)
|
731
735
|
|
732
736
|
bb = self.visibleBox
|
733
737
|
xmin, ymin, xmax, ymax = bb.xmin, bb.ymin, bb.xmax, bb.ymax
|