A Validating Editable List Control Extension

Someone on StackOverflow asked about allowing only integer input, using a wx.EditableListCtrl and it occurred to me that it would be useful to not only restrict input but define allowable formats, to a degree as well.
Below is what I came up with.
Obviously it’s malleable to your own requirements, as there is no change to the underlying EditableListCtrl.
I trust someone will find it useful.
Issues on a postcard please.
Regards
Rolf

I would have posted an attachment but I’m not permitted, as I forgot my old login and had to create a new one.

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@gmx.com>
        #
        #   *** 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
        #
        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)
        
    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):
        text = self.editor.GetValue()
        swap = False
        warn = self.mixin_test["warn"]
        report = self.mixin_test["report"]
        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()