A Validating Editable List Control Extension Update

Bizarrely I appear to have lost the ability to edit a previous post.
I forgot the ability to back out of an edit in the Validating Editable List Control Extension, this update rectifies that oversight, by adding the ability to use the Escape key to do that.

import wx
import wx.lib.mixins.listctrl as listmix
import datetime

class EditableListCtrl(wx.ListCtrl, listmix.TextEditMixin):

    def __init__(self, parent, ID=wx.ID_ANY, pos=wx.DefaultPosition, size=wx.DefaultSize, style=0):
        wx.ListCtrl.__init__(self, parent, ID, pos, size, style)
        listmix.TextEditMixin.__init__(self)
        #
        # Validating Editable List Control Extension
        #
        # Author:     J Healey
        # Created:    8th October 2022
        # Email:      rolfofsaxony
        #
        #   *** Requires datetime having been imported if using "date" or "time" checks ***
        #
        # Create a base dictionary entry for each type of test to apply when a column is edited
        # This should be amended after this class has been created
        # Erroneous input can be refused, retaining the original data, with a report or silently
        # Erroneous input can be simply reported as not within parameters but retained
        #
        # Entries for integer and float, are a simple list of columns requiring those tests
        #       e.g. MyListCtrl.mixin_test["integer"] = [2,5] will perform integer tests on columns 2 and 5
        #            if they are edited
        #            MyListCtrl.mixin_test["float"] = [6] would perform a float test on column 6
        #
        # Range and Group are a dictionary of columns, each with a list of min/max or valid entries
        #       e.g. MyListCtrl.mixin_test["range"] = {4:["A","D"], 5:[1,9], 6:[1.0, 1.9]}
        #       e.g. MyListCtrl.mixin_test["group"] = {4:["A","B","E","P"]}
        #
        #       Range and Group can be integers, floats or strings, the test is adjusted by including the 
        #       column number in the "integer" or "float" tests.
        #       For strings you may add a column "case" test for "upper", "lower" etc
        #       e.g. MyListCtrl.mixin_test["integer"] = [5] combined with the range entry above would ensure
        #            an integer test for values between 1 and 9
        #       e.g. MyListCtrl.mixin_test["case"] = {4:["upper"]} combined with the range or group test above
        #            would ensure the input is converted to uppercase before the range or group test is
        #            applied
        #
        # Date is a dictionary item of columns, each with a list item containing the date format required
        #       e.g. ListCtrl_name.mixin_test["date"] = {2:['%Y/%m/%d'], 3:['%Y/%m/%d']}
        #       *** Remember %x for locale's date format ***
        #
        #       Picking the appropriate datetime format means that a date check can be used for input of
        #       Month names %B, Day names %A, Years %Y, Week numbers %W etc
        #
        # Time is a dictionary item of columns, each with a list item containing the time format required
        #       e.g. ListCtrl_name.mixin_test["time"] = {2:['%H:%M:%S'], 3:['%M:%S.%f'], 4:['%H:%M'],}
        #       *** Remember %X for locale's time format ***
        #
        #       Time may also have a null format {2:[]} using an empty list
        #       this will utilise a generic time format checking both hh:mm:ss and mm:ss (hh:mm) for a match
        #
        # Case is a dictionary item of columns, each with a list item containing a string function:
        #       "upper", "lower", "capitalize" or "title"
        #       that function will be applied to the string in the given column
        #
        # Report should be True or False allowing Reporting of Errors or silent operation
        #       report is global, not for individual columns
        #
        # Warn should be True or False
        #       warn overrides erroneous data being swapped back to the original data
        #       A warning is issued but the erroreous data is retained
        #       warn is global, not for individual columns
        #
        # Tips should be True or False
        #       if tips is True simple ToolTips are constructed depending on the test type and validating rules
        #       tips is global, not for individual columns
        #
        # Escape key will backout of an edit
        #
        self.mixin_test = {
            "integer": [],
            "float": [],
            "time": {None:[],},
            "date": {None:[],},
            "range": {None:[],},
            "group": {None:[],},
            "case": {None:[],},
            "report": True,
            "warn": False,
            "tips": True
            }

    def OpenEditor(self, col, row):        
        # Enable the editor for the column construct tooltip
        listmix.TextEditMixin.OpenEditor(self, col, row)
        self.col = col # column is used for type of validity check
        self.OrigData = self.GetItemText(row, col) # original data to swap back in case of error
        if self.mixin_test["tips"]:
            tip = self.OnSetTip(tip="")
            self.editor.SetToolTip(tip)
        self.editor.Bind(wx.EVT_KEY_DOWN, self.OnEscape)
        
    def OnEscape(self, event):
        keycode = event.GetKeyCode()
        if keycode == wx.WXK_ESCAPE:
            self.CloseEditor(event=None, swap=True)
            return
        else:
            event.Skip()
        
    def OnSetTip(self, tip=""):        
        if self.col in self.mixin_test["integer"]:
            tip += "Integer\n"
        if self.col in self.mixin_test["float"]:
            tip += "Float\n"
        if self.col in self.mixin_test["date"]:
            try:
                format = self.mixin_test["date"][self.col][0]
                format_ = datetime.datetime.today().strftime(format)
                tip += "Date format "+format_
            except Exception as e:
                tip += "Date format definition missing "+str(e)
        if self.col in self.mixin_test["time"]:
            try:
                format = self.mixin_test["time"][self.col][0]
                format_ = datetime.datetime.today().strftime(format)
                tip += "Time format "+format_
            except Exception as e:
                tip += "Time generic format hh:mm:ss or mm:ss"
        if self.col in self.mixin_test["range"]:
            try:
                r_min = self.mixin_test["range"][self.col][0]
                r_max = self.mixin_test["range"][self.col][1]
                tip += "Range - Min: "+str(r_min)+" Max: "+str(r_max)+"\n"
            except Exception as e:
                tip += "Range definition missing "+str(e)
        if self.col in self.mixin_test["group"]:
            try:
                tip += "Group: "+str(self.mixin_test["group"][self.col])+"\n"
            except Exception as e:
                tip += "Group definition missing "+str(e)
        if self.col in self.mixin_test["case"]:
            try:
                tip += "Text Case "+str(self.mixin_test["case"][self.col])
            except Exception as e:
                tip += "Case definition missing "+str(e)

        return tip

    def OnRangeCheck(self, text):
        head = mess = ""
        swap = False

        try:
            r_min = self.mixin_test["range"][self.col][0]
            r_max = self.mixin_test["range"][self.col][1]
        except Exception as e:
            head = "Range Missing - Error"
            mess = "Error: "+str(e)+"\n"
            swap = True
            return head, mess, swap

        try:
            if self.col in self.mixin_test["float"]:
                item = float(text)
                head = "Float Range Test - Error"
            elif self.col in self.mixin_test["integer"]:
                item = int(text)
                head = "Integer Range Test - Error"
            else:
                item = text
                head = "Text Range Test - Error"
            if item < r_min or item > r_max:
                mess += text+" Out of Range: Min - "+str(r_min)+" Max - "+str(r_max)+"\n"
                swap = True
        except Exception as e:
            head = "Range Test - Error"
            mess += "Error: "+str(e)+"\n"
            swap = True

        return head, mess, swap

    def OnDateCheck(self, text):
        head = mess = ""
        swap = False

        try:
            format = self.mixin_test["date"][self.col][0]
        except Exception as e:
            head = "Date Format Missing - Error"
            mess = "Error: "+str(e)+"\n"
            swap = True
            return head, mess, swap

        try:
            datetime.datetime.strptime(text, format)
        except Exception as e:
            format_ = datetime.datetime.today().strftime(format)
            head = "Date Test - Error"                
            mess = text+" does not match format "+format_+"\n"
            swap = True    

        return head, mess, swap

    def OnTimeCheck(self, text):
        head = mess = ""
        swap = False

        try:
            format = self.mixin_test["time"][self.col][0]
        except Exception as e:
            try:
                datetime.datetime.strptime(text, '%H:%M:%S')
            except Exception as e:
                try:
                    datetime.datetime.strptime(text, '%M:%S')
                except:
                    head = "Time Test - Error"
                    mess = "Generic Time format must be hh:mm:ss or mm:ss\n"
                    swap = True
            return head, mess, swap

        try:
            datetime.datetime.strptime(text, format)
        except Exception as e:
            format_ = datetime.datetime.today().strftime(format)
            head = "Time Test - Error"                
            mess = text+" does not match format "+format_+"\n"
            swap = True        

        return head, mess, swap

    def OnCaseCheck(self, text):
        try:
            format = self.mixin_test["case"][self.col][0]
        except:
            format = None
        if format == "upper":
            text = text.upper() 
        if format == "lower":
            text = text.lower()
        if format == "capitalize":
            text = text.capitalize()
        if format == "title":
            text = text.title()
        self.editor.SetValue(text)

        return text

    def OnGroupCheck(self, text):
        head = mess = ""
        swap = False

        try:
            tests = self.mixin_test["group"][self.col]
        except Exception as e:
            head = "Group Missing - Error"
            mess = "Error: "+str(e)+"\n"
            swap = True
            return head, mess, swap

        if text in tests:
            pass
        else:
            head = "Group Test - Error"
            mess = text+" Not in Group: "+str(tests)+"\n"
            swap = True        

        return head, mess, swap

    def CloseEditor(self, event=None, swap=False):
        text = self.editor.GetValue()
        warn = self.mixin_test["warn"]
        report = self.mixin_test["report"]
        if swap:
            self.editor.Hide()        
            listmix.TextEditMixin.CloseEditor(self, event)
            return
        if warn:
            report = False
        #  Integer Check
        if self.col in self.mixin_test["integer"]:
            try:
                int(text)
            except Exception as e:
                head = "Integer Test - Error"
                mess = "Not Integer\n"
                swap = True
        #  Float Check
        if self.col in self.mixin_test["float"]:
            try:
                float(text)
            except Exception as e:
                head = "Float Test - Error"
                mess = str(e)+"\n"
                swap = True
        # Time check
        if self.col in self.mixin_test["time"]:
            head, mess, swap = self.OnTimeCheck(text)

        #  Date check
        if self.col in self.mixin_test["date"]:
            head, mess, swap = self.OnDateCheck(text)

        #  Case check
        if self.col in self.mixin_test["case"]:
            text = self.OnCaseCheck(text)

        # Range check
        if not swap and self.col in self.mixin_test["range"]:
            head, mess, swap = self.OnRangeCheck(text)

        # Group check
        if not swap and self.col in self.mixin_test["group"]:
            head, mess, swap = self.OnGroupCheck(text)

        if warn and swap: 
            wx.MessageBox(mess + 'Invalid entry: ' + text + "\n", \
                          head, wx.OK | wx.ICON_ERROR)
        elif swap: #  Invalid data error swap back original data
            self.editor.SetValue(self.OrigData)
            if report:
                wx.MessageBox(mess + 'Invalid entry: ' + text + "\nResetting to "+str(self.OrigData), \
                              head, wx.OK | wx.ICON_ERROR)

        listmix.TextEditMixin.CloseEditor(self, event)

        
class MyPanel(wx.Panel):
    def __init__(self, parent):
        wx.Panel.__init__(self, parent)

        rows = [("Ford", "Taurus", "1996/01/01", "Blue", "C", "1", "1.1", "12:32", "M"),
                ("Nissan", "370Z", "2010/11/22", "Green", "B", "2", "1.8", "10:10", "F"),
                ("Porche", "911", "2009/02/28", "Red", "A", "1", "1.3", "23:44", "F")
                ]

        self.list_ctrl = EditableListCtrl(self, style=wx.LC_REPORT)
        self.list_ctrl.InsertColumn(0, "Make")
        self.list_ctrl.InsertColumn(1, "Model")
        self.list_ctrl.InsertColumn(2, "Date*")
        self.list_ctrl.InsertColumn(3, "Text*")
        self.list_ctrl.InsertColumn(4, "Range*")
        self.list_ctrl.InsertColumn(5, "Integer*")
        self.list_ctrl.InsertColumn(6, "Float*")
        self.list_ctrl.InsertColumn(7, "Time*")
        self.list_ctrl.InsertColumn(8, "Group*")
        index = 0
        for row in rows:
            self.list_ctrl.InsertItem(index, row[0])
            self.list_ctrl.SetItem(index, 1, row[1])
            self.list_ctrl.SetItem(index, 2, row[2])
            self.list_ctrl.SetItem(index, 3, row[3])
            self.list_ctrl.SetItem(index, 4, row[4])
            self.list_ctrl.SetItem(index, 5, row[5])
            self.list_ctrl.SetItem(index, 6, row[6])
            self.list_ctrl.SetItem(index, 7, row[7])
            self.list_ctrl.SetItem(index, 8, row[8])
            index += 1
        sizer = wx.BoxSizer(wx.VERTICAL)
        sizer.Add(self.list_ctrl, 0, wx.ALL|wx.EXPAND, 5)
        self.SetSizer(sizer)
        self.list_ctrl.Bind(wx.EVT_LIST_BEGIN_LABEL_EDIT, self.OnVetoItems)

        # Here we note columns that require validation for the editablelistctrl
        #
        # column 2 should be a date with the format yyyy/mm/dd
        # column 3 should be capitalised
        # column 4 should be in a range from "A" to "D" and uppercase
        # column 5 should be in a integer range from 1 to 9
        # column 6 should be in a float range from 1.0 to 1.9
        # column 7 should be in a time, format hh:mm
        # column 8 should be in a group the M or F and upper case        
        self.list_ctrl.mixin_test["date"]      = {2:['%Y/%m/%d']}
        self.list_ctrl.mixin_test["integer"]   = [5]
        self.list_ctrl.mixin_test["float"]     = [6]
        self.list_ctrl.mixin_test["case"]      = {3:["capitalize"], 4:["upper"], 8:["upper"]}
        self.list_ctrl.mixin_test["range"]     = {4:["A","D"], 5:[1,9], 6:[1.0, 1.9]}
        self.list_ctrl.mixin_test["time"]      = {7:['%H:%M']}
        self.list_ctrl.mixin_test["group"]     = {8:["M","F"]}
        # This would be column 7 with a time format including micro seconds 
        #self.list_ctrl.mixin_test["time"]     = {7:['%M:%S.%f']}
        # This would be column 7 with a time format hh:mm:ss 
        #self.list_ctrl.mixin_test["time"]     = {7:['%H:%M:%S']}
        # This would be column 7 with a generic time format of either hh:mm:ss, hh:mm or mm:ss
        #self.list_ctrl.mixin_test["time"]     = {7:[]}
        self.list_ctrl.mixin_test["report"] = True
        #self.list_ctrl.mixin_test["warn"] = False
        #self.list_ctrl.mixin_test["tips"] = False

    def OnVetoItems(self, event):
        #  Enable editing only for columns 2 and above
        if event.Column < 2:
            event.Veto()
            return    

class MyFrame(wx.Frame):
    def __init__(self):
        wx.Frame.__init__(self, None, wx.ID_ANY, "Validating Editable List Control", size=(750, -1))
        panel = MyPanel(self)
        self.Show()

if __name__ == "__main__":
    app = wx.App(False)
    frame = MyFrame()
    app.MainLoop()