A simple GUI spreadsheet in less than 100 lines of Python

Written by

in

Original challenge: Standard library only

I set myself a little challenge to write a simple spreadsheet-like program in Python in less than 100 lines, using only the standard library.

The code in this repository manages it in 100 non-blank lines, including comments. 88 if you ignore the comments. The full code is also at the end of this post. Note: I have also redone the code in PyQT5. This is discussed after the tkinter code.

This is what the program looks like when you run it:

You can click on cells to edit their formulae and move around with the arrow keys. Formulae are evaluated once you press Return or navigate way from a cell. Every cell is evaluated as a Python expression. This means you don’t do the Excel thing of having formulae start with =. Everything is a fomula. So the formula in A2 in the screenshot above is valid and will evaluate to 1 once focus moves away from the cell. The math module is imported by default, so you can use sin and cos and so on. I use a very cheap and nasty call of eval to make it all work, which you should never do for production code.

Other than using eval, I also play a little fast and loose with the way objects are allowed to touch each other in the code. I allow each cell to access all their siblings directly instead of going through an intermediary. I probably also break a couple of other OO rules along the way with the way the cells propagate their calculations when they are changed. But the whole code only took a couple of hours to write and it actually works.

And now, here is the full code:

#!/usr/bin/env python

import tkinter as tk
import math
import re
from collections import ChainMap

Nrows = 5
Ncols = 5

cellre = re.compile(r'b[A-Z][0-9]b')


def cellname(i, j):
    return f'{chr(ord("A")+j)}{i+1}'


class Cell():
    def __init__(self, i, j, siblings, parent):
        self.row = i
        self.col = j
        self.siblings = siblings
        self.name = cellname(i, j)
        self.formula = '0'
        self.value = 0
        # Dependencies - must be updated if this cell changes
        self.deps = set()
        # Requirements - values required for this cell to calculate
        self.reqs = set()

        self.var = tk.StringVar()
        entry = self.widget = tk.Entry(parent,
                                       textvariable=self.var,
                                       justify='right')
        entry.bind('<FocusIn>', self.edit)
        entry.bind('<FocusOut>', self.update)
        entry.bind('<Return>', self.update)
        entry.bind('<Up>', self.move(-1, 0))
        entry.bind('<Down>', self.move(+1, 0))
        entry.bind('<Left>', self.move(0, -1))
        entry.bind('<Right>', self.move(0, 1))

        self.var.set(self.value)

    def move(self, rowadvance, coladvance):
        targetrow = (self.row + rowadvance) % Nrows
        targetcol = (self.col + coladvance) % Ncols

        def focus(event):
            targetwidget = self.siblings[cellname(targetrow, targetcol)].widget
            targetwidget.focus()

        return focus

    def calculate(self):
        currentreqs = set(cellre.findall(self.formula))

        # Add this cell to the new requirement's dependents
        for r in currentreqs - self.reqs:
            self.siblings[r].deps.add(self.name)
        # Add remove this cell from dependents no longer referenced
        for r in self.reqs - currentreqs:
            self.siblings[r].deps.remove(self.name)

        # Look up the values of our required cells
        reqvalues = {r: self.siblings[r].value for r in currentreqs}
        # Build an environment with these values and basic math functions
        environment = ChainMap(math.__dict__, reqvalues)
        # Note that eval is DANGEROUS and should not be used in production
        self.value = eval(self.formula, {}, environment)

        self.reqs = currentreqs
        self.var.set(self.value)

    def propagate(self):
        for d in self.deps:
            self.siblings[d].calculate()
            self.siblings[d].propagate()

    def edit(self, event):
        self.var.set(self.formula)
        self.widget.select_range(0, tk.END)

    def update(self, event):
        self.formula = self.var.get()
        self.calculate()
        self.propagate()
        # If this was after pressing Return, keep showing the formula
        if hasattr(event, 'keysym') and event.keysym == "Return":
            self.var.set(self.formula)


class SpreadSheet(tk.Frame):
    def __init__(self, rows, cols, master=None):
        super().__init__(master)
        self.rows = rows
        self.cols = cols
        self.cells = {}

        self.pack()
        self.create_widgets()

    def create_widgets(self):
        # Frame for all the cells
        self.cellframe = tk.Frame(self)
        self.cellframe.pack(side='top')

        # Column labels
        blank = tk.Label(self.cellframe)
        blank.grid(row=0, column=0)
        for j in range(self.cols):
            label = tk.Label(self.cellframe, text=chr(ord('A')+j))
            label.grid(row=0, column=j+1)

        # Fill in the rows
        for i in range(self.rows):
            rowlabel = tk.Label(self.cellframe, text=str(i + 1))
            rowlabel.grid(row=1+i, column=0)
            for j in range(self.cols):
                cell = Cell(i, j, self.cells, self.cellframe)
                self.cells[cell.name] = cell
                cell.widget.grid(row=1+i, column=1+j)


root = tk.Tk()
app = SpreadSheet(Nrows, Ncols, master=root)
app.mainloop()

Part 2: PyQT5

I posted this blog to Reddit and one of the commenters mentioned that PyQT5 has a nice table widget which is more spreadsheet-like. So I re-did the spreadsheet in PyQT5. The code is functionally similar at the moment and also has approximately the same number of lines (87 non-comment code lines). The main differences are that I could use the QTableWidget to handle all the navigation. I had to do some interesting things with delegates to enable to editing mode I wanted, though, which cost me all the lines I saved from the navigation. I think my next step is going to be to extract the calculation logic into a common class which both of these can use – there is way too much duplication going on here right now!

So, this is what the PyQT5 one looks like. It’s a little more compact, but otherwise very similar. The interaction is slightly more spreadsheet-like though, and you get automatic highlighting and selection of multiple cells for free. Very nice.

And here is the code. You will notice a great deal of similarity. The key to getting the edit mode thing to work properly is the piece of magic with SpreadSheetDelegate. I didn’t think of that myself – I stole it from this PyQT5 example, which has a much nicer spreadsheet (you can change colours, fonts, print etc) with much worse formula logic.

#!/usr/bin/env python

import re
import sys
from collections import ChainMap
import math

from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (QApplication, QMainWindow, QTableWidget,
        QTableWidgetItem, QItemDelegate, QLineEdit)


cellre = re.compile(r'b[A-Z][0-9]b')


def cellname(i, j):
    return f'{chr(ord("A")+j)}{i+1}'


class SpreadSheetDelegate(QItemDelegate):
    def __init__(self, parent=None):
        super(SpreadSheetDelegate, self).__init__(parent)

    def createEditor(self, parent, styleOption, index):
        editor = QLineEdit(parent)
        editor.editingFinished.connect(self.commitAndCloseEditor)
        return editor

    def commitAndCloseEditor(self):
        editor = self.sender()
        self.commitData.emit(editor)
        self.closeEditor.emit(editor, QItemDelegate.NoHint)

    def setEditorData(self, editor, index):
        editor.setText(index.model().data(index, Qt.EditRole))

    def setModelData(self, editor, model, index):
        model.setData(index, editor.text())


class SpreadSheetItem(QTableWidgetItem):
    def __init__(self, siblings):
        super(SpreadSheetItem, self).__init__()
        self.siblings = siblings
        self.value = 0
        self.deps = set()
        self.reqs = set()

    def formula(self):
        return super().data(Qt.DisplayRole)

    def data(self, role):
        if role == Qt.EditRole:
            return self.formula()
        if role == Qt.DisplayRole:
            return self.display()

        return super(SpreadSheetItem, self).data(role)

    def calculate(self):
        formula = self.formula()

        if formula is None or formula == '':
            self.value = 0
            return

        currentreqs = set(cellre.findall(formula))

        name = cellname(self.row(), self.column())

        # Add this cell to the new requirement's dependents
        for r in currentreqs - self.reqs:
            self.siblings[r].deps.add(name)
        # Add remove this cell from dependents no longer referenced
        for r in self.reqs - currentreqs:
            self.siblings[r].deps.remove(name)

        # Look up the values of our required cells
        reqvalues = {r: self.siblings[r].value for r in currentreqs}
        # Build an environment with these values and basic math functions
        environment = ChainMap(math.__dict__, reqvalues)
        # Note that eval is DANGEROUS and should not be used in production
        self.value = eval(formula, {}, environment)

        self.reqs = currentreqs

    def propagate(self):
        for d in self.deps:
            self.siblings[d].calculate()
            self.siblings[d].propagate()

    def display(self):
        self.calculate()
        self.propagate()
        return str(self.value)


class SpreadSheet(QMainWindow):
    def __init__(self, rows, cols, parent=None):
        super(SpreadSheet, self).__init__(parent)

        self.rows = rows
        self.cols = cols

        self.cells = {}

        self.create_widgets()

    def create_widgets(self):
        table = self.table = QTableWidget(self.rows, self.cols, self)

        headers = [chr(ord('A') + j) for j in range(self.cols)]
        table.setHorizontalHeaderLabels(headers)

        table.setItemDelegate(SpreadSheetDelegate(self))

        for i in range(self.rows):
            for j in range(self.cols):
                cell = SpreadSheetItem(self.cells)
                self.cells[cellname(i, j)] = cell
                self.table.setItem(i, j, cell)

        self.setCentralWidget(table)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    sheet = SpreadSheet(5, 5)
    sheet.resize(520, 200)
    sheet.show()
    sys.exit(app.exec_())

Comments

6 responses to “A simple GUI spreadsheet in less than 100 lines of Python”

  1. Chuipsy Avatar

    PyQT example gives this error with Python 2.7:

    line 17, return f'{chr(ord("A")+j)}{i+1}'
    ^
    SyntaxError: invalid syntax

  2. Carl Sandrock Avatar

    You will need Python 3.6+ to run this code

  3. Michael Kalango Avatar

    How can i extract the data's written in the spreadsheet and form a dictionary with it for creating a network diagram

  4. Fluffy McDeath Avatar

    return '%s%s' % (chr(ord("A")+j),(i+1))

    works for me (and makes others shudder which adds to the fun).
    Still need 3.something for ChainMap though.

  5. Mike The Watch Guy Avatar

    I think you would like PySimpleGUI. The code to make this window
    https://user-images.githubusercontent.com/46163555/68989941-29675b00-081b-11ea-94ae-fd0b4955de57.png

    Is this much when using PySimpleGUI:
    import PySimpleGUI as sg
    layout = [[sg.Text(' '*11)]+[sg.Text(s+ ' '*19) for s in 'ABCDE'] ]
    layout += [[sg.T(r)] + [sg.Input('0', justification='r', key=(r,c)) for c in range(5)] for r in range(6)]
    layout += [[sg.Button('Exit')]]

    window = sg.Window('A Table Simulation', layout, default_element_size=(12,1), element_padding=(1,1))
    event, values = window.read()

    I know your goal was to stick with the standard library. PySimpleGUI, the tkinter version, only uses the tkinter package from std lib.

  6. ProgrammingZone Avatar

    Thanks so much for this code. I am actual look to have spreadsheet added to my application and read input from the spreadsheet rather than using excel but am finding it difficult. please can your email? this is my email ibosnigltd@gmail.com. Thanks.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.