最近在做一些網絡爬蟲的時候,會經常用到正則表達式。為了寫出正確的正則表達式,我經常在這個網站上進行測試:Regex Tester。這個頁面上面一個輸入框輸入正則表達式,下面一個輸入框輸入測試數據,上面三個 checkBox 選擇匹配模式,如果匹配正確,則將測試數據中匹配上的數據高亮。是一個很方便的工具網站。
我想,要是上不去網的時候想檢測正則表達式的正確性該怎么辦?不如自己寫個小工具,無非就是一個界面,得到輸入的正則表達式和測試數據,直接調用 Python 的 re 模塊,匹配好后高亮一下就行。
最開始我准備用 wxPython 庫來制作 GUI 界面,還去網上下了個《活學活用wxPython》,大致翻了下,發現用這個實現界面的話全部要自己寫代碼,感覺這樣工作量一下就大了。某日我在使用 Python(x,y) 里面的 Spyder IDE 寫腳本的時候,發現里面有個 Qt Designer,就點開看了下,這個居然能直接拖放控件來生成一個 GUI 界面,於是果斷放棄使用 wxPython,轉投 PyQt4。
首先,我照着前面提到的網頁,大致畫了個界面,包括三個 checkBox、兩個 textEdit 和兩個 label,分別放在三個 layout 里面大概就是下面這個界面:
保存后會得到一個擴展名為 .ui 的文件。比如我得到了一個 RegexTester.ui。
然后打開 cmd 命令行,切換到當前目錄,輸入以下命令: pyuic4 -o regexTesterUi.py RegexTester.ui,回車,就能根據畫好的 ui 文件生成一個 py 文件。這時可以寫一個測試腳本來運行一下這個界面。
1 from PyQt4.QtGui import * 2 from PyQt4.QtCore import * 3 import sys 4 import regexTesterUi 5 6 class TestDialog(QDialog,regexTesterUi.Ui_Dialog): 7 def __init__(self,parent=None): 8 super(TestDialog,self).__init__(parent) 9 self.setupUi(self) 10 11 if __name__ == '__main__': 12 app=QApplication(sys.argv) 13 dialog=TestDialog() 14 dialog.show() 15 app.exec_()
運行這個腳本,我們就可以得到剛才畫的那個 GUI 界面,並且可以選中三個 checkBox ,在兩個 textEdit 里面輸入文本。只是除此之外沒有任何功能。現在界面已經做好,我們需要做的就是實現高亮匹配數據的功能。
首先我們來完善一下這個類,我們需要的變量為輸入的正則表達式、輸入的測試數據、三個匹配模式(大小寫敏感、多行匹配、點匹配所有)。
1 class RegexTesterDialog(QtGui.QDialog, regexTesterUi.Ui_Dialog): 2 3 def __init__(self, parent = None): 4 super(RegexTesterDialog, self).__init__(parent) 5 6 self.CI = False # case insensitive (i) 7 self.MB = False # ^$ match at line breaks (m) 8 self.DM = False # dot matched all (s) 9 self.regex = '' 10 self.data = '' 11 12 self.ui = regexTesterUi.Ui_Dialog() 13 self.ui.setupUi(self)
響應功能:
由於這個界面並沒有按鈕,我需要程序檢測到任何一點變動就改變高亮的部分。這里就涉及到 Qt 的信號和槽機制。本文就不復述這些知識,具體可以參考 QT的信號與槽機制介紹。這里我們就要用到 QTextEdit 控件的 textChanged() 信號函數。具體的介紹可以查看 Qt 在線文檔。這個信號函數在檢測到 QTextEdit 控件中的文本發生了變化的時候會發射一個信號,與其關聯的槽函數就會立即執行。而把這個信號函數和槽函數關聯起來的方法就是 connect() 方法。這個網上也有很多介紹,這里我來介紹一個更 pythonic 的方法,使用 Python 的裝飾器。PyQt中支持同名傳遞信號,就是說根據控件的名字來自動選擇哪個槽。比如這里提到的 textChanged() 信號函數,如果要響應這個文本變化信號,可以這么做:
@QtCore.pyqtSlot() # 該裝飾器標志此函數為接收信號的槽函數 def on_textEdit_Regex_textChanged(self): # 槽函數名標准格式 【on_控件名字_信號函數名字】 self.regex = self.ui.textEdit_Regex.toPlainText() self.ui.textEdit_Data.setText(self.regex)
這里在槽函數上面加一個裝飾器表示這個函數為接收信號的槽函數,然后根據控件名和信號函數名命名一個槽函數,這里我的接收正則表達式輸入的 QTextEdit 控件名為 textEdit_Regex,因此這個槽函數名為 on_textEdit_Regex_textChanged。在這個槽函數里,我們通過 toPlainText() 方法得到文本框中的文本數據,然后將 textEdit_Data 中的數據設置為我們輸入的值,這樣就可以測試這個槽函數運行是否正確。當測試 textEdit_Data 控件的信號和槽函數時,也可以利用 textEdit_Regex 來輸出結果。
除了 QTextEdit 控件,我們的界面還有 QCheckBox 控件。去查一下文檔,可以找到這個控件的信號函數為 stateChanged(int)。我們發現這個函數帶有一個參數,使用之前的方法會發現無法發射信號,這里我們需要在裝飾器和槽函數中加入這個參數:
@QtCore.pyqtSlot(int) def on_checkBox_CI_stateChanged(self, value): if self.ui.checkBox_CI.isChecked(): self.CI = True
self.ui.textEdit_Data.setText(‘True’) else: self.CI = False
self.ui.textEdit_Data.setText(‘False')
雖然我們不知道這個參數是什么,但只要加進來,就可以正常使用。同理,碰到需要兩個參數的信號函數時,只要再加一個參數就行。這里,當接收到 stateChanged(int) 信號時,我們使用 isChecked() 方法來檢查控件是否被選中。如果選中了,則返回真,否則返回假。這里我們同樣用到了 QTextEdit 控件來輸出結果測試信號是否正確。其他兩個 QCheckBox 控件同樣設置。
匹配功能:
完成基本的響應函數之后,就要開始實現匹配功能。這里很簡單,直接調用 re 模塊,使用 findall() 方法。由於有三個 checkBox 提供三種匹配模式:
- re.I (全拼:IGNORECASE): 忽略大小寫(括號內是完整寫法,下同)
- re.M (全拼:MULTILINE): 多行模式,改變'^'和'$'的行為
- re.S (全拼:DOTALL): 點任意匹配模式,改變'.'的行為
因此總共有 23 =8 種匹配模式:
1 def matchData(self): 2 if (not self.CI) and (not self.MB) and (not self.DM): 3 pattern = re.compile(self.regex) 4 elif (not self.CI) and (not self.MB) and (self.DM): 5 pattern = re.compile(self.regex, re.S) 6 elif (not self.CI) and (self.MB) and (not self.DM): 7 pattern = re.compile(self.regex, re.M) 8 elif (not self.CI) and (self.MB) and (self.DM): 9 pattern = re.compile(self.regex, re.M|re.S) 10 elif (self.CI) and (not self.MB) and (not self.DM): 11 pattern = re.compile(self.regex, re.I) 12 elif (self.CI) and (not self.MB) and (self.DM): 13 pattern = re.compile(self.regex, re.I|re.S) 14 elif (self.CI) and (self.MB) and (not self.DM): 15 pattern = re.compile(self.regex, re.I|re.M) 16 elif (self.CI) and (self.MB) and (self.DM): 17 pattern = re.compile(self.regex, re.I|re.M|re.S) 18 19 dataMatched = re.findall(pattern, self.data)
這里我們就可以得到匹配好的一個列表。剛開始實現這部分的時候,由於 Python 的 re 模塊接收的參數類型是 Python string,而 PyQt 中控件得到的數據是 QString,一直報錯,我一度准備使用 Qt 的 QRegExp 類來進行正則表達式匹配。但是查找文檔查了好久,只找到一個改變大小寫敏感的函數,找不到設置多行匹配和點匹配所有的方法,於是我去 stackoverflow 上問了個問題:Can QRegExp do MULTILINE and DOTALL match? 得到了一個詳細的答案,一個解決問題的簡單方法就是使用 unicode() 方法將 QString 轉換成 python string,而一般不用將 python string 轉換成 QString,因為接收 QString 類型參數的函數會自動將python string 轉換成 QString。這樣,我們直接在兩個 QTextEdit 控件的槽函數中將得到的文本數據轉換成 python string,就可以交給 re 模塊處理了。
@QtCore.pyqtSlot() def on_textEdit_Regex_textChanged(self): self.regex = unicode(self.ui.textEdit_Regex.toPlainText()) self.matchData()
通過單步調試,我們可以測試上述 dataMatched 列表中的數據是否正確,如果正確,我們就可以繼續進行下一步,實現高亮功能。
高亮功能:
去網上搜索 PyQt 高亮,可以搜到這樣一篇博文:pyqt的語法高亮實現(譯) 。這里使用到了 QSyntaxHighlighter 類。查文檔可以看到,這個類可以與 QTextEdit 控件綁定,然后重寫這個類的 highlightBlock() 函數,當 QTextEdit 控件中的文本發生變化時,會自動調用 highlightBlock() 函數,來高亮特定的文本。在上面的博文中,作者在自己定義的 MyHighlighter 類(QSyntaxHighlighter 類的子類)的初始化函數中,定義了一些規則,比如高亮一些關鍵字或數字,並將這些類型和高亮格式(顏色、加粗等)存到一個列表中,然后在重新實現的 highlightBlock() 函數遍歷這個列表,並利用 setFormat() 方法來實現高亮。根據這個思想,將自己定義的 Highlighter 類與對話框類的 textEdit_Data 對象綁定,在初始化函數中定義高亮的格式,然后在 highlightBlock() 函數中對傳進來的匹配結果進行高亮。由於 highlightBlock() 函數是對象在接收到文本變化信號后自動調用的方法,這個函數的 text 參數就是與之綁定的 textEdit_Data 對象得到的文本。這里我遍歷傳進來的匹配結果,然后在 text 中查找結果的開始位置,然后根據起始位置和匹配結果長度對匹配上的數據更改格式進行高亮。
1 class MyHighlighter(QtGui.QSyntaxHighlighter): 2 3 def __init__(self, parent) # parent即綁定的QTextEdit對象 4 QtGui.QSyntaxHighlighter.__init__(self, parent) 5 self.parent = parent 6 self.highlight_data = [] # 存儲匹配結果的列表 7 8 self.matched_format = QtGui.QTextCharFormat() # 定義高亮格式 9 brush = QtGui.QBrush(QtCore.Qt.yellow, QtCore.Qt.SolidPattern) 10 self.matched_format.setBackground(brush) 11 12 def highlightBlock(self, text): 13 index = 0 14 length = 0 15 for item in self.highlight_data: 16 index = text.indexOf(item, index + length) 17 length = len(item) 18 self.setFormat(index, length, self.matched_format) 19 20 def setHighlightData(self, highlight_data): 21 self.highlight_data = highlight_data
這里我加了個 setHighlightData(self, highlight_data),用來在得到匹配結果后將結果傳遞到這個高亮類。
這時我們需要對之前的代碼進行一些更改。
在對話框類 RegexTesterDialog 的初始化函數中加上高亮對象及它和 textEdit_Data 的綁定:
self.highlighter = MyHighlighter(self.ui.textEdit_Data)
在 matchData() 方法中加上向高亮類傳遞匹配結果:
self.highlighter.setHighlightData(dataMatched)
然后,每當槽函數執行的時候,說明數據或匹配模式發生了變化,這時需要重新匹配一次,在5個槽函數中都要調用 matchData() 方法,比如:
@QtCore.pyqtSlot(int) def on_checkBox_DM_stateChanged(self, value): if self.ui.checkBox_DM.isChecked(): self.DM = True else: self.DM = False self.matchData() @QtCore.pyqtSlot() def on_textEdit_Regex_textChanged(self): self.regex = unicode(self.ui.textEdit_Regex.toPlainText()) self.matchData()
這時候運行,發現了很多問題。當 textEdit_Data 中的數據更改時,並不能實時高亮,必須再輸入一個字母才會高亮。比如正則表達式為 'ab',輸入的數據為 'cab',當輸入 'cab' 的時候,'ab' 並不會高亮,需要再輸入一個其他的字母才能高亮。當 textEdit_Regex 中的數據更改或三個 checkBox 狀態更改時,高亮結果並不會改變。想了很久,發現問題是因為當 textEdit_Data 發射 textChanged() 信號時,高亮類的 highlightBlock() 方法會自動調用,但他高亮的匹配結果是上一次匹配結果,所以會出現延時高亮。而其他幾個槽函數執行時,高亮類並不能接收到他們的信號,所以其他的狀態改變時並不會改變高亮結果。查找文檔,發現高亮類還有個函數 rehighlightBlock(),這里的介紹是再次高亮整個文件。由於每個槽函數都調用了 matchData() 方法,我們就需要在方法結尾將匹配結果傳遞到高亮類后手動刷新一下界面,加上一個 rehighlightBlock() 方法。
dataMatched = re.findall(pattern, self.data)
self.highlighter.setHighlightData(dataMatched)
self.highlighter.rehighlight()
再次執行,發現雖然能實時高亮,但是 IDE 一直在報錯,每當我在 textEdit_Data 中輸入一個字符時,都會報一個 runtime error,大概意思是遞歸深度出錯。去網上查了下,是出現了無限遞歸,超過了 Python 默認的遞歸深度。
再次查了下文檔,並設斷點調試,發現了問題:highlightBlock() 方法會在接收到 textEdit_Data 的 textChanged() 信號時執行,同時槽函數 on_textEdit_Data_textChanged() 也會執行。而這個槽函數中又間接調用了 rehighlightBlock() 方法,當高亮匹配結果后, textEdit_Data 的文本格式發生了變化(部分數據被高亮了,雖然文本沒有變化),這時又會發射 textChanged() 信號,於是程序會在 highlightBlock() 和 rehighlightBlock() 兩個方法之間不停地遞歸,直到超過默認的遞歸深度。這里我想到的解決方法就是利用高亮后雖然格式變了但數據不變的特點,在槽函數 on_textEdit_Data_textChanged() 中加一個條件判斷:
@QtCore.pyqtSlot() def on_textEdit_Data_textChanged(self): self.data = unicode(self.ui.textEdit_Data.toPlainText()) if self.data != self.previous_data: self.previous_data = self.data self.matchData()
給對話框類加上一個成員 previous_data,來存儲 textEdit_Data 變化前的數據。初始化時都默認為空字符串。當該槽函數接收到文本變化信號時,首先判斷這個數據與之前的數據是否相等,若相等,說明只是進行了高亮,無需重新匹配;如不等,則先將新數據賦給 previous_data,再重新匹配。這樣就能解決無限遞歸問題。
這時再測試三個 checkBox 控件。前兩個運行正確,運行最后一個,也就是 DOTALL 模式出了問題。
如果我不選中 DOT ALL 選項時,由於 '.' 不會匹配換行符,所以他能正確高亮,會高亮每一行中的符合條件的數據。但是如果我選中的話,'.' 會匹配換行符,這時候一旦正則表達式中包含 '.*' 之類的東西就會匹配不上。
比如我的測試數據是:
abcd
efgh
這里是一個兩行數據。正則表達式是 'b.*',當沒選中 DOT ALL 時,他能正確高亮 'bcd',一旦選中卻不能高亮任何數據,而正確結果應該是高亮 ‘b’ 之后所有數據。
再次單步調試,測試到 matchData() 方法時發現匹配結果正確,結果中包含有換行符。而運行到 highlightBlock() 函數時,發現了問題。這個函數會被調用兩次。每一次傳進來的 text 參數只包含其中的一行數據。比如上面的例子,匹配結果是 'bcd\nefgh',而第一次調用 highlightBlock() 函數時,text 值為 'abcd',這時查找匹配結果為找不到,第二次調用時,text 值為 'efgh',同樣找不到。當時就覺得這個設定很奇葩。為啥不只調用一次,text 直接傳遞文本框中的所有文本呢?
沒辦法,設計者這么設計肯定有他的考慮,我只有在這個基礎上修改,達到能高亮結果的目的。既然他分行處理,我也可以分行高亮。先查看匹配結果中是否有換行符,如果有,使用 split('\n') 將結果分割成幾個部分,這樣這幾個部分就分別在不同行了。需要注意的是,由於函數會被調用多次,因此找不到的時候一定要將 index 值重新置 0,因為換行時匹配結果可能是從行開頭就開始高亮,若 index 不置 0,還是會出現找不到的現象。於是,高亮類的 highlightBlock() 方法變為:
def highlightBlock(self, text): index = 0 length = 0 for item in self.highlight_data: if item.count('\n') != 0: itemList = item.split('\n') for part in itemList: index = text.indexOf(part, index + length) if index == -1: index = 0 else: length = len(part) self.setFormat(index, length, self.matched_format) else: index = text.indexOf(item, index + length) length = len(item) self.setFormat(index, length, self.matched_format)
這樣修改后就能正確高亮匹配結果了。
效果如下:
整個文件的源代碼為:

1 import re 2 3 from PyQt4 import QtGui, QtCore 4 import sys 5 import regexTesterUi 6 7 8 class MyHighlighter(QtGui.QSyntaxHighlighter): 9 10 def __init__(self, parent): # parent即綁定的QTextEdit對象 11 QtGui.QSyntaxHighlighter.__init__(self, parent) 12 self.parent = parent 13 self.highlight_data = [] # 存儲匹配結果的列表 14 15 self.matched_format = QtGui.QTextCharFormat() # 定義高亮格式 16 brush = QtGui.QBrush(QtCore.Qt.yellow, QtCore.Qt.SolidPattern) 17 self.matched_format.setBackground(brush) 18 19 def highlightBlock(self, text): 20 index = 0 21 length = 0 22 for item in self.highlight_data: 23 if item.count('\n') != 0: 24 itemList = item.split('\n') 25 for part in itemList: 26 index = text.indexOf(part, index + length) 27 if index == -1: 28 index = 0 29 else: 30 length = len(part) 31 self.setFormat(index, length, self.matched_format) 32 else: 33 index = text.indexOf(item, index + length) 34 length = len(item) 35 self.setFormat(index, length, self.matched_format) 36 37 def setHighlightData(self, highlight_data): 38 self.highlight_data = highlight_data 39 40 41 class RegexTesterDialog(QtGui.QDialog, regexTesterUi.Ui_Dialog): 42 43 def __init__(self, parent = None): 44 super(RegexTesterDialog, self).__init__(parent) 45 self.CI = False # case insensitive (i) 46 self.MB = False # ^$ match at line breaks (m) 47 self.DM = False # dot matched all (s) 48 self.regex = '' 49 self.data = '' 50 self.previous_data = '' 51 self.ui = regexTesterUi.Ui_Dialog() 52 self.ui.setupUi(self) 53 self.highlighter = MyHighlighter(self.ui.textEdit_Data) 54 55 @QtCore.pyqtSlot(int) 56 def on_checkBox_CI_stateChanged(self, value): 57 if self.ui.checkBox_CI.isChecked(): 58 self.CI = True 59 else: 60 self.CI = False 61 self.matchData() 62 63 @QtCore.pyqtSlot(int) 64 def on_checkBox_MB_stateChanged(self, value): 65 if self.ui.checkBox_MB.isChecked(): 66 self.MB = True 67 else: 68 self.MB = False 69 self.matchData() 70 71 @QtCore.pyqtSlot(int) 72 def on_checkBox_DM_stateChanged(self, value): 73 if self.ui.checkBox_DM.isChecked(): 74 self.DM = True 75 else: 76 self.DM = False 77 self.matchData() 78 79 @QtCore.pyqtSlot() # 該裝飾器標志此函數為接收信號的槽函數 80 def on_textEdit_Regex_textChanged(self): # 槽函數名標准格式 【on_控件名字_信號函數名字】,表示這個函數接收該控件的信號 81 self.regex = unicode(self.ui.textEdit_Regex.toPlainText()) 82 self.matchData() 83 84 @QtCore.pyqtSlot() 85 def on_textEdit_Data_textChanged(self): 86 self.data = unicode(self.ui.textEdit_Data.toPlainText()) 87 if self.data != self.previous_data: 88 self.previous_data = self.data 89 self.matchData() 90 91 def matchData(self): 92 if (not self.CI) and (not self.MB) and (not self.DM): 93 pattern = re.compile(self.regex) 94 elif (not self.CI) and (not self.MB) and (self.DM): 95 pattern = re.compile(self.regex, re.S) 96 elif (not self.CI) and (self.MB) and (not self.DM): 97 pattern = re.compile(self.regex, re.M) 98 elif (not self.CI) and (self.MB) and (self.DM): 99 pattern = re.compile(self.regex, re.M|re.S) 100 elif (self.CI) and (not self.MB) and (not self.DM): 101 pattern = re.compile(self.regex, re.I) 102 elif (self.CI) and (not self.MB) and (self.DM): 103 pattern = re.compile(self.regex, re.I|re.S) 104 elif (self.CI) and (self.MB) and (not self.DM): 105 pattern = re.compile(self.regex, re.I|re.M) 106 elif (self.CI) and (self.MB) and (self.DM): 107 pattern = re.compile(self.regex, re.I|re.M|re.S) 108 109 dataMatched = re.findall(pattern, self.data) 110 self.highlighter.setHighlightData(dataMatched) 111 self.highlighter.rehighlight() 112 113 if __name__ == '__main__': 114 app = QtGui.QApplication(sys.argv) 115 dialog = RegexTesterDialog() 116 dialog.show() 117 sys.exit(app.exec_())
大家可以點擊 這里 從百度網盤下載這個項目,包括上面的源碼,設計的 ui 文件以及通過 ui 文件生成的 Python 代碼。
或者直接去 GitHub 上下載: PyRegexTester 。