Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[main-] handle position args as +sheet:subsheet:col:row #2425

Open
wants to merge 11 commits into
base: develop
Choose a base branch
from
Open
2 changes: 1 addition & 1 deletion visidata/basesheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ def getSheet(vd, sheetname):
try:
sheetidx = int(sheetname)
return vd.sheets[sheetidx]
except ValueError:
except (ValueError, IndexError):
pass

if sheetname == 'options':
Expand Down
2 changes: 1 addition & 1 deletion visidata/cmdlog.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ def isLoggableSheet(sheet):
def moveToRow(vs, rowstr):
'Move cursor to row given by *rowstr*, which can be either the row number or keystr.'
rowidx = vs.getRowIndexFromStr(rowstr)
if rowidx is None:
if rowidx is None or rowidx >= vs.nRows:
return False

vs.cursorRowIndex = rowidx
Expand Down
34 changes: 17 additions & 17 deletions visidata/features/slide.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,20 +116,20 @@ def t(vdx, golden):
def test_slide_keycol_1(vd):
t = make_tester(f'''
open-file {sample_path}
+::OrderDate key-col
+::Region key-col
+::Rep key-col
+:OrderDate: key-col
+:Region: key-col
+:Rep: key-col
''')

t('', 'OrderDate Region Rep Item Units Unit_Cost Total')
t('+::Rep slide-leftmost', 'Rep OrderDate Region Item Units Unit_Cost Total')
t('+::OrderDate slide-rightmost', 'Region Rep OrderDate Item Units Unit_Cost Total')
t('+::Rep slide-left', 'OrderDate Rep Region Item Units Unit_Cost Total')
t('+::OrderDate slide-right', 'Region OrderDate Rep Item Units Unit_Cost Total')
t('+:Rep: slide-leftmost', 'Rep OrderDate Region Item Units Unit_Cost Total')
t('+:OrderDate: slide-rightmost', 'Region Rep OrderDate Item Units Unit_Cost Total')
t('+:Rep: slide-left', 'OrderDate Rep Region Item Units Unit_Cost Total')
t('+:OrderDate: slide-right', 'Region OrderDate Rep Item Units Unit_Cost Total')

t('''
+::Item key-col
+::Item slide-left
+:Item: key-col
+:Item: slide-left
slide-left
slide-right
slide-right
Expand All @@ -141,17 +141,17 @@ def test_slide_keycol_1(vd):
def test_slide_leftmost(vd):
t = make_tester(f'''open-file {benchmark_path}''')

t('+::Paid slide-leftmost', 'Paid Date Customer SKU Item Quantity Unit')
t('+:Paid: slide-leftmost', 'Paid Date Customer SKU Item Quantity Unit')

t = make_tester(f'''
open-file {benchmark_path}
+::Date key-col
+:Date: key-col
''')

t('', 'Date Customer SKU Item Quantity Unit Paid')
t('''+::Item slide-leftmost''', 'Date Item Customer SKU Quantity Unit Paid')
t('''+::SKU key-col
+::Quantity slide-leftmost''', 'Date SKU Quantity Customer Item Unit Paid')
t('''+::Date slide-leftmost''', 'Date Customer SKU Item Quantity Unit Paid')
t('''+::Item slide-leftmost
+::SKU slide-leftmost''', 'Date SKU Item Customer Quantity Unit Paid')
t('''+:Item: slide-leftmost''', 'Date Item Customer SKU Quantity Unit Paid')
t('''+:SKU: key-col
+:Quantity: slide-leftmost''', 'Date SKU Quantity Customer Item Unit Paid')
t('''+:Date: slide-leftmost''', 'Date Customer SKU Item Quantity Unit Paid')
t('''+:Item: slide-leftmost
+:SKU: slide-leftmost''', 'Date SKU Item Customer Quantity Unit Paid')
198 changes: 142 additions & 56 deletions visidata/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
import signal
import warnings
import builtins # to override print
import time

from visidata import vd, options, run, BaseSheet, AttrDict
from visidata import Path
from visidata import Path, asyncthread
from visidata.settings import _get_config_file
import visidata

Expand Down Expand Up @@ -90,28 +91,43 @@ def duptty():


@visidata.VisiData.api
def parsePos(vd, arg:str, inputs=None):
'Return (startsheets:list, startrow:str, startcol:str) from *arg* like "+sheet:subsheet:col:row". Empty sheetstr in startsheets means the starting pos applies to all sheets.'
startsheets, startrow, startcol = [], None, None
def parsePos(vd, arg:str, inputs:'list[tuple[str, dict]]'=None):
'''Return (startsheets:list, startcol:str, startrow:str) from *arg* like "+sheet:subsheet:col:row".
The elements of *startsheets* are identifiers that pick out a sheet, either
a) a string that is the name of a sheet or subsheet
b) integers (which are indices of a row or column, or a sheet number).
For example [1, 'sales', 3].
Returns an empty list for *startsheets* when the starting pos applies to all sheets.
Returns None for *startsheets* when the position expression did not specify a sheet.
*inputs* is a list of (path, options) tuples.
'''
if arg == '': return None
startsheets, startcol, startrow = None, None, None

pos = []
# convert any numeric index strings to ints
for idx in arg.split(':'):
if idx:
if idx.isdigit() or (idx[0] == '-' and idx[1:].isdigit()):
idx = int(idx)
pos.append(idx)

if ':' not in arg:
return (None, arg, None)

pos = arg.split(':')
if len(pos) == 1:
startsheet = [Path(inputs[-1]).base_stem] if inputs else None
start_pos = (startsheet, pos[0], None)
# -1 means the last sheet in the list of open sheets
startsheets = [-1] if inputs else None
startrow = arg
elif len(pos) == 2:
startsheet = [Path(inputs[-1]).base_stem] if inputs else None
startrow, startcol = pos
start_pos = (None, startrow, startcol)
else: # if len(pos) >= 3:
startsheets = [-1] if inputs else None
startcol, startrow = pos
else:
# the first element of pos is the startsheet,
# the later elements (if present) describe the branch to a subsheet
startsheets = pos[:-2]
startrow, startcol = pos[-2:]
start_pos = (startsheets, startrow, startcol)

# index subsheets need to be loaded *after* the cursor indexing
vd.options.set('load_lazy', True, obj=start_pos[0])
if startsheets == ['']: startsheets = []
startcol, startrow = pos[-2:]
if startcol == '': startcol = None
if startrow == '': startrow = None
start_pos = (startsheets, startcol, startrow)

return start_pos

Expand All @@ -133,45 +149,113 @@ def outputProgressEvery(vd, sheet, seconds:float=0.5):
time.sleep(seconds)

@visidata.VisiData.api
def moveToPos(vd, sources, startsheets, startrow, startcol):
sheets = [] # sheets to apply startrow:startcol to
if not startsheets:
sheets = sources # apply row/col to all sheets
def moveToPos(vd, sources, sheet_desc, startcol, startrow):
if sheet_desc == [] or sheet_desc[0] == '':
## the list moves must have each of its elements refer only 1
# sheet, so expand the "all sheets" sheet descriptor into individual sheets
sheet_descs = [[i] + sheet_desc[1:] for i, sheet in enumerate(sources)]
else:
startsheet = startsheets[0] or sources[-1]
vs = vd.getSheet(startsheet)
if not vs:
vd.warning(f'no sheet "{startsheet}"')
return

vd.sync(vs.ensureLoaded())
vd.clearCaches()
for startsheet in startsheets[1:]:
rowidx = vs.getRowIndexFromStr(vd.options.rowkey_prefix + startsheet)
if rowidx is None:
vd.warning(f'{vs.name} has no subsheet "{startsheet}"')
vs = None
break
vs = vs.rows[rowidx]
sheet_descs = [sheet_desc]
# for each sheet, attempt column moves first, then rows
if startcol is not None or startrow is not None:
moves = [(d, startcol, None) for d in sheet_descs] + \
[(d, None, startrow) for d in sheet_descs]
else:
moves = [(d, None, None) for d in sheet_descs]
# start a thread to keep attempting the moves till they all succeed, for sheets that are slow to load
retry_move_to_pos(vd, sources, moves)

def sheet_from_description(vd, sources, sheet_desc):
'''Return a Sheet to apply col/row to, given a list *sheet_desc* that refers to one specific sheet.
The *sheet_desc* is either a Sheet, or a list of strings/ints similar to the return value of parsePos(),
with the difference that *sheet_desc* will not ever be the empty list that denotes "all sheets".
Return None if no matching sheet was found; if sheets are loading, a subsequent call may return
a matching sheet.
Raise ValueError to indicate that a move failed, and should not be retried.'''
if isinstance(sheet_desc, BaseSheet):
vd.push(sheet_desc)
return sheet_desc

# descend the tree of subsheets
for desc_lvl, subsheet in enumerate(sheet_desc):
if desc_lvl == 0:
vs = None
#try subsheets as numbers first, then as names
if isinstance(subsheet, int):
try:
vs = sources[subsheet]
except IndexError:
pass
else:
vs = vd.getSheet(subsheet)
if not vs:
raise ValueError(f'no sheet "{subsheet}"')
else:
if isinstance(subsheet, int):
rowidx = subsheet
else:
rowidx = vs.getRowIndexFromStr(vd.options.rowkey_prefix + subsheet)
try:
if rowidx is None: raise IndexError
vs_subsheet = vs.rows[rowidx]
except IndexError:
vd.warning(f'sheet {vs.name} has no subsheet "{subsheet}"')
return None
if not isinstance(vs_subsheet, BaseSheet):
raise ValueError(f'row "{subsheet}" is not a sheet in {vs.name}')
vs = vs_subsheet
# if we have any more levels of subsheets to look at, load the current sheet fully
if desc_lvl < len(sheet_desc) - 1:
# Prevent the sheet from doing automatic ensureLoaded() on its subsheets when it
# loads, so that we can call ensureLoaded() ourselves and sync() on it.
vd.options.set('load_lazy', True, obj=vs)
vd.sync(vs.ensureLoaded())
vd.clearCaches()
if vs:
vd.push(vs)
sheets = [vs]

if startrow:
for vs in sheets:
if vs:
vs.moveToRow(startrow) or vd.warning(f'{vs} has no row "{startrow}"')

if startcol:
for vs in sheets:
if vs:
if not vs.moveToCol(startcol):
if startcol.isdigit():
vs.moveToCol(int(startcol)) # handle indexing by column number
else:
vd.warning(f'{vs} has no column "{startcol}"')
vd.push(vs)
return vs

@asyncthread
def retry_move_to_pos(vd, sources, moves, retry_interval=0.1):
while moves:
unmoved = []
for move in moves:
try:
move_succeeded = attempt_move_to_pos(vd, sources, *move)
except ValueError as e:
# skip failed moves if they can't ever succeed
vd.warning(e)
continue
if not move_succeeded:
unmoved.append(move)
if unmoved:
time.sleep(retry_interval)
moves = unmoved

def attempt_move_to_pos(vd, sources, sheet_desc, startcol, startrow):
'''Return True if the move succeeded in moving to the row and column, on the described sheet.
Raise ValueError to indicate that a move failed, and should not be retried.'''
vs = sheet_from_description(vd, sources, sheet_desc)
if not vs:
return False
# switch the active sheet, for command line args like +s::
if vs and startrow is None and startcol is None:
vd.push(vs)
return True

# try cursor moves
success = True
if startrow is not None:
if not vs.moveToRow(startrow):
if vs.nRows > 0: # avoid uninformative warnings early in startup
vd.warning(f'{vs} has no row {startrow}: n_rows={len(vs.rows)}"')
success = False

if startcol is not None:
if not vs.moveToCol(startcol):
if vs.nRows > 0:
vd.warning(f'{vs} has no column {startcol}')
success = False
return success

def main_vd():
'Open the given sources using the VisiData interface.'
Expand Down Expand Up @@ -259,7 +343,9 @@ def main_vd():
if flGlobal:
global_args[optname] = optval
elif arg.startswith('+'): # position cursor at start
after_config.append((vd.moveToPos, *vd.parsePos(arg[1:], inputs=inputs)))
parsed_pos = vd.parsePos(arg[1:], inputs=inputs)
if parsed_pos:
after_config.append((vd.moveToPos, *parsed_pos))
elif current_args.get('play', None) and '=' in arg:
# parse 'key=value' pairs for formatting cmdlog template in replay mode
k, v = arg.split('=', maxsplit=1)
Expand Down
37 changes: 31 additions & 6 deletions visidata/man/vd.inc
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
.Nm vd
.Op Ar options
.Op Ar input No ...
.Cm + Ns Ar toplevel Ns : Ns Ar subsheet Ns : Ns Ar row Ns : Ns Ar col
.Cm + Ns Ar toplevel Ns : Ns Ar subsheet Ns : Ns Ar col Ns : Ns Ar row
.
.Sh DESCRIPTION
.Nm VisiData No is an easy-to-use multipurpose tool to explore, clean, edit, and restructure data.
Expand Down Expand Up @@ -880,8 +880,8 @@ show/hide methods and hidden properties
.Bl -tag -width XXXXXXXXXXXXXXXXXXXXXXXXXXX -compact
.It Cm -P Ns = Ns Ar longname
.No preplay Ar longname No before replay or regular launch; limited to Sy Base Sheet No bound commands
.It Cm + Ns Ar toplevel Ns : Ns Ar subsheet Ns : Ns Ar row Ns : Ns Ar col
.No launch vd with Ar subsheet No of Ar toplevel No at top-of-stack, and cursor at Ar row No and Ar col Ns ; all arguments are optional
.It Cm + Ns Ar toplevel Ns : Ns Ar subsheet Ns : Ns Ar col Ns : Ns Ar row
.No launch vd with Ar subsheet No of Ar toplevel No at top-of-stack, and cursor at Ar col No and Ar row Ns ; all arguments are optional
.It Cm --overwrite Ns = Ns Ar c
.No Overwrite with confirmation
.It Cm --guides
Expand Down Expand Up @@ -959,11 +959,36 @@ disable loading .visidatarc and plugin addons
.Dl Ic vd newfile.tsv
.No open a blank sheet named Ar newfile No if file does not exist
.Pp
.Dl Ic vd sample.xlsx +:sheet1:2:3
.No launch with Sy sheet1 No at top-of-stack, and cursor at column Sy 2 No and row Sy 3
.Pp
.Dl Ic vd -P open-plugins
.No preplay longname Sy open-plugins No before starting the session
.Pp
.Dl Ic vd a.xlsx b.tsv c.tsv +0:3:1:2
.No launch with cursor for sheet Sy 0 No (a.xlsx) at subsheet Sy 3 No in sheet Sy 0 Ns , col Sy 1 Ns , row Sy 2
.Dl Ic vd a.xlsx b.tsv c.tsv +0:1:2
.No launch with cursor for sheet Sy 0 No (a.xlsx) at col Sy 1 Ns , row Sy 2
.Dl Ic vd a.tsv b.tsv c.tsv +1:2
.No launch with cursor for last sheet (c.tsv) at col Sy 1 Ns , row Sy 2
.Dl Ic vd a.tsv b.tsv c.tsv +1:
.No launch with cursor for last sheet (c.tsv) at col Sy 1
.Dl Ic vd a.tsv b.tsv c.tsv +:2
.No launch with cursor for last sheet (c.tsv) at row Sy 2
.Pp
.Dl Ic vd a.xlsx b.xlsx c.xlsx +0:annual:1:2 +1:sales:monthly:3:4
.No launch with cursor for sheet Sy 0 No (a.xlsx) in subsheet Sy annual No at col Sy 1 Ns , row Sy 2 Ns , and cursor for sheet Sy 1 No (b.xlsx), subsheet Sy sales No in subsheet monthly; col Sy 3 Ns , row Sy 4
.Pp
.Dl Ic vd a.tsv b.tsv c.tsv +1::
.No launch in sheet Sy 1 No (b.tsv), with cursor at col 0, row 0
.Dl Ic vd a.tsv b.tsv c.tsv +-2::
.No launch in Sy 2nd No from last sheet (b.tsv), with cursor at col 0, row 0
.Pp
.Dl Ic vd a.tsv b.tsv c.tsv +:1:
.No launch with cursors for all sheets at col Sy 1
.Dl Ic vd a.tsv b.tsv c.tsv +::2
.No launch with cursors for all sheets at row Sy 2
.Dl Ic vd a.tsv b.tsv c.tsv +:1:2
.No launch with cursors for all sheets at col Sy 1 Ns , row Sy 2
.Dl Ic vd a.xlsx b.xslx c.xlsx +:a:1:2
.No launch with cursors for all sheets at subsheet Sy a Ns , col Sy 1 Ns , row Sy 2
.Sh FILES
At the start of every session,
.Sy VisiData No looks for Pa $HOME/.visidatarc Ns , and calls Python exec() on its contents if it exists.
Expand Down
4 changes: 2 additions & 2 deletions visidata/man/vd.txt
Original file line number Diff line number Diff line change
Expand Up @@ -566,8 +566,8 @@ COMMANDLINE OPTIONS

-P=longname preplay longname before replay or regular
launch; limited to Base Sheet bound commands
+toplevel:subsheet:row:col launch vd with subsheet of toplevel at
top-of-stack, and cursor at row and col; all
+toplevel:subsheet:col:row launch vd with subsheet of toplevel at
top-of-stack, and cursor at col and row; all
arguments are optional
--overwrite=c Overwrite with confirmation
--guides open Guide Index
Expand Down