Source code for visidata.modify

from copy import copy

from visidata import vd, VisiData, asyncthread, ColumnColorizer
from visidata import Sheet, RowColorizer, CellColorizer, Column, BaseSheet, Progress, ColumnsSheet

vd.theme_option('color_readonly', 'on 52', 'color for readonly columns')
vd.theme_option('color_add_pending', 'green', 'color for rows pending add')
vd.theme_option('color_change_pending', 'reverse yellow', 'color for cells pending modification')
vd.theme_option('color_delete_pending', 'red', 'color for rows pending delete')
vd.option('overwrite', 'c', 'allow overwriting existing files {c=check|n=no/readonly}')

vd.optalias('readonly', 'overwrite', 'n')
vd.optalias('ro', 'overwrite', 'n')
vd.optalias('y', 'confirm', 'y')


@VisiData.api
def couldOverwrite(vd) -> bool:
    'Return True if overwrite might be allowed.'
    return vd.options.overwrite.startswith(('c', 'y'))


@VisiData.api
def confirmOverwrite(vd, path, msg:str=''):
    'Fail if file exists and overwrite not allowed.  *path* of None always checks.'
    if path is not None and not path.exists():
        return True
    ow = vd.options.overwrite
    if not ow.startswith(('c', 'y')):  #1805 empty/no/never: readonly
        vd.fail('overwrite disabled')
    if ow.startswith('c'):  # 'y' (legacy) excluded on purpose: it overwrites without confirming
        if not msg and path is not None:
            msg = f'{path.given} exists. overwrite? '
        vd.confirm(msg or 'overwrite? ')
    return True

# deferred cached
@Sheet.lazy_property
def _deferredAdds(sheet):
    return dict() # [s.rowid(row)] -> row

@Sheet.lazy_property
def _deferredMods(sheet):
    return dict() # [s.rowid(row)] -> (row, { [col] -> val })

@Sheet.lazy_property
def _deferredDels(sheet):
    return dict() # [s.rowid(row)] -> row

Sheet.colorizers += [
        RowColorizer(9, 'color_add_pending', lambda s,c,r,v: s.rowid(r) in s._deferredAdds),
        CellColorizer(8, 'color_change_pending', lambda s,c,r,v: c and (r is not None) and s.isChanged(c, r)),
        RowColorizer(9, 'color_delete_pending', lambda s,c,r,v: s.isDeleted(r)),
        ColumnColorizer(9, 'color_readonly', lambda s,c,r,v: c and (r is None) and c.readonly),
]

@Sheet.api
def preloadHook(sheet):
    BaseSheet.preloadHook(sheet)
    sheet._deferredAdds.clear()
    sheet._deferredMods.clear()
    sheet._deferredDels.clear()

@Sheet.after
def afterLoad(sheet):
    sheet.hasBeenModified = False

@Sheet.api
def rowAdded(self, row):
    'Mark row as a deferred add-row'
    self._deferredAdds[self.rowid(row)] = row
    def _undoRowAdded(sheet, row):
        if sheet.rowid(row) not in sheet._deferredAdds:
            vd.warning('cannot undo row addition after a commit')
            return
        del sheet._deferredAdds[sheet.rowid(row)]
    vd.addUndo(_undoRowAdded, self, row)

@Column.api
def cellChanged(col, row, val):
    'Mark cell at row for col as a deferred edit-cell'
    oldval = col.getValue(row)
    if oldval != val:
        rowid = col.sheet.rowid(row)

        if rowid in col.sheet._deferredAdds:
            col.putValue(row, val)
            def _undoNewCellChanged(col, row, oldval):
                col.putValue(row, oldval)
            vd.addUndo(_undoNewCellChanged, col, row, oldval)
            return


        if rowid not in col.sheet._deferredMods:
            rowmods = {}
            col.sheet._deferredMods[rowid] = (row, rowmods)
        else:
            _, rowmods = col.sheet._deferredMods[rowid]
        rowmods[col] = val

        def _undoCellChanged(col, row, oldval):
            if oldval == col.getSourceValue(row):
                # if we have reached the original value, remove from defermods entirely
                if col.sheet.rowid(row) not in col.sheet._deferredMods:
                    vd.warning('cannot undo cell change after a commit')
                    return
                del col.sheet._deferredMods[col.sheet.rowid(row)]
            else:
                # otherwise, update deferredMods with previous value
                _, rowmods = col.sheet._deferredMods[col.sheet.rowid(row)]
                rowmods[col] = oldval

        vd.addUndo(_undoCellChanged, col, row, oldval)

@Sheet.api
def rowDeleted(self, row):
    'Mark row as a deferred delete-row'
    self._deferredDels[self.rowid(row)] = row
    self.addUndoSelection()
    self.unselectRow(row)
    def _undoRowDeleted(sheet, row):
        if sheet.rowid(row) not in sheet._deferredDels:
            vd.warning('cannot undo row deletion after a commit')
            return
        del sheet._deferredDels[sheet.rowid(row)]
    vd.addUndo(_undoRowDeleted, self, row)


@Sheet.api
@asyncthread
def addRows(sheet, rows, index=None, undo=True):
    'Add *rows* after row at *index*, possibly deferred, setting the modified status, and making it undoable if *undo* is True.'
    addedRows = {}
    if index is None: index=len(sheet.rows)
    for i, row in enumerate(Progress(rows, gerund='adding')):
        addedRows[sheet.rowid(row)] = row
        sheet.addRow(row, index=index+i+1)

        if sheet.defer:
            sheet.rowAdded(row)
    sheet.setModified()

    @asyncthread
    def _removeRows():
        sheet.deleteBy(lambda r,sheet=sheet,addedRows=addedRows: sheet.rowid(r) in addedRows, commit=True, undo=False)

    if undo:
        vd.addUndo(_removeRows)


[docs] @Sheet.api def deleteBy(sheet, func, commit=False, undo=True): '''Delete rows on sheet for which ``func(row)`` returns true. Return number of rows deleted. If sheet.defer is set and *commit* is True, remove rows immediately without deferring. If undo is set to True, add an undo for deletion.''' oldrows = copy(sheet.rows) oldidx = sheet.cursorRowIndex ndeleted = 0 newCursorRow = None # row to re-place cursor after # if commit is True, commit to delete, even if defer is True if sheet.defer and not commit: ndeleted = 0 for r in sheet.gatherBy(func, 'deleting'): sheet.rowDeleted(r) ndeleted += 1 return ndeleted # find next non-deleted row to go to once delete has finished while oldidx < len(oldrows): if not func(oldrows[oldidx]): newCursorRow = sheet.rows[oldidx] break oldidx += 1 sheet.rows.clear() # must delete from the existing rows object for r in Progress(oldrows, 'deleting'): if not func(r): sheet.rows.append(r) if r is newCursorRow: sheet.cursorRowIndex = len(sheet.rows)-1 else: try: sheet.commitDeleteRow(r) ndeleted += 1 except Exception as e: vd.exceptionCaught(e) if undo: vd.addUndo(setattr, sheet, 'rows', oldrows) sheet.setModified() if ndeleted: vd.status('deleted %s %s' % (ndeleted, sheet.rowtype)) return ndeleted
@Sheet.api def isDeleted(self, row): 'Return True if *row* has been deferred for deletion.' return self.rowid(row) in self._deferredDels @Sheet.api def isChanged(self, col, row): 'Return True if cell at *row* for *col* has been deferred for modification.' try: row, rowmods = self._deferredMods[self.rowid(row)] newval = rowmods[col] curval = col.getSourceValue(row) return (newval is None and curval is not None) or (curval is None and newval is not None) or (col.type(newval) != col.type(curval)) except KeyError: return False except Exception: return False
[docs] @Column.api def getSourceValue(col, row): 'For deferred sheets, return value for *row* in this *col* as it would be in the source, without any deferred modifications applied.' return Column.calcValue(col, row)
@Sheet.api def commitAdds(self): 'Return the number of rows that have been marked for deferred add-row. Clear the marking.' nadded = 0 nerrors = 0 for row in self._deferredAdds.values(): try: self.commitAddRow(row) nadded += 1 except Exception as e: vd.exceptionCaught(e) nerrors += 1 if nadded or nerrors: vd.status(f'added {nadded} {self.rowtype} ({nerrors} errors)') self._deferredAdds.clear() return nadded @Sheet.api def commitMods(sheet): 'Commit all deferred modifications (that are not from rows added or deleted in this commit. Return number of cells changed.' _, deferredmods, _ = sheet.getDeferredChanges() nmods = 0 for row, rowmods in deferredmods.values(): for col, val in rowmods.items(): try: col.putValue(row, val) nmods += 1 except Exception as e: vd.exceptionCaught(e) sheet._deferredMods.clear() return nmods @Sheet.api def commitDeletes(self): 'Return the number of rows that have been marked for deletion. Delete the rows. Clear the marking.' ndeleted = self.deleteBy(self.isDeleted, commit=True, undo=False) if ndeleted: vd.status('deleted %s %s' % (ndeleted, self.rowtype)) return ndeleted @Sheet.api def commitAddRow(self, row): 'To commit an added row. Override per sheet type.' @Sheet.api def commitDeleteRow(self, row): 'To commit a deleted row. Override per sheet type.' # on the ColumnsSheet a row is a source column; adding/deleting marks the source modified @ColumnsSheet.before def addRow(sheet, row, index=None): row.sheet.setModified() @ColumnsSheet.api def commitDeleteRow(sheet, row): row.sheet.setModified()
[docs] @Sheet.api @asyncthread def putChanges(sheet): 'Commit changes to ``sheet.source``. May overwrite source completely without confirmation. Overridable.' sheet.commitAdds() sheet.commitMods() sheet.commitDeletes() # clear after save, to ensure cstr (in commit()) is aware of deletes sheet._deferredDels.clear()
[docs] @Sheet.api def getDeferredChanges(sheet): '''Return changes made to deferred sheets that have not been committed, as a tuple (added_rows, modified_rows, deleted_rows). *modified_rows* does not include any *added_rows* or *deleted_rows*, or changes in hidden columns. - *added_rows*: { rowid:row, ... } - *modified_rows*: { rowid: (row, { col:val, ... }), ... } - *deleted_rows*: { rowid: row } *rowid* is from ``Sheet.rowid(row)``. *col* is an actual Column object. ''' # only report mods if they aren't adds or deletes mods = {} # [rowid] -> (row, dict(col:val)) visibleCols = set(sheet.visibleCols) for row, rowmods in sheet._deferredMods.values(): rowid = sheet.rowid(row) if rowid not in sheet._deferredAdds and rowid not in sheet._deferredDels: changed = {col:val for col, val in rowmods.items() if col in visibleCols and sheet.isChanged(col, row)} if changed: mods[rowid] = (row, changed) return sheet._deferredAdds, mods, sheet._deferredDels
@Sheet.api def changestr(self, adds, mods, deletes): 'Return a str for status that outlines how many deferred changes are going to be committed.' cstr = '' if adds: cstr += 'add %d %s' % (len(adds), self.rowtype) if mods: if cstr: cstr += ' and ' cstr += 'change %d values' % sum(len(rowmods) for row, rowmods in mods.values()) if deletes: if cstr: cstr += ' and ' cstr += 'delete %d %s' % (len(deletes), self.rowtype) return cstr
[docs] @Sheet.api def commit(sheet, *rows): 'Commit all deferred changes on this sheet to original ``sheet.source``.' if not sheet.defer: vd.fail('commit-sheet is not enabled for this sheet type') adds, mods, deletes = sheet.getDeferredChanges() cstr = sheet.changestr(adds, mods, deletes) vd.confirmOverwrite(sheet.rootSheet().source, 'really ' + cstr + '? ') sheet.putChanges() sheet.hasBeenModified = False
@Sheet.api def new_rows(sheet, n): return [sheet.newRow() for i in range(n)] Sheet.addCommand('a', 'add-row', 'addRows([newRow()], index=cursorRowIndex); cursorDown(1)', 'append a blank row') Sheet.addCommand('ga', 'add-rows', 'n=int(input("add # rows: ", value=1)); addRows(new_rows(n), index=cursorRowIndex); cursorDown(1)', 'append N blank rows') Sheet.addCommand('za', 'addcol-new', 'addColumnAtCursor(SettableColumn(input("column name: ")))', 'append an empty column') Sheet.addCommand('gza', 'addcol-bulk', 'addColumnAtCursor(*(SettableColumn() for c in range(int(input("add # columns: ", value=1)))))', 'append N empty columns') Sheet.addCommand('zCtrl+S', 'commit-sheet', 'commit()', 'commit changes back to source. not undoable!') vd.addMenuItems(''' File > Save > changes to source > commit-sheet Row > Add > one row Row > Add > multiple rows Column > Add column > empty > one column > addcol-new Column > Add column > empty > multiple columns > addcol-bulk ''')