11
\$\begingroup\$

(NOTE: Python2 only) It is part of a separate application that allows users to interact very loosely with different databases and check for possible errors and make corrections.

try: 
    import Tkinter as tk
except ImportError:
    import tkinter as tk
import tkFont

import pandas

class EditorApp:

    def __init__(self, master, dataframe, edit_rows=[]):
        """ master    : tK parent widget
        dataframe : pandas.DataFrame object"""
        self.root = master
        self.root.minsize(width=600, height=400)
        self.root.title('database editor')

        self.main = tk.Frame(self.root)
        self.main.pack(fill=tk.BOTH, expand=True)

        self.lab_opt = {'background': 'darkgreen', 'foreground': 'white'}

#       the dataframe
        self.df = dataframe
        self.dat_cols = list(self.df)
        if edit_rows:
            self.dat_rows = edit_rows
        else:
            self.dat_rows = range(len(self.df))
        self.rowmap = {i: row for i, row in enumerate(self.dat_rows)}

#       subset the data and convert to giant list of strings (rows) for viewing
        self.sub_data = self.df.ix[self.dat_rows, self.dat_cols]
        self.sub_datstring = self.sub_data.to_string(
            index=False, col_space=13).split('\n')
        self.title_string = self.sub_datstring[0]

# save the format of the lines, so we can update them without re-running
# df.to_string()
        self._get_line_format(self.title_string)

#       fill in the main frame
        self._fill()

#       updater for tracking changes to the database
        self.update_history = []

##################
# ADDING WIDGETS #
##################
    def _fill(self):
        self.canvas = tk.Canvas(self.main)
        self.canvas.pack(fill=tk.BOTH, expand=tk.YES)

        self._init_scroll()
        self._init_lb()
        self._pack_config_scroll()
        self._pack_bind_lb()
        self._fill_listbox()
        self._make_editor_frame()
        self._sel_mode()

##############
# SCROLLBARS #
##############
    def _init_scroll(self):
        self.scrollbar = tk.Scrollbar(self.canvas, orient="vertical")
        self.xscrollbar = tk.Scrollbar(self.canvas, orient="horizontal")

    def _pack_config_scroll(self):
        self.scrollbar.config(command=self.lb.yview)
        self.xscrollbar.config(command=self._xview)
        self.scrollbar.pack(side="right", fill="y")
        self.xscrollbar.pack(side="bottom", fill="x")

    def _onMouseWheel(self, event):
        self.title_lb.yview("scroll", event.delta, "units")
        self.lb.yview("scroll", event.delta, "units")
        return "break"

    def _xview(self, *args):
        """connect the yview action together"""
        self.lb.xview(*args)
        self.title_lb.xview(*args)

################
# MAIN LISTBOX #
################
    def _init_lb(self):
        self.title_lb = tk.Listbox(self.canvas, height=1,
                                   font=tkFont.Font(self.canvas,
                                                    family="Courier",
                                                    size=14),
                                   yscrollcommand=self.scrollbar.set,
                                   xscrollcommand=self.xscrollbar.set,
                                   exportselection=False)

        self.lb = tk.Listbox(self.canvas,
                             font=tkFont.Font(self.canvas,
                                              family="Courier",
                                              size=14),
                             yscrollcommand=self.scrollbar.set,
                             xscrollcommand=self.xscrollbar.set,
                             exportselection=False,
                             selectmode=tk.EXTENDED)

    def _pack_bind_lb(self):
        self.title_lb.pack(fill=tk.X)
        self.lb.pack(fill="both", expand=True)
        self.title_lb.bind("<MouseWheel>", self._onMouseWheel)
        self.lb.bind("<MouseWheel>", self._onMouseWheel)

    def _fill_listbox(self):
        """ fill the listbox with rows from the dataframe"""
        self.title_lb.insert(tk.END, self.title_string)
        for line in self.sub_datstring[1:]:
            self.lb.insert(tk.END, line)
            self.lb.bind('<ButtonRelease-1>', self._listbox_callback)
        self.lb.select_set(0)

    def _listbox_callback(self, event):
        """ when a listbox item is selected"""
        items = self.lb.curselection()
        if items:
            new_item = items[-1]
            dataVal = str(
                self.df.ix[
                    self.rowmap[new_item],
                    self.opt_var.get()])
            self.entry_box_old.config(state=tk.NORMAL)
            self.entry_box_old.delete(0, tk.END)
            self.entry_box_old.insert(0, dataVal)
            self.entry_box_old.config(state=tk.DISABLED)

#####################
# FRAME FOR EDITING #
#####################
    def _make_editor_frame(self):
        """ make a frame for editing dataframe rows"""
        self.editorFrame = tk.Frame(
            self.main, bd=2, padx=2, pady=2, relief=tk.GROOVE)
        self.editorFrame.pack(fill=tk.BOTH, side=tk.LEFT)

#       column editor
        self.col_sel_lab = tk.Label(
            self.editorFrame,
            text='Select a column to edit:',
            **self.lab_opt)
        self.col_sel_lab.grid(row=0, columnspan=2, sticky=tk.W + tk.E)

        self.opt_var = tk.StringVar()
        self.opt_var.set(self.dat_cols[0])
        self.opt = tk.OptionMenu(
            self.editorFrame,
            self.opt_var,
            *
            list(
                self.df))
        self.opt.grid(row=0, columnspan=2, column=2, sticky=tk.E + tk.W)

        self.old_val_lab = tk.Label(
            self.editorFrame,
            text='Old value:',
            **self.lab_opt)
        self.old_val_lab.grid(row=1, sticky=tk.W, column=0)
        self.entry_box_old = tk.Entry(
            self.editorFrame,
            state=tk.DISABLED,
            bd=2,
            relief=tk.GROOVE)
        self.entry_box_old.grid(row=1, column=1, sticky=tk.E)

#       entry widget
        self.new_val_lab = tk.Label(
            self.editorFrame,
            text='New value:',
            **self.lab_opt)
        self.new_val_lab.grid(row=1, sticky=tk.E, column=2)
        self.entry_box_new = tk.Entry(self.editorFrame, bd=2, relief=tk.GROOVE)
        self.entry_box_new.grid(row=1, column=3, sticky=tk.E + tk.W)

#       make update button
        self.update_b = tk.Button(
            self.editorFrame,
            text='Update selection',
            relief=tk.RAISED,
            command=self._updateDF_multi)
        self.update_b.grid(row=2, columnspan=1, column=3, sticky=tk.W + tk.E)

#       make undo button
        self.undo_b = tk.Button(
            self.editorFrame,
            text='Undo',
            command=self._undo)
        self.undo_b.grid(row=2, columnspan=1, column=1, sticky=tk.W + tk.E)

################
# SELECT MODES #
################
    def _sel_mode(self):
        """ creates a frame for toggling between interaction modes wt"""
        self.mode_frame = tk.Frame(
            self.main, bd=2, padx=2, pady=2, relief=tk.GROOVE)
        self.mode_frame.pack(fill=tk.BOTH, side=tk.LEFT)

        tk.Label(self.mode_frame, text='Selection mode', **
                 self.lab_opt).pack(fill=tk.BOTH, expand=tk.YES)

        self.mode_lb = tk.Listbox(
            self.mode_frame,
            height=2,
            width=16,
            exportselection=False)
        self.mode_lb.pack(fill=tk.BOTH, expand=tk.YES)
        self.mode_lb.insert(tk.END, 'Multiple selection')
        self.mode_lb.bind('<ButtonRelease-1>', self._mode_lb_callback)
        self.mode_lb.insert(tk.END, 'Find and replace')
        self.mode_lb.bind('<ButtonRelease-1>', self._mode_lb_callback)
        self.mode_lb.select_set(0)

    def _mode_lb_callback(self, event):
        items = self.mode_lb.curselection()
        if items[0] == 0:
            self._swap_mode('multi')
        elif items[0] == 1:
            self._swap_mode('findrep')

    def _swap_mode(self, mode='multi'):
        """swap between modes of interaction with database"""
        self.lb.selection_clear(0, tk.END)
        self._swap_lab(mode)
        if mode == 'multi':
            self.lb.config(state=tk.NORMAL)
            self.entry_box_old.config(state=tk.DISABLED)
            self.update_b.config(
                command=self._updateDF_multi,
                text='Update multi selection')
        elif mode == 'findrep':
            self.lb.config(state=tk.DISABLED)
            self.entry_box_old.config(state=tk.NORMAL)
            self.update_b.config(
                command=self._updateDF_findrep,
                text='Find and replace')
        self.entry_box_new.delete(0, tk.END)
        self.entry_box_new.insert(0, "Enter new value")

    def _swap_lab(self, mode='multi'):
        """ alter the labels on the editor frame"""
        if mode == 'multi':
            self.old_val_lab.config(text='Old value:')
            self.new_val_lab.config(text='New value:')
        elif mode == 'findrep':
            self.old_val_lab.config(text='Find:')
            self.new_val_lab.config(text='Replace:')

#################
# EDIT COMMANDS #
#################
    def _updateDF_multi(self):
        """ command for updating via selection"""
        self.col = self.opt_var.get()
        items = self.lb.curselection()
        self._track_items(items)

    def _updateDF_findrep(self):
        """ command for updating via find/replace"""
        self.col = self.opt_var.get()
        old_val = self.entry_box_old.get()
        try:
            items = pandas.np.where(
                self.sub_data[
                    self.col].astype(str) == old_val)[0]
        except TypeError as err:
            self.errmsg(
                '%s: `%s` for column `%s`!' %
                (err, str(old_val), self.col))
            return
        if not items.size:
            self.errmsg(
                'Value`%s` not found in column `%s`!' %
                (str(old_val), self.col))
            return
        else:
            self._track_items(items)
            self.lb.config(state=tk.DISABLED)

    def _undo(self):
        if self.update_history:
            updated_vals = self.update_history.pop()
            for idx, val in updated_vals['vals'].items():
                self.row = self.rowmap[idx]
                self.idx = idx
                self.df.set_value(self.row, updated_vals['col'], val)
                self._rewrite()
            self.sync_subdata()

####################
# HISTORY TRACKING #
####################
    def _track_items(self, items):
        """ this strings several functions together,
        updates database, tracks changes, and updates database viewer"""
        self._init_hist_tracker()
        for i in items:
            self.idx = i
            self.row = self.rowmap[i]
            self._track()
            self._setval()
            self._rewrite()
        self._update_hist_tracker()
#       update sub_data used w find and replace
        self.sync_subdata()

    def _setval(self):
        """ update database"""
        try:
            self.df.set_value(self.row, self.col, self.entry_box_new.get())
        except ValueError:
            self.errmsg(
                'Invalid entry `%s` for column `%s`!' %
                (self.entry_box_new.get(), self.col))

    def _init_hist_tracker(self):
        """ prepare to track a changes to the database"""
        self.prev_vals = {}
        self.prev_vals['col'] = self.col
        self.prev_vals['vals'] = {}

    def _track(self):
        """record a change to the database"""
        self.prev_vals['vals'][self.idx] = str(self.df.ix[self.row, self.col])

    def _update_hist_tracker(self):
        """ record latest changes to database"""
        self.update_history.append(self.prev_vals)

    def sync_subdata(self):
        """ syncs subdata with data"""
        self.sub_data = self.df.ix[self.dat_rows, self.dat_cols]

#################
# ERROR MESSAGE #
#################
    def errmsg(self, message):
        """ opens a simple error message"""
        errWin = tk.Toplevel()
        tk.Label(
            errWin,
            text=message,
            foreground='white',
            background='red').pack()
        tk.Button(errWin, text='Ok', command=errWin.destroy).pack()

##################
# UPDATING LINES #
##################
    def _rewrite(self):
        """ re-writing the dataframe string in the listbox"""
        new_col_vals = self.df.ix[self.row, self.dat_cols].astype(str).tolist()
        new_line = self._make_line(new_col_vals)
        if self.lb.cget('state') == tk.DISABLED:
            self.lb.config(state=tk.NORMAL)
            self.lb.delete(self.idx)
            self.lb.insert(self.idx, new_line)
            self.lb.config(state=tk.DISABLED)
        else:
            self.lb.delete(self.idx)
            self.lb.insert(self.idx, new_line)

    def _get_line_format(self, line):
        """ save the format of the title string, stores positions
            of the column breaks"""
        pos = [1 + line.find(' ' + n) + len(n) for n in self.dat_cols]
        self.entry_length = [pos[0]] + \
            [p2 - p1 for p1, p2 in zip(pos[:-1], pos[1:])]

    def _make_line(self, col_entries):
        """ add a new line to the database in the correct format"""
        new_line_entries = [('{0: >%d}' % self.entry_length[i]).format(entry)
                            for i, entry in enumerate(col_entries)]
        new_line = "".join(new_line_entries)
        return new_line


def main():
    #   make a test dataframe here of integers, can be anything really
    df = pandas.DataFrame(
        pandas.np.random.randint(
            0, 100, (1000, 20)), columns=[
            'col_%d' %
            x for x in xrange(20)])

#   start
    root = tk.Tk()
    editor = EditorApp(root, df)
    root.mainloop()  # until closes window

#   re-assign dataframe
    new_df = editor.df

    print "THIS IS THE NEW DATABASE:"
    print new_df.to_string(index=False)

if __name__ == '__main__':
    main()

I feel the way I am displaying the data with a tk.Listbox widget is actually quite inefficient. I have a separate string representation for each line, and I update that line each time the DataFrame is updated. I have read that Tkinter listboxes can be slow when working with large amounts of data. I have also considered the treeview widget, but it seems to have a lot of functionality I won't use for database viewing.

What are some ways I might improve performance, namely speed, when altering the dataframe representation within the widget?

\$\endgroup\$

1 Answer 1

3
\$\begingroup\$

After starting the application for the first time it looks quite nice, the code is readable even though it has some uncommon formatting.

For the UI, the first question that comes to my mind is: Why not use a table widget? Intuitively that is a better fit for ... tabular data than any other widget. That ties into the edit model: Selecting a row and then having to manually choose the column from a drop down is uncomfortable and takes too much time.

The undo button could do with a disabled state if there's no more undo available.

The performance doesn't look completely bad, though it is very noticeable with edit and undo operations. It depends on how much you want to use this productively. Using a better widget or less wasteful update operations come to mind.

Code

Formatting isn't quite PEP8 compatible, but it seems somewhat consistent. Just note that people might object to it.

The names are quite short in many cases, making it harder to discern what it relates to. E.g. lb and errmsg.

If possible you should also try to make the code compatible with Python 3. In particular the print function should be used, i.e. print("THIS ...").

The mode swapping operation and comparisons could more readibly done with separate classes for the two modes, or at least with an enumeration object instead of comparing strings inline (which will be a problem once you add a new value or decide to change the names).

The prev_vals member can be initialised inline with

self.prev_cals = {
    "col": self.col,
    "vals": {}
}

, which means less things to read. I'd recommend to follow the literal style all the time unless there's a pressing need not to do it.

I have a whole number of other minor things as well, but I'd maybe start with the bigger issues above before improving the small stuff.

Suggestions

If you're looking for more functional improvements, I'd recommend using some other export functionality for the data frame than printing it, e.g. CSV export, or something similar, possibly using a button on the UI with file selection etc.

You could also package this so users of the library could edit a data frame in their application with a single function invocation - a functionality that seems useful in some scripts, or so.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.