最近在做一些网络爬虫的时候,会经常用到正则表达式。为了写出正确的正则表达式,我经常在这个网站上进行测试: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 文件。这时可以写一个测试脚本来运行一下这个界面。
from PyQt4.QtGui import *
from PyQt4.QtCore import *
import sys
import regexTesterUi class TestDialog(QDialog,regexTesterUi.Ui_Dialog):
def __init__(self,parent=None):
super(TestDialog,self).__init__(parent)
self.setupUi(self) if __name__ == '__main__':
app=QApplication(sys.argv)
dialog=TestDialog()
dialog.show()
app.exec_()
运行这个脚本,我们就可以得到刚才画的那个 GUI 界面,并且可以选中三个 checkBox ,在两个 textEdit 里面输入文本。只是除此之外没有任何功能。现在界面已经做好,我们需要做的就是实现高亮匹配数据的功能。
首先我们来完善一下这个类,我们需要的变量为输入的正则表达式、输入的测试数据、三个匹配模式(大小写敏感、多行匹配、点匹配所有)。
class RegexTesterDialog(QtGui.QDialog, regexTesterUi.Ui_Dialog): def __init__(self, parent = None):
super(RegexTesterDialog, self).__init__(parent) self.CI = False # case insensitive (i)
self.MB = False # ^$ match at line breaks (m)
self.DM = False # dot matched all (s)
self.regex = ''
self.data = '' self.ui = regexTesterUi.Ui_Dialog()
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 种匹配模式:
def matchData(self):
if (not self.CI) and (not self.MB) and (not self.DM):
pattern = re.compile(self.regex)
elif (not self.CI) and (not self.MB) and (self.DM):
pattern = re.compile(self.regex, re.S)
elif (not self.CI) and (self.MB) and (not self.DM):
pattern = re.compile(self.regex, re.M)
elif (not self.CI) and (self.MB) and (self.DM):
pattern = re.compile(self.regex, re.M|re.S)
elif (self.CI) and (not self.MB) and (not self.DM):
pattern = re.compile(self.regex, re.I)
elif (self.CI) and (not self.MB) and (self.DM):
pattern = re.compile(self.regex, re.I|re.S)
elif (self.CI) and (self.MB) and (not self.DM):
pattern = re.compile(self.regex, re.I|re.M)
elif (self.CI) and (self.MB) and (self.DM):
pattern = re.compile(self.regex, re.I|re.M|re.S) dataMatched = re.findall(pattern, self.data)
这里我们就可以得到匹配好的一个列表。刚开始实现这部分的时候,由于 Python 的 re 模块接收的参数类型是 Python string,而 PyQt 中控件得到的数据是 QString,一直报错,我一度准备使用 Qt 的 QRegExp 类来进行正则表达式匹配。但是查找文档查了好久,只找到一个改变大小写敏感的函数,找不到设置多行匹配和点匹配所有的方法,于是我去 * 上问了个问题: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 中查找结果的开始位置,然后根据起始位置和匹配结果长度对匹配上的数据更改格式进行高亮。
class MyHighlighter(QtGui.QSyntaxHighlighter): def __init__(self, parent) # parent即绑定的QTextEdit对象
QtGui.QSyntaxHighlighter.__init__(self, parent)
self.parent = parent
self.highlight_data = [] # 存储匹配结果的列表 self.matched_format = QtGui.QTextCharFormat() # 定义高亮格式
brush = QtGui.QBrush(QtCore.Qt.yellow, QtCore.Qt.SolidPattern)
self.matched_format.setBackground(brush) def highlightBlock(self, text):
index = 0
length = 0
for item in self.highlight_data:
index = text.indexOf(item, index + length)
length = len(item)
self.setFormat(index, length, self.matched_format) def setHighlightData(self, highlight_data):
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)
这样修改后就能正确高亮匹配结果了。
效果如下:
整个文件的源代码为:
import re from PyQt4 import QtGui, QtCore
import sys
import regexTesterUi class MyHighlighter(QtGui.QSyntaxHighlighter): def __init__(self, parent): # parent即绑定的QTextEdit对象
QtGui.QSyntaxHighlighter.__init__(self, parent)
self.parent = parent
self.highlight_data = [] # 存储匹配结果的列表 self.matched_format = QtGui.QTextCharFormat() # 定义高亮格式
brush = QtGui.QBrush(QtCore.Qt.yellow, QtCore.Qt.SolidPattern)
self.matched_format.setBackground(brush) 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) def setHighlightData(self, highlight_data):
self.highlight_data = highlight_data class RegexTesterDialog(QtGui.QDialog, regexTesterUi.Ui_Dialog): def __init__(self, parent = None):
super(RegexTesterDialog, self).__init__(parent)
self.CI = False # case insensitive (i)
self.MB = False # ^$ match at line breaks (m)
self.DM = False # dot matched all (s)
self.regex = ''
self.data = ''
self.previous_data = ''
self.ui = regexTesterUi.Ui_Dialog()
self.ui.setupUi(self)
self.highlighter = MyHighlighter(self.ui.textEdit_Data) @QtCore.pyqtSlot(int)
def on_checkBox_CI_stateChanged(self, value):
if self.ui.checkBox_CI.isChecked():
self.CI = True
else:
self.CI = False
self.matchData() @QtCore.pyqtSlot(int)
def on_checkBox_MB_stateChanged(self, value):
if self.ui.checkBox_MB.isChecked():
self.MB = True
else:
self.MB = False
self.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): # 槽函数名标准格式 【on_控件名字_信号函数名字】,表示这个函数接收该控件的信号
self.regex = unicode(self.ui.textEdit_Regex.toPlainText())
self.matchData() @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() def matchData(self):
if (not self.CI) and (not self.MB) and (not self.DM):
pattern = re.compile(self.regex)
elif (not self.CI) and (not self.MB) and (self.DM):
pattern = re.compile(self.regex, re.S)
elif (not self.CI) and (self.MB) and (not self.DM):
pattern = re.compile(self.regex, re.M)
elif (not self.CI) and (self.MB) and (self.DM):
pattern = re.compile(self.regex, re.M|re.S)
elif (self.CI) and (not self.MB) and (not self.DM):
pattern = re.compile(self.regex, re.I)
elif (self.CI) and (not self.MB) and (self.DM):
pattern = re.compile(self.regex, re.I|re.S)
elif (self.CI) and (self.MB) and (not self.DM):
pattern = re.compile(self.regex, re.I|re.M)
elif (self.CI) and (self.MB) and (self.DM):
pattern = re.compile(self.regex, re.I|re.M|re.S) dataMatched = re.findall(pattern, self.data)
self.highlighter.setHighlightData(dataMatched)
self.highlighter.rehighlight() if __name__ == '__main__':
app = QtGui.QApplication(sys.argv)
dialog = RegexTesterDialog()
dialog.show()
sys.exit(app.exec_())
大家可以点击 这里 从百度网盘下载这个项目,包括上面的源码,设计的 ui 文件以及通过 ui 文件生成的 Python 代码。
或者直接去 GitHub 上下载: PyRegexTester 。