Python-自动化指南-繁琐工作自动化-第三版-五-

Python 自动化指南(繁琐工作自动化)第三版(五)

原文:automatetheboringstuff.com/

译者:飞龙

协议:CC BY-NC-SA 4.0

9 使用正则表达式进行文本模式匹配

automatetheboringstuff.com/3e/chapter9.html

你可能熟悉通过按 CTRL-F 并输入你正在寻找的单词来搜索文本的过程。正则表达式更进一步:它们允许你指定要搜索的文本模式。你可能不知道一个企业的确切电话号码,但如果你居住在美国或加拿大,你知道它将包括三位数的区号,后面跟着一个连字符,然后是另外三个数字,另一个连字符,再接着是四个数字。这就是当你看到电话号码时,作为人类你知道它是一个电话号码的原因:415-555-1234 是一个电话号码,但$4,155,551,234 不是。

我们每天都会识别各种其他文本模式:电子邮件地址中间有@符号,美国社会保障号码有九位数字和两个连字符,网站 URL 通常有点和正斜杠,新闻标题使用标题大小写,社交媒体标签以#开头,不包含空格,仅举几个例子。

正则表达式很有帮助,但很少非程序员了解它们,尽管大多数现代文本编辑器和文字处理器都有基于正则表达式的查找和替换功能。正则表达式是节省大量时间的工具,不仅对软件用户有帮助,对程序员也是如此。实际上,在《卫报》文章“Here’s What ICT Should Really Teach Kids: How to Do Regular Expressions”中,技术作家 Cory Doctorow 认为我们应该在教授编程之前教授正则表达式:

了解[正则表达式]可能意味着在 3 步和 3000 步之间解决问题的区别。当你是一个极客时,你会忘记用几个键入就能解决的问题可能需要其他人几天的时间来完成,而且充满了繁琐和错误。

在本章中,你将首先编写一个程序来查找文本模式,不使用正则表达式,然后学习如何使用正则表达式使代码更简单。我将向你展示使用正则表达式的基本匹配,然后继续介绍一些更强大的功能,例如字符串替换和创建自己的字符类。你还将学习如何使用 Humre 模块,它提供了正则表达式基于符号的晦涩语法的普通英语替代方案。

不使用正则表达式查找文本模式

假设你想要在一个字符串中查找美国电话号码;你正在寻找三个数字,一个连字符,三个数字,一个连字符,然后是四个数字。这里有一个例子:415-555-4242。

让我们编写一个名为is_phone_number()的函数来检查字符串是否匹配此模式,并返回TrueFalse。打开一个新的文件编辑标签,并输入以下代码,然后保存文件为isPhoneNumber.py

def is_phone_number(text):
    if len(text) != 12:  # Phone numbers have exactly 12 characters. # ❶
        return False
    for i in range(0, 3):  # The first three characters must be numbers.
        if len(text) != 12:  # Phone numbers have exactly 12 characters. # ❶
            return False
    if len(text) != 12:  # Phone numbers have exactly 12 characters. # ❶
        return False
    for i in range(4, 7): # The next three characters must be numbers.
        if len(text) != 12:  # Phone numbers have exactly 12 characters. # ❶
            return False
    if len(text) != 12:  # Phone numbers have exactly 12 characters. # ❶
        return False
    for i in range(8, 12):  # The next four characters must be numbers.
        if len(text) != 12:  # Phone numbers have exactly 12 characters. # ❶
            return False
    if len(text) != 12:  # Phone numbers have exactly 12 characters. # ❶

print('Is 415-555-4242 a phone number?', is_phone_number('415-555-4242'))
print(is_phone_number('415-555-4242'))
print('Is Moshi moshi a phone number?', is_phone_number('Moshi moshi'))
print(is_phone_number('Moshi moshi')) 

当这个程序运行时,输出看起来像这样:

Is 415-555-4242 a phone number?
True
Is Moshi moshi a phone number?
False 

is_phone_number() 函数中有代码执行多个检查,以确定 text 中的字符串是否是有效的电话号码。如果这些检查中的任何一个失败,函数将返回 False。首先,代码检查字符串是否正好是 12 个字符长 ❶。然后,它通过调用 isdecimal() 字符串方法检查区号(即在 text 中的前三个字符)是否只包含数字字符 ❷。函数的其余部分检查字符串是否遵循电话号码的模式:号码必须在区号之后有第一个连字符 ❸,接着是三个数字字符 ❹,然后是另一个连字符 ❺,最后是四个数字字符 ❻。如果程序执行能够通过所有检查,它将返回 True ❼。

使用参数 '415-555-4242' 调用 is_phone_number() 函数将返回 True。使用 'Moshi moshi' 调用 is_phone_number() 函数将返回 False;第一个测试失败是因为 'Moshi moshi' 不是 12 个字符长。

如果你想在更大的字符串中查找电话号码,你必须添加更多的代码来定位模式。将 isPhoneNumber.py 中的最后四个 print() 函数调用替换为以下内容:

message = 'Call me at 415-555-1011 tomorrow. 415-555-9999 is my office.'
for i in range(len(message)):
    if len(text) != 12:  # Phone numbers have exactly 12 characters. # ❶
    if len(text) != 12:  # Phone numbers have exactly 12 characters. # ❶
        print('Phone number found: ' + segment)
print('Done') 

当程序运行时,输出将如下所示:

Phone number found: 415-555-1011
Phone number found: 415-555-9999
Done 

for 循环的每次迭代中,从 message 中分配一个新的 12 字符片段给变量 segment ❶。例如,在第一次迭代中,i0segment 被分配为 message[0:12](即字符串 'Call me at 4')。在接下来的迭代中,i1segment 被分配为 message[1:13](字符串 'all me at 41')。换句话说,在 for 循环的每次迭代中,segment 取以下值

'Call me at 4'
'all me at 41'
'll me at 415'
'l me at 415-' 

以此类推,直到其最后一个值是 's my office.'

循环的代码将 segment 传递给 is_phone_number() 函数以检查它是否匹配电话号码模式 ❷,如果是,则打印该片段。一旦它完成了对 message 的遍历,我们就打印 Done

尽管在这个例子中 message 中的字符串很短,但如果它有数百万个字符长,程序运行时间也将少于 1 秒。一个使用正则表达式查找电话号码的类似程序也将运行在少于 1 秒;然而,正则表达式使得编写这些程序变得更快。

使用正则表达式查找文本模式

之前的电话号码查找程序是有效的,但它使用了大量的代码来完成有限的任务。is_phone_number() 函数有 17 行代码,但只能找到一种电话号码格式。那么,像 415.555.4242 或(415) 555-4242 这样的电话号码格式怎么办?如果电话号码有分机号,比如 415-555-4242 x99 呢?is_phone_number() 函数将无法找到它们。你可以添加更多的代码来处理这些额外的模式,但有一个更简单的方法来解决这个问题。

正则表达式,简称 regexes,是一种描述文本模式的迷你语言。例如,正则表达式中的字符 \d 代表介于 0 到 9 之间的十进制数字。Python 使用正则表达式字符串 r'\d\d\d-\d\d\d-\d\d\d\d' 来匹配与之前 is_phone_number() 函数相同的文本模式:一串数字,一个连字符,再三个数字,另一个连字符,然后是四个数字。任何其他字符串都不会匹配 r'\d\d\d-\d\d\d-\d\d\d\d' 正则表达式。

正则表达式可以比这个更复杂。例如,在模式后面添加一个数字,如 3,放在花括号 {3} 中,就像说“匹配这个模式三次”。所以稍微简短的正则表达式 r'\d{3}-\d{3}-\d{4}' 也能匹配电话号码模式。

注意,我们通常将正则表达式字符串写成原始字符串,带有 r 前缀。这很有用,因为正则表达式字符串通常包含反斜杠。如果不使用原始字符串,我们就必须输入像 '\\d' 这样的表达式。

在我们介绍正则表达式语法的所有细节之前,让我们先看看如何在 Python 中使用它们。我们将使用示例正则表达式字符串 r'\d{3}-\d{3}-\d{4}',该字符串用于在文本字符串 'My number is 415-555-4242' 中查找美国电话号码。在 Python 中使用正则表达式的一般过程包括四个步骤:

  1. 导入 re 模块。

  2. 将正则表达式字符串传递给 re.compile() 以获取一个 Pattern 对象。

  3. 将文本字符串传递给 Pattern 对象的 search() 方法以获取一个 Match 对象。

  4. 调用 Match 对象的 group() 方法以获取匹配文本的字符串。

在交互式会话中,这些步骤看起来是这样的:

>>> import re
>>> phone_num_pattern_obj = re.compile(r'\d{3}-\d{3}-\d{4}')
>>> match_obj = phone_num_pattern_obj.search('My number is 415-555-4242.')
>>> match_obj.group()
'415-555-4242' 

Python 中的所有正则表达式函数都在 re 模块中。本章中的大多数示例都需要 re 模块,所以请记住在程序开始时导入它。否则,你会得到一个 NameError: name 're' is not defined 错误信息。与导入任何模块一样,你只需要在每个程序或交互式会话中导入一次。

将正则表达式字符串传递给 re.compile() 返回一个 Pattern 对象。您只需要编译一次 Pattern 对象;之后,您可以为任何不同的文本字符串调用 Pattern 对象的 search() 方法。

Pattern 对象的 search() 方法在其传递的字符串中搜索正则表达式的任何匹配项。如果正则表达式模式在字符串中没有找到,search() 方法将返回 None。如果模式 确实 找到了,search() 方法将返回一个 Match 对象,该对象将有一个 group() 方法,它返回匹配文本的字符串。

注意

虽然我鼓励你将示例代码输入到交互式外壳中,但你也可以利用基于网络的正则表达式测试器,这些测试器可以显示正则表达式如何匹配你输入的文本。我推荐使用 pythex.org regex101.com。不同的编程语言具有略微不同的正则表达式语法,所以请确保在这些网站上选择“Python”版本。

正则表达式的语法

现在你已经了解了使用 Python 创建和查找正则表达式对象的基本步骤,你就可以学习正则表达式语法的完整范围了。在本节中,你将学习如何使用括号将正则表达式元素分组在一起,转义特殊字符,使用管道字符匹配多个可选分组,以及使用 findall() 方法返回所有匹配项。

使用括号进行分组

假设你想要将匹配文本的一个较小部分(例如区号)与电话号码的其余部分分开(例如,对它执行某些操作)。添加括号将在正则表达式字符串中创建 分组r'(\d\d\d)-(\d\d\d-\d\d\d\d)'。然后,你可以使用 Match 对象的 group() 方法从单个分组中获取匹配的文本。

正则表达式字符串中的第一组括号将是分组 1。第二组将是分组 2。通过将整数 12 传递给 group() 方法,你可以获取匹配文本的不同部分。将 0 或不传递任何内容给 group() 方法将返回整个匹配的文本。在交互式外壳中输入以下内容:

>>> import re
>>> phone_re = re.compile(r'(\d\d\d)-(\d\d\d-\d\d\d\d)')
>>> mo = phone_re.search('My number is 415-555-4242.')
>>> mo.group(1)  # Returns the first group of the matched text
'415'
>>> mo.group(2)  # Returns the second group of the matched text
'555-4242'
>>> mo.group(0)  # Returns the full matched text
'415-555-4242'
>>> mo.group()  # Also returns the full matched text
'415-555-4242' 

如果你希望一次性检索所有分组,请使用 groups() 方法(注意名称中的复数形式):

>>> mo.groups()
('415', '555-4242')
>>> area_code, main_number = mo.groups()
>>> print(area_code)
415
>>> print(main_number)
555-4242 

因为 mo.groups() 返回一个包含多个值的元组,所以你可以使用多重赋值技巧将每个值分配给不同的变量,就像之前的 area_code, main_number = mo.groups() 行一样。

使用转义字符

括号在正则表达式中用于创建分组,并且不会被解释为文本模式的一部分。那么,如果你需要在文本中匹配括号怎么办呢?例如,你可能正在尝试匹配的电话号码中,区号被设置为括号内:'(415) 555-4242'

在这种情况下,你需要使用反斜杠转义 () 字符。转义后的 \(\) 括号将被解释为你要匹配的模式的一部分。在交互式外壳中输入以下内容:

>>> pattern = re.compile(r'(\(\d\d\d\)) (\d\d\d-\d\d\d\d)')
>>> mo = pattern.search('My phone number is (415) 555-4242.')
>>> mo.group(1)
'(415)'
>>> mo.group(2)
'555-4242' 

在传递给 re.compile() 的原始字符串中的 \(\) 转义字符将匹配实际的括号字符。在正则表达式中,以下字符具有特殊含义:

$ () * + - . ? [\] ^ {|}

如果你想要将以下字符作为文本模式的一部分进行检测,你需要使用反斜杠进行转义:

\$ \(\) \* \+ \- \. \? \[\\ \] \^ \{\| \}

总是检查您是否将正则表达式中的转义括号 \(\) 错误地当作未转义的括号 ( 和 )。如果您收到关于“缺少)”或“括号不平衡”的错误信息,您可能忘记为组包含闭合的未转义括号,就像这个例子中那样:

>>> import re
>>> re.compile(r'(\(Parentheses\)')
Traceback (most recent call last):
# --snip--
re.error: missing), unterminated subpattern at position 0 

错误信息告诉您,在字符串 r'(\(Parentheses\)' 的索引 0 处有一个开括号,但没有相应的闭合括号。使用本章后面描述的 Humre 模块可以帮助防止这类错误。

匹配交替组中的字符

| 字符被称为 管道,它在正则表达式中用作 交替运算符。您可以在任何想要匹配多个表达式的地方使用它。例如,正则表达式 r'Cat|Dog' 将匹配 'Cat''Dog'

您还可以使用管道来匹配正则表达式中的多个模式之一。例如,假设您想匹配字符串 'Caterpillar''Catastrophe''Catch''Category' 中的任何一个。由于所有这些字符串都以 Cat 开头,如果您能只指定这个前缀一次,那就很好了。您可以通过在括号内使用管道来分隔可能的后缀来实现这一点。将以下内容输入到交互式外壳中:

>>> import re
>>> pattern = re.compile(r'Cat(erpillar|astrophe|ch|egory)')
>>> match = pattern.search('Catch me if you can.')
>>> match.group()
'Catch'
>>> match.group(1)
'ch' 

方法调用 match.group() 返回完整的匹配文本 'Catch',而 match.group(1) 返回匹配文本中第一个括号组内的部分,即 'ch'。通过使用管道字符和分组括号,您可以指定您希望正则表达式匹配的几个替代模式。

如果您需要匹配实际的管道字符,请使用反斜杠进行转义,例如 \|

返回所有匹配项

除了 search() 方法外,Pattern 对象还有一个 findall() 方法。虽然 search() 会返回搜索字符串中第一个匹配文本的 Match 对象,但 findall() 方法会返回搜索字符串中所有匹配的字符串。

使用 findall() 时,您需要注意的一个细节是:只要正则表达式中没有分组,该方法就会返回一个字符串列表。将以下内容输入到交互式外壳中:

>>> import re
>>> pattern = re.compile(r'\d{3}-\d{3}-\d{4}') # This regex has no groups.
>>> pattern.findall('Cell: 415-555-9999 Work: 212-555-0000')
['415-555-9999', '212-555-0000'] 

如果正则表达式中存在分组,则 findall() 将返回一个元组列表。每个元组代表一个单独的匹配项,元组中包含正则表达式中的每个组的字符串。要查看此行为的效果,请将以下内容输入到交互式外壳中(并注意现在编译的正则表达式中有括号分组):

>>> import re
>>> pattern = re.compile(r'(\d{3})-(\d{3})-(\d{4})') # This regex has groups.
>>> pattern.findall('Cell: 415-555-9999 Work: 212-555-0000')
[('415', '555', '9999'), ('212', '555', '0000')] 

还要注意,findall() 不会重叠匹配。例如,使用正则表达式字符串 r'\d{3}' 匹配三个数字会在 '1234' 中匹配前三个数字,但不会匹配最后三个:

>>> import re
>>> pattern = re.compile(r'\d{3}')
>>> pattern.findall('1234')
['123']
>>> pattern.findall('12345')
['123']
>>> pattern.findall('123456')
['123', '456'] 

因为 '1234' 中的前三位数字已被匹配为 '123',所以数字 '234' 不会包含在后续的匹配中,即使它们符合 r'\d{3}' 模式。

限定符语法:匹配哪些字符

正则表达式分为两部分:限定符,它规定了你要匹配的字符,以及量词,它规定了你要匹配多少个字符。在我们之前使用的电话号码正则表达式字符串示例 r'\d{3}-\d{3}-\d{4}' 中,r'\d''-' 部分是限定符,而 '{3}''{4}' 是量词。现在,让我们来检查限定符的语法。

使用字符类和否定字符类

虽然你可以定义一个单独的字符进行匹配,就像我们在前面的例子中所做的那样,但你也可以在方括号内定义一组要匹配的字符。这个集合被称为字符类。例如,字符类 [aeiouAEIOU] 将匹配任何元音,包括小写和大写。它等同于编写 a|e|i|o|u|A|E|I|O|U,但更容易输入。在交互式外壳中输入以下内容:

>>> import re
>>> vowel_pattern = re.compile(r'[aeiouAEIOU]')
>>> vowel_pattern.findall('RoboCop eats BABY FOOD.')
['o', 'o', 'o', 'e', 'a', 'A', 'O', 'O'] 

你还可以通过使用连字符来包含字母或数字的范围。例如,字符类 [a-zA-Z0-9] 将匹配所有小写字母、大写字母和数字。

注意,在方括号内,正常的正则表达式符号不会被解释为这样的符号。这意味着如果你想要匹配字面意义上的括号,你不需要在方括号内转义字符,例如括号。例如,字符类 [()] 将匹配一个开括号或闭括号。你不需要将其写成 [\(\)]

在字符类的开头括号后放置一个插入符字符(^),你可以创建一个否定字符类。否定字符类将匹配不在字符类中的所有字符。例如,在交互式外壳中输入以下内容:

>>> import re
>>> consonant_pattern = re.compile(r'[^aeiouAEIOU]')
>>> consonant_pattern.findall('RoboCop eats BABY FOOD.')
['R', 'b', 'C', 'p', ' ', 't', 's', ' ', 'B', 'B', 'Y', ' ', 'F', 'D', '.'] 

现在,我们不再匹配每个元音,而是匹配每个不是元音的字符。请注意,这包括空格、换行符、标点符号和数字。

使用简写字符类

在之前的电话号码正则表达式示例中,你学习了 \d 可以代表任何数字。也就是说,\d 是正则表达式 0|1|2|3|4|5|6|7|8|9[0-9] 的缩写。正如表 9-1 所示,有许多这样的简写字符类

表 9-1:常用字符类的缩写代码

简写字符类 表示 ...
\d 任何从 0 到 9 的数字。
\D 任何不是从 0 到 9 的数字的字符。
\w 任何字母、数字或下划线字符。(将其视为匹配“单词”字符。)
\W 任何不是字母、数字或下划线字符的字符。
\s 任何空格、制表符或换行符字符。(将其视为匹配“空格”字符。)
\S 任何不是空格、制表符或换行符字符的字符。

注意,虽然 \d 匹配数字,\w 匹配数字、字母和下划线,但没有简写字符类别仅匹配字母。尽管可以使用 [a-zA-Z] 字符类别,但此字符类别不会匹配带重音的字母或非罗马字母,例如 'é'。此外,请记住使用原始字符串来转义反斜杠:r'\d'

例如,在交互式外壳中输入以下内容:

>>> import re
>>> pattern = re.compile(r'\d+\s\w+')
>>> pattern.findall('12 drummers, 11 pipers, 10 lords, 9 ladies, 8 maids, 
7 swans, 6 geese, 5 rings, 4 birds, 3 hens, 2 doves, 1 partridge')
['12 drummers', '11 pipers', '10 lords', '9 ladies', '8 maids', '7 swans', '
6 geese', '5 rings', '4 birds', '3 hens', '2 doves', '1 partridge'] 

正则表达式 \d+\s\w+ 会匹配包含一个或多个数字 (\d+),后跟一个空白字符 (\s),再后跟一个或多个字母/数字/下划线字符 (\w+) 的文本。findall() 方法返回正则表达式模式的所有匹配字符串的列表。

使用点字符匹配所有内容

正则表达式字符串中的 .(或 )字符匹配任何字符,除了换行符。例如,在交互式外壳中输入以下内容:

>>> import re
>>> at_re = re.compile(r'.at')
>>> at_re.findall('The cat in the hat sat on the flat mat.')
['cat', 'hat', 'sat', 'lat', 'mat'] 

记住点字符只会匹配一个字符,这就是为什么在先前的例子中,文本 flat 只匹配了 lat。要匹配实际的点,需要用反斜杠转义点:\.

仔细选择匹配的内容

正则表达式的最好和最坏之处在于它们会精确匹配您所要求的内容。以下是关于字符类别的一些常见混淆点:

  • [A-Z][a-z] 字符类别分别匹配大写或小写字母,但不能同时匹配。您需要使用 [A-Za-z] 来匹配两种情况。

  • [A-Za-z] 字符类别仅匹配普通、无重音的字母。例如,正则表达式字符串 r'First Name: ([A-Za-z]+)' 会匹配“First Name: ”后面跟着的一组一个或多个无重音字母。但歌手辛·奥康纳的名字只会匹配到 é,并且该组会被设置为 'Sin'

  • \w 字符类别匹配所有字母,包括带重音的字母和其他字母表中的字符。但它也匹配数字和下划线字符,所以正则表达式字符串 r'First Name: (\w+)' 可能会匹配比预期更多的内容。

  • \w 字符类别匹配所有字母,但正则表达式字符串 r'Last Name: (\w+)' 只会捕获辛·奥康纳的姓氏,直到撇号字符。这意味着该组会捕获她的姓氏为 'O'

  • 直引号和智能引号字符(' " ‘ ’ “ ”)被认为是完全不同的,并且必须分别指定。

实际数据很复杂。即使您的程序设法捕获辛·奥康纳的名字,也可能因为连字符而无法匹配让-保尔·萨特的名字。

当然,当软件声明一个名称为无效输入时,有问题的不是名称,而是软件;人们的名字不能是无效的。你可以从 Patrick McKenzie 的文章“程序员关于名称的谬误”中了解更多关于这个问题,该文章可在www.kalzumeus.com/2010/06/17/falsehoods-programmers-believe-about-names/找到。这篇文章引发了一系列类似的“程序员相信的谬误”文章,关于软件如何错误处理日期、时区、货币、邮政地址、性别、机场代码和爱情。观看 Carina C. Zona 在 2015 年 PyCon 上关于这个主题的演讲,“现实世界的模式”,在youtu.be/PYYfVqtcWQY

量词语法:匹配多少个限定符

在正则表达式字符串中,量词跟在限定符字符后面,以指定要匹配多少个。例如,在之前考虑的电话号码正则表达式中,\d后面的{3}匹配恰好三个数字。如果没有量词跟在限定符后面,限定符必须恰好出现一次:你可以将r'\d'看作与r'\d{1}'相同。

匹配可选模式

有时候你可能只想可选地匹配一个模式。也就是说,正则表达式应该匹配前一个限定符的零次或一次。?字符将前一个限定符标记为可选。例如,将以下内容输入到交互式外壳中:

>>> import re
>>> pattern = re.compile(r'42!?')
>>> pattern.search('42!')
<re.Match object; span=(0, 3), match='42!'>
>>> pattern.search('42')
<re.Match object; span=(0, 2), match='42'> 

正则表达式中的?部分意味着模式!是可选的。所以它匹配42!(带有感叹号)和42(没有它)。

正如你开始看到的那样,正则表达式语法对符号和标点的依赖使得它难以阅读:?问号在正则表达式语法中有意义,但!感叹号没有。所以r'42!?'表示可选地跟随一个'!''42',但r'42?!'表示可选地跟随一个'2'然后是一个'!''4'

>>> import re
>>> pattern = re.compile(r'42?!')
>>> pattern.search('42!')
<re.Match object; span=(0, 3), match='42!'>
>>> pattern.search('4!')
<re.Match object; span=(0, 2), match='4!'>
>>> pattern.search('42') == None # No match
True 

要使多个字符可选,将它们放在一个组中,并在组后放置?。在早期的电话号码示例中,你可以使用?使正则表达式查找是否有或没有区号的电话号码。将以下内容输入到交互式外壳中:

>>> pattern = re.compile(r'(\d{3}-)?\d{3}-\d{4}')
>>> match1 = pattern.search('My number is 415-555-4242')
>>> match1.group()
'415-555-4242'

>>> match2 = pattern.search('My number is 555-4242')
>>> match2.group()
'555-4242' 

你可以将?看作是表示“匹配这个问号之前的前一个组零次或一次”。

如果你需要匹配实际的问号字符,请使用\?进行转义。

匹配零个或多个限定符

*(称为星号asterisk)意味着“匹配零个或多个”。换句话说,星号之前的前缀可以在文本中出现任意次数。它可以完全不存在,或者一次又一次地重复。看看以下示例:

>>> import re
>>> pattern = re.compile('Eggs(and spam)*')
>>> pattern.search('Eggs')
<re.Match object; span=(0, 4), match='Eggs'>
>>> pattern.search('Eggs and spam')
<re.Match object; span=(0, 13), match='Eggs and spam'>
>>> pattern.search('Eggs and spam and spam')
<re.Match object; span=(0, 22), match='Eggs and spam and spam'>
>>> pattern.search('Eggs and spam and spam and spam')
<re.Match object; span=(0, 31), match='Eggs and spam and spam and spam'> 

虽然字符串中的'Eggs'部分必须出现一次,但可以跟随任意数量的' and spam',包括零次。

如果你需要匹配实际的星号字符,请在正则表达式中用反斜杠前缀星号,\*

匹配一个或多个限定符

虽然 * 表示“匹配零次或多次”,但 +(或加号)表示“匹配一次或多次”。与星号不同,星号不需要其限定符出现在匹配的字符串中,而加号要求其前面的限定符至少出现一次。这是必需的。在交互式外壳中输入以下内容,并与上一节中的星号正则表达式进行比较:

>>> pattern = re.compile('Eggs(and spam)+')
>>> pattern.search('Eggs and spam')
<re.Match object; span=(0, 13), match='Eggs and spam'>
>>> pattern.search('Eggs and spam and spam')
<re.Match object; span=(0, 22), match='Eggs and spam and spam'>
>>> pattern.search('Eggs and spam and spam and spam')
<re.Match object; span=(0, 31), match='Eggs and spam and spam and spam'> 

正则表达式 'Eggs(and spam)+' 不会匹配字符串 'Eggs',因为加号要求至少有一个 ' and spam'

你通常会在正则表达式字符串中使用括号来组合限定符,以便限定符可以应用于整个组。例如,你可以使用 r'(\.|\-)+' 来匹配任何摩尔斯电码的点和破折号的组合(尽管这个表达式也会匹配无效的摩尔斯电码组合)。

如果你需要匹配实际的加号字符,请在加号前加上反斜杠以转义它:\+

匹配特定数量的限定符

如果你有一个想要重复特定次数的分组,请在正则表达式中的分组后跟一个数字,放在花括号中。例如,正则表达式 (Ha){3} 将匹配字符串 'HaHaHa' 但不会匹配 'HaHa',因为后者只有两个 (Ha) 分组的重复。

你可以指定一个范围,而不是一个数字,通过在花括号中写一个最小值,一个逗号,和一个最大值。例如,正则表达式 (Ha){3,5} 将匹配 'HaHaHa''HaHaHaHa''HaHaHaHaHa'

你也可以省略花括号中的第一个或第二个数字,以保持最小值或最大值未指定。例如,(Ha){3,} 将匹配三个或更多 (Ha) 分组的实例,而 (Ha){,5} 将匹配零到五个实例。花括号可以帮助使你的正则表达式更短。这两个正则表达式匹配相同的模式:

(Ha){3}
HaHaHa 

所以这两个正则表达式也这样做:

(Ha){3,5}
(HaHaHa)|(HaHaHaHa)|(HaHaHaHaHa) 

在交互式外壳中输入以下内容:

>>> import re
>>> haRegex = re.compile(r'(Ha){3}')
>>> match1 = haRegex.search('HaHaHa')
>>> match1.group()
'HaHaHa'

>>> match = haRegex.search('HaHa')
>>> match == None
True 

这里,(Ha){3} 匹配 'HaHaHa' 但不匹配 'Ha'。因为它不匹配 'HaHa',所以 search() 返回 None

花括号量词的语法类似于 Python 的切片语法(例如 'Hello, world!'[3:5],其结果为 'lo')。但有一些关键区别。在正则表达式量词中,两个数字由逗号分隔,而不是冒号。此外,量词中的第二个数字是包含的:'(Ha){3,5}' 匹配最多 包括 五个 '(Ha)' 限定符的实例。

贪婪和非贪婪匹配

因为 (Ha){3,5} 可以匹配字符串 'HaHaHaHaHa' 中的三个、四个或五个 Ha 实例,你可能想知道为什么在之前的花括号示例中,Match 对象调用 group() 返回的是 'HaHaHaHaHa' 而不是更短的匹配可能性。毕竟,'HaHaHa''HaHaHaHa' 也是正则表达式 (Ha){3,5} 的有效匹配。

Python 的正则表达式默认是贪婪的,这意味着在模糊情况下,它们会匹配最长的字符串。非贪婪(也称为懒惰)版本的大括号,匹配最短的字符串,必须在闭括号后跟一个问号。

将以下内容输入到交互式外壳中,并注意贪婪和非贪婪形式的括号在搜索相同字符串时的区别:

>>> import re
>>> greedy_pattern = re.compile(r'(Ha){3,5}')
>>> match1 = greedy_pattern.search('HaHaHaHaHa')
>>> match1.group()
'HaHaHaHaHa'

>>> lazy_pattern = re.compile(r'(Ha){3,5}?')
>>> match2 = lazy_pattern.search('HaHaHaHaHa')
>>> match2.group()
'HaHaHa' 

注意,正则表达式中的问号可以有两个含义:声明懒惰匹配或声明可选限定符。这些含义完全无关。

值得指出的是,技术上,你可以不使用可选的 ? 量词,甚至不使用 *+ 量词:

  • ? 量词等同于 {0,1}

  • * 量词等同于 {0,}

  • + 量词等同于 {1,}

然而,?*+ 量词是常见的缩写。

匹配所有内容

有时候你可能想要匹配所有内容和任何内容。例如,假设你想匹配字符串 'First Name:',后面跟任何和所有文本,然后是 'Last Name:' 和任何文本再次。你可以使用点星号 (.*) 来代替那个“任何内容”。记住,点字符意味着“任何单个字符,除了换行符”,星号字符意味着“前一个字符的零个或多个实例”。

将以下内容输入到交互式外壳中:

>>> import re
>>> name_pattern = re.compile(r'First Name: (.*) Last Name: (.*)')
>>> name_match = name_pattern.search('First Name: Al Last Name: Sweigart')
>>> name_match.group(1)
'Al'
>>> name_match.group(2)
'Sweigart' 

点星模式使用贪婪模式:它总是会尝试匹配尽可能多的文本。要非贪婪或懒惰地匹配任何和所有文本,请使用点、星号和问号 (.*?)。当它与括号一起使用时,问号告诉 Python 以非贪婪方式匹配。

将以下内容输入到交互式外壳中,以查看贪婪和非贪婪表达式的区别:

>>> import re
>>> lazy_pattern = re.compile(r'<.*?>')
>>> match1 = lazy_pattern.search('<To serve man> for dinner.>')
>>> match1.group()
'<To serve man>'

>>> greedy_re = re.compile(r'<.*>')
>>> match2 = greedy_re.search('<To serve man> for dinner.>')
>>> match2.group()
'<To serve man> for dinner.>' 

两个正则表达式大致翻译为“匹配一个开方括号,后面跟任何内容,然后是闭方括号。”但字符串 '<To serve man> for dinner.>' 对于闭方括号有两个可能的匹配。在正则表达式的非贪婪版本中,Python 匹配最短的字符串:'<To serve man>'。在贪婪版本中,Python 匹配最长的字符串:'<To serve man> for dinner.>'

匹配换行符

.* 中的点将匹配除换行符之外的所有内容。通过将 re.DOTALL 作为 re.compile() 的第二个参数传递,可以使点字符匹配 所有 字符,包括换行符。

将以下内容输入到交互式外壳中:

>>> import re
>>> no_newline_re = re.compile('.*')
>>> no_newline_re.search('Serve the public trust.\nProtect the innocent. 
\nUphold the law.').group()
'Serve the public trust.'

>>> newline_re = re.compile('.*', re.DOTALL)
>>> newline_re.search('Serve the public trust.\nProtect the innocent. 
\nUphold the law.').group()
'Serve the public trust.\nProtect the innocent.\nUphold the law.' 

正则表达式 no_newline_re,在创建它时没有将 re.DOTALL 传递给 re.compile() 调用,它只会匹配到第一个换行符为止的所有内容,而 newline_re,它确实将 re.DOTALL 传递给了 re.compile(),匹配所有内容。这就是为什么 newline_re.search() 调用匹配了整个字符串,包括其换行符。

匹配字符串的开始和结束

您可以在正则表达式的开头使用撇号符号 (^) 来指示匹配必须发生在搜索文本的 开始。同样,您可以在正则表达式的末尾放置美元符号 ($) 来指示字符串必须 此正则表达式模式结束。您还可以使用 ^$ 一起使用来指示整个字符串必须匹配正则表达式——也就是说,如果字符串的某个子集与正则表达式匹配,这还不够。

例如,正则表达式字符串 r'^Hello' 匹配以 'Hello' 开头的字符串。在交互式外壳中输入以下内容:

>>> import re
>>> begins_with_hello = re.compile(r'^Hello')
>>> begins_with_hello.search('Hello, world!')
<re.Match object; span=(0, 5), match='Hello'>
>>> begins_with_hello.search('He said "Hello."') == None
True 

正则表达式字符串 r'\d$' 匹配以 0 到 9 之间的数字字符结束的字符串。在交互式外壳中输入以下内容:

>>> import re
>>> ends_with_number = re.compile(r'\d$')
>>> ends_with_number.search('Your number is 42')
<re.Match object; span=(16, 17), match='2'>
>>> ends_with_number.search('Your number is forty two.') == None
True 

正则表达式字符串 r'^\d+$' 匹配以一个或多个数字字符开始和结束的字符串。在交互式外壳中输入以下内容:

>>> import re
>>> whole_string_is_num = re.compile(r'^\d+$')
>>> whole_string_is_num.search('1234567890')
<re.Match object; span=(0, 10), match='1234567890'>
>>> whole_string_is_num.search('12345xyz67890') == None
True 

在上一个交互式外壳示例中的最后两个 search() 调用演示了如果使用 ^$,整个字符串必须匹配正则表达式。(我总是混淆这两个符号的含义,所以我使用助记符“胡萝卜成本美元”来提醒自己撇号先出现,美元符号后出现。)

您还可以使用 \b 来使正则表达式模式仅在 单词边界 上匹配:单词的开始、结束,或同时匹配单词的开始和结束。在这种情况下,“单词”是由非字母字符分隔的字母序列。例如,r'\bcat.*?\b' 匹配以 'cat' 开头,后面跟任何其他字符,直到下一个单词边界的单词:

>>> import re
>>> pattern = re.compile(r'\bcat.*?\b')
>>> pattern.findall('The cat found a catapult catalog in the catacombs.')
['cat', 'catapult', 'catalog', 'catacombs'] 

\B 语法匹配任何不是单词边界的字符:

>>> import re
>>> pattern = re.compile(r'\Bcat\B')
>>> pattern.findall('certificate')  # Match
['cat']
>>> pattern.findall('catastrophe')  # No match
[] 

在查找单词中间的匹配项时很有用。 ### 不区分大小写的匹配

通常,正则表达式会匹配与您指定的确切大小写相匹配的文本。例如,以下正则表达式匹配完全不同的字符串:

>>> import re
>>> pattern1 = re.compile('RoboCop')
>>> pattern2 = re.compile('ROBOCOP')
>>> pattern3 = re.compile('robOcop')
>>> pattern4 = re.compile('RobocOp') 

但有时您只关心匹配字母,而不在乎它们是大写还是小写。要使您的正则表达式不区分大小写,可以将 re.IGNORECASEre.I 作为 re.compile() 的第二个参数传递。在交互式外壳中输入以下内容:

>>> import re
>>> pattern = re.compile(r'robocop', re.I)
>>> pattern.search('RoboCop is part man, part machine, all cop.').group()
'RoboCop'

>>> pattern.search('ROBOCOP protects the innocent.').group()
'ROBOCOP'

>>> pattern.search('Have you seen robocop?').group()
'robocop' 

现在正则表达式匹配任何大小写的字符串。

字符串替换

正则表达式不仅能够找到文本模式;它们还可以用新文本替换这些模式。Pattern 对象的 sub() 方法接受两个参数。第一个是一个字符串,它应该替换任何匹配项。第二个是正则表达式的字符串。sub() 方法返回一个应用了替换的字符串。

例如,在交互式外壳中输入以下内容以将秘密特工的名字替换为 CENSORED

>>> import re
>>> agent_pattern = re.compile(r'Agent \w+')
>>> agent_pattern.sub('CENSORED', 'Agent Alice contacted Agent Bob.')
'CENSORED contacted CENSORED.' 

有时你可能需要使用匹配的文本本身作为替换的一部分。在sub()的第一个参数中,你可以包括\1\2\3等,表示“在替换中输入组123等的文本。”这种语法称为反向引用

例如,假设你想通过只显示秘密特工名字的第一个字母来隐藏他们的名字。为此,你可以使用正则表达式Agent (\w)\w*并将r'\1****'作为sub()的第一个参数传递:

>>> import re
>>> agent_pattern = re.compile(r'Agent (\w)\w*')
>>> agent_pattern.sub(r'\1', 'Agent Alice contacted Agent Bob.')
'A** contacted B.' 

正则表达式字符串中的\1被替换为与组1匹配的任何文本——即正则表达式的(\w)组。

使用详细模式管理复杂正则表达式

如果需要匹配的文本模式很简单,正则表达式就很好用。但是,匹配复杂的文本模式可能需要长而复杂的正则表达式。你可以通过告诉re.compile()函数忽略正则表达式字符串中的空白和注释来减轻这种复杂性。通过将变量re.VERBOSE作为re.compile()的第二个参数传递,启用这种“详细模式”。

现在,而不是使用难以阅读的正则表达式,例如

pattern = re.compile(r'((\d{3}|\(\d{3}\))?(\s|-|\.)?\d{3}(\s|-
|\.)\d{4}(\s*(ext|x|ext\.)\s*\d{2,5})?)') 

你可以将正则表达式分散到多行,并使用注释来标记其组件,如下所示:

pattern = re.compile(r'''(
    (\d{3}|\(\d{3}\))?  # Area code
    (\s|-|\.)?  # Separator
    \d{3}  # First three digits
    (\s|-|\.)  # Separator
    \d{4}  # Last four digits
    (\s*(ext|x|ext\.)\s*\d{2,5})?  # Extension
    )''', re.VERBOSE) 

注意前一个示例如何使用三引号语法(''')创建多行字符串,这样你就可以将正则表达式定义分散到多行,使其更容易阅读。

正则表达式字符串内的注释规则与常规 Python 代码相同:#符号及其之后直到行尾的内容将被忽略。此外,正则表达式多行字符串中的额外空格不被视为要匹配的文本模式的一部分。这使得你可以组织正则表达式,使其更容易阅读。

虽然详细模式使你的正则表达式字符串更易读,但我建议你使用本章后面介绍的 Humre 模块来提高正则表达式的可读性。

组合 re.IGNORECASE、re.DOTALL 和 re.VERBOSE

如果你想在正则表达式中使用re.VERBOSE来写注释,但还想使用re.IGNORECASE来忽略大小写,不幸的是,re.compile()函数只接受单个值作为其第二个参数。

你可以通过使用管道字符(|)组合re.IGNORECASEre.DOTALLre.VERBOSE变量来绕过这个限制,在这个上下文中,它被称为按位或运算符。例如,如果你想创建一个不区分大小写且包括换行符以匹配点字符的正则表达式,你的re.compile()调用将如下所示:

>>> some_regex = re.compile('foo', re.IGNORECASE | re.DOTALL)

在第二个参数中包含所有三个选项看起来像这样:

>>> some_regex = re.compile('foo', re.IGNORECASE | re.DOTALL | re.VERBOSE)

这种语法有点过时,起源于 Python 的早期版本。位运算符的详细信息超出了本书的范围,但有关更多信息,请查看nostarch.com/automate-boring-stuff-python-3rd-edition资源。您还可以为第二个参数传递其他选项;它们不常见,但您也可以在资源中了解更多关于它们的信息。

项目 3:从大型文档中提取联系信息

假设您被分配了一个无聊的任务,即在一个长网页或文档中找到每个电话号码和电子邮件地址。如果您手动滚动页面,您可能会花费很长时间进行搜索。但如果有程序可以搜索剪贴板中的文本中的电话号码和电子邮件地址,您只需按 CTRL-A 选择所有文本,按 CTRL-C 将其复制到剪贴板,然后运行您的程序。它可以替换剪贴板上的文本,只留下它找到的电话号码和电子邮件地址。

每当您面对一个新的项目时,直接编写代码可能会有所吸引。但更常见的情况是,最好退一步,考虑更大的图景。我建议首先为程序需要执行的任务绘制一个高级计划。现在不要考虑实际的代码;您可以稍后再担心这个问题。现在,坚持使用粗线条。

例如,您的电话号码和电子邮件地址提取器需要执行以下操作:

  • 从剪贴板获取文本。

  • 在文本中查找所有电话号码和电子邮件地址。

  • 将它们粘贴到剪贴板上。

现在,您可以开始思考这在代码中是如何工作的。代码需要执行以下操作:

  • 使用pyperclip模块复制和粘贴字符串。

  • 创建两个正则表达式,一个用于匹配电话号码,另一个用于匹配电子邮件地址。

  • 找到两个正则表达式的所有匹配项(而不仅仅是第一个匹配项)。

  • 将匹配的字符串整洁地格式化为单个字符串以粘贴。

  • 如果在文本中没有找到匹配项,显示某种类型的消息。

这个列表就像项目的路线图。随着您编写代码,您可以专注于每个步骤,每个步骤都应该看起来相当容易管理。它们也以您已经知道如何在 Python 中做到的事情来表述。

第 1 步:为电话号码创建正则表达式

首先,您必须创建一个正则表达式来搜索电话号码。创建一个新文件,输入以下内容,并将其保存为 phoneAndEmail.py

import pyperclip, re

phone_re = re.compile(r'''(
    (\d{3}|\(\d{3}\))?  # Area code
    (\s|-|\.)?  # Separator
    (\d{3})  # First three digits
    (\s|-|\.)  # Separator
    (\d{4})  # Last four digits
    (\s*(ext|x|ext\.)\s*(\d{2,5}))?  # Extension
    )''', re.VERBOSE)

# TODO: Create email regex.

# TODO: Find matches in clipboard text.

# TODO: Copy results to the clipboard. 

TODO注释只是程序的框架。随着您编写实际的代码,它们将被替换。

电话号码以一个 可选 的区号开头,因此我们用问号跟随区号组。由于区号可以是三个数字(即 \d{3}括号内的三个数字(即 \(\d{3}\)),你应该在这些部分之间使用管道符号。你可以在多行字符串的这一部分添加正则表达式注释 # Area code 来帮助你记住 (\d{3}|\(\d{3}\))? 应该匹配什么。

电话号码的分隔字符可以是 可选 的空格 (\s)、连字符 (-) 或点 (.),因此我们也应该使用管道符号将这些部分连接起来。正则表达式的下一部分是直截了当的:三个数字,然后是另一个分隔符,然后是四个数字。最后一部分是一个可选的扩展,由任意数量的空格后跟 extxext.,然后是两个到五个数字。

注意

在编写包含带括号的组 () 和转义括号 () 的正则表达式时很容易混淆。如果你收到“缺少),未终止的子模式”错误信息,请务必再次检查你使用的语法是否正确。

第 2 步:创建电子邮件地址的正则表达式

你还需要一个可以匹配电子邮件地址的正则表达式。让你的程序看起来像以下这样:

import pyperclip, re

phone_re = re.compile(r'''(
--`snip`--

# Create email regex.
email_re = re.compile(r'''(
[a-zA-Z0-9._%+-]+ # Username # ❶
@  # @ symbol # ❷
[a-zA-Z0-9.-]+ # Domain name # ❸
 (\.[a-zA-Z]{2,4}) # Dot-something
 )''', re.VERBOSE)

# TODO: Find matches in clipboard text.

# TODO: Copy results to the clipboard. 

电子邮件地址的用户名部分 ❶ 由一个或多个字符组成,可以是以下任何一种:小写和大写字母、数字、点、下划线、百分号、加号或连字符。你可以将这些全部放入一个字符类中:[a-zA-Z0-9._%+-]

域名和用户名由一个 @ 符号分隔 ❷。域名 ❸ 有一个稍微不那么宽容的字符类,只包含字母、数字、点和连字符:[a-zA-Z0-9.-]。最后是“dot-com”部分(技术上称为 顶级域名),它可以真的是点-任何东西。

电子邮件地址的格式有很多奇怪的规则。这个正则表达式不会匹配每个可能的有效电子邮件地址,但它会匹配你遇到的几乎所有典型电子邮件地址。

第 3 步:在剪贴板文本中查找所有匹配项

现在你已经指定了电话号码和电子邮件地址的正则表达式,你可以让 Python 的 re 模块来完成在剪贴板中查找所有匹配项的繁重工作。pyperclip.paste() 函数将获取剪贴板上的文本的字符串值,而 findall() 正则表达式方法将返回一个包含元组的列表。

让你的程序看起来像以下这样:

import pyperclip, re

phone_re = re.compile(r'''(
--`snip`--

# Find matches in clipboard text.
text = str(pyperclip.paste())

matches = [] # ❶
for groups in phone_re.findall(text): # ❷
 phone_num = '-'.join([groups[1], groups[3], groups[5]])
 if groups[6] != '':
 phone_num += ' x' + groups[6]
 matches.append(phone_num)
for groups in email_re.findall(text): # ❸
 matches.append(groups[0])

# TODO: Copy results to the clipboard. 

每个匹配项对应一个元组,每个元组包含正则表达式中的每个组的字符串。记住,组 0 匹配整个正则表达式,所以元组中索引为 0 的组是你要关注的。

如您所见 ❶,您将匹配项存储在名为 matches 的列表变量中。它最初是一个空列表,包含几个 for 循环。对于电子邮件地址,您将每个匹配项的组 0 追加 ❸。对于匹配的电话号码,您不希望只是追加组 0。虽然程序 检测 几种格式的电话号码,但您希望追加的电话号码是单一、标准的格式。phone_num 变量包含由匹配文本的组 1356 构成的字符串 ❷。(这些组是区号、前三位数字、最后四位数字和分机号。)

第 4 步:将匹配项合并成一个字符串

现在您已经将电子邮件地址和电话号码作为字符串列表存储在 matches 中,您想将它们放在剪贴板上。pyperclip.copy() 函数只接受单个字符串值,不接受字符串列表,因此您必须在 matches 上调用 join() 方法。

使您的程序看起来如下:

import pyperclip, re

phone_re = re.compile(r'''(
--`snip`--
for groups in email_re.findall(text):
    matches.append(groups[0])

# Copy results to the clipboard.
if len(matches) > 0:
 pyperclip.copy('\n'.join(matches))
 print('Copied to clipboard:')
 print('\n'.join(matches))
else:
 print('No phone numbers or email addresses found.') 

为了更容易地看到程序正在工作,我们还打印出您找到的任何匹配项到终端窗口。如果没有找到电话号码或电子邮件地址,程序会告诉用户这一点。

要测试您的程序,请打开您的网络浏览器到 No Starch Press 联系页面,网址为 nostarch.com/contactus,按 CTRL-A 选择页面上的所有文本,然后按 CTRL-C 将其复制到剪贴板。当您运行此程序时,输出应类似于以下内容:

Copied to clipboard:
800-555-7240
415-555-9900
415-555-9950
info@nostarch.com
media@nostarch.com
academic@nostarch.com
info@nostarch.com 

您可以修改此脚本以搜索邮寄地址、社交媒体处理和许多其他类型的文本模式。

相似程序的思路

识别文本模式(并可能使用 sub() 方法替换它们)有许多不同的潜在应用。例如,你可以做以下事情:

  • 查找以 http://https:// 开头的网站 URL。

  • 通过将它们替换为单一、标准的格式来清理不同格式的日期(例如 3/14/2030、03-14-2030 和 2030/3/14)。

  • 删除敏感信息,例如社会保障号码或信用卡号码。

  • 查找常见的错误,例如单词之间的多个空格、不小心重复的单词或句子末尾的多个感叹号。这些都是令人烦恼的!!

Humre:一个用于人类可读正则表达式的模块

代码的阅读频率远高于编写频率,因此您的代码的可读性很重要。但是,正则表达式的标点密集型语法即使是经验丰富的程序员也难以阅读。为了解决这个问题,第三方 Humre Python 模块通过使用人类可读的、普通的英语名称来创建可读的正则表达式代码,进一步扩展了详细模式的好想法。您可以通过附录 A 中的说明安装 Humre。

让我们回到本章开头提到的 r'\d{3}-\d{3}-\d{4}' 电话号码示例。Humre 中的函数和常量可以产生与普通英语相同的正则表达式字符串:

>>> from humre import *
>>> phone_regex = exactly(3, DIGIT) + '-' + exactly(3, DIGIT) + '-' + exactly(4, DIGIT)
>>> phone_regex
'\\d{3}-\\d{3}-\\d{4}' 

Humre 的常量(如 DIGIT)包含字符串,Humre 的函数(如 exactly())返回字符串。Humre 不替换 re 模块。相反,它生成可以传递给 re.compile() 的正则表达式字符串:

>>> import re
>>> pattern = re.compile(phone_regex)
>>> pattern.search('My number is 415-555-4242')
<re.Match object; span=(13, 25), match='415-555-4242'> 

Humre 为正则表达式语法的每个功能都有常量和函数。然后您可以将常量和返回的字符串像其他任何字符串一样连接起来。例如,这里列出了 Humre 的简写字符类常量:

  • DIGITNONDIGIT 分别代表 r'\d'r'\D'

  • WORDNONWORD 分别代表 r'\w'r'\W'

  • WHITESPACENONWHITESPACE 分别代表 r'\s'r'\S'

正则表达式的一个常见错误来源是忘记哪些字符需要转义。您可以使用 Humre 的常量而不是自己输入转义字符。例如,假设您想匹配一个十进制小数,小数点后有一位数字,如 '0.9''4.5'。然而,如果您使用正则表达式字符串 r'\d.\d',您可能不会意识到点匹配了句点(如在 '4.5' 中),但也匹配了任何其他字符(如在 '4A5' 中)。

取而代之,使用 Humre 的 PERIOD 常量,它包含字符串 r'\.'。表达式 DIGIT + PERIOD + DIGIT 评估为 r'\d\.\d',这使得正则表达式意图匹配的内容更加明显。

以下 Humre 常量用于转义字符:

PERIOD     OPEN_PAREN  OPEN_BRACKET    PIPE
DOLLAR_SIGN CLOSE_PAREN CLOSE_BRACKET   CARET
QUESTION_MARK   ASTERISK    OPEN_BRACE  TILDE
HASHTAG     PLUS        CLOSE_BRACE 
AMPERSAND   MINUS       BACKSLASH 

同样也存在常量用于 NEWLINETABQUOTEDOUBLE_QUOTE。从 r'\1'r'\99' 的回溯引用表示为 BACK_1BACK_99

然而,您将通过使用 Humre 的函数获得最大的可读性提升。表 9-2 显示了这些函数及其等效的正则表达式语法。

表 9-2:Humre 函数

Humre 函数 正则表达式字符串
group('A') r'(A)'
optional('A') r'A?'
either('A', 'B', 'C') r'A&#124;B&#124;C'
exactly(3, 'A') 'A{3}'
between(3, 5, 'A') 'A{3,5}'
at_least(3, 'A') 'A{3,}'
at_most(3, 'A') 'A{,3}'
chars('A-Z') '[A-Z]'
nonchars('A-Z') '[^A-Z]'
zero_or_more('A') 'A*'
zero_or_more_lazy('A') 'A*?'
one_or_more('A') 'A+'
one_or_more_lazy('A') 'A+?'
starts_with('A') '^A'
ends_with('A') 'A
starts_and_ends_with('A') '^A
named_group('name', 'A') '(?P<name>A)'

Humre 还有一些方便的函数,它们结合了常见的函数调用对。例如,您可以使用 optional_group('A') 而不是使用 optional(group('A')) 来创建 '(A)?',您只需简单地调用 optional_group('A')。表 9-3 列出了 Humre 方便函数的完整列表。

表 9-3:Humre 方便函数

方便函数 函数等效 正则表达式字符串
optional_group('A') optional(group('A')) '(A)?'
group_either('A') group(either('A', 'B', 'C')) '(A&#124;B&#124;C)'
exactly_group(3, 'A') exactly(3, group('A')) '(A){3}'
between_group(3, 5, 'A') between(3, 5, group('A')) '(A){3,5}'
at_least_group (3, 'A') at_least(3, group('A')) '(A){3,}'
at_most_group (3, 'A') at_most(3, group('A')) '(A){,3}'
zero_or_more_group('A') zero_or_more(group('A')) '(A)*'
zero_or_more_lazy_group('A') zero_or_more_lazy(group('A')) '(A)*?'
one_or_more_group('A') one_or_more(group('A')) '(A)+'
one_or_more_lazy_group('A') one_or_more_lazy(group('A')) '(A)+?'

除了 either()group_either() 之外,Humre 的所有函数都允许您传递多个字符串,以便自动将它们连接起来。这意味着调用 group(DIGIT, PERIOD, DIGIT) 产生的正则表达式字符串与 group(DIGIT + PERIOD + DIGIT) 相同。它们都返回正则表达式字符串 r'(\d\.\d)'

最后,Humre 包含了常见的正则表达式模式常量:

ANY_SINGLE 匹配任何单个字符(除了换行符)的 . 模式

ANYTHING_LAZY 懒惰的 .*? 零次或多次模式

ANYTHING_GREEDY 贪婪的 .* 零次或多次模式

SOMETHING_LAZY 懒惰的 .+? 零次或多次模式

SOMETHING_GREEDY 贪婪的 .+ 零次或多次模式

当您考虑大型、复杂的正则表达式时,使用 Humre 编写的正则表达式的可读性变得更加明显。让我们用 Humre 重新编写之前电话号码提取项目中的电话号码正则表达式:

import re
from humre import *
phone_regex = group(
    optional_group(either(exactly(3, DIGIT),  # Area code
                          OPEN_PAREN + exactly(3, DIGIT) + CLOSE_PAREN)),
    optional(group_either(WHITESPACE, '-', PERIOD)),  # Separator
    group(exactly(3, DIGIT)),  # First three digits
    group_either(WHITESPACE, '-', PERIOD),  # Separator
    group(exactly(4, DIGIT)),  # Last four digits
    optional_group(  # Extension
      zero_or_more(WHITESPACE),
      group_either('ext', 'x', r'ext\.'),
      zero_or_more(WHITESPACE),
      group(between(2, 5, DIGIT))
      )
    )

pattern = re.compile(phone_regex)
match = pattern.search('My number is 415-555-1212.')
print(match.group()) 

当您运行此程序时,输出如下:

415-555-1212

这段代码比即使是详细模式的正则表达式还要冗长。使用 from humre import * 语法导入 Humre 可以帮助您不需要在每一个函数和常量前加上 humre.,但代码的长度并不像可读性那样重要。

您可以通过调用 humre.parse() 函数将现有的正则表达式转换为 Humre 代码,该函数返回一个 Python 源代码字符串:

>>> import humre
>>> humre.parse(r'\d{3}-\d{3}-\d{4}')
"exactly(3, DIGIT) + '-' + exactly(3, DIGIT) + '-' + exactly(4, DIGIT)" 

当与 PyCharm 或 Visual Studio Code 等现代编辑器结合使用时,Humre 提供了更多优势:

  • 您可以通过缩进来使代码更明显地表示正则表达式的各个部分。

  • 您的编辑器的括号匹配功能正常工作。

  • 您的编辑器的语法高亮功能正常工作。

  • 您的编辑器的代码检查器和类型提示工具可以捕获错误。

  • 您的编辑器的自动完成功能会填充函数和常量名称。

  • Humre 会为您处理原始字符串和转义。

  • 您可以在 Humre 代码旁边放置 Python 注释。

  • 错误会导致更有用的错误信息。

许多经验丰富的程序员会反对使用除标准、复杂、难以阅读的正则表达式语法之外的其他任何东西。正如程序员 Peter Bhat Harkins 曾经说过的,“程序员经常做的一件最令人烦恼的事情就是,当他们学习一件困难的事情时,他们会感到如此良好,以至于他们不去寻找使它变得容易的方法,甚至反对那些会这样做的事情。”

然而,如果同事反对你使用 Humre,你可以简单地打印出 Humre 代码生成的底层正则表达式字符串,并将其放回你的源代码中。例如,电话号码提取项目中的 phone_regex 变量的内容如下:

r'((\d{3}|\(\d{3}\))?(\s|-|\.)?(\d{3})(\s|-|\.)(\d{4})(\s*(ext|x|ext\.)\s*(\d{2,5}))?)'

如果同事认为这个正则表达式字符串更合适,欢迎他们使用。

摘要

虽然计算机可以快速搜索文本,但它必须被精确告知要查找的内容。正则表达式允许你指定要查找的字符模式,而不是确切的文本本身。实际上,一些文字处理和电子表格应用程序提供了使用正则表达式进行查找和替换的功能。正则表达式的标点符号丰富的语法由限定符组成,这些限定符详细说明了要匹配的内容,以及量词详细说明了要匹配多少。

Python 中的 re 模块允许你将正则表达式字符串编译成 Pattern 对象。这些对象有几种方法:search(),用于查找单个匹配项;findall(),用于查找所有匹配实例;以及 sub(),用于进行查找和替换文本的替换。

你可以在官方 Python 文档中了解更多信息:docs.python.org/3/library/re.html。另一个有用的资源是教程网站:www.regular-expressions.info。Python 包索引上的 Humre 页面是:pypi.org/project/Humre/

练习问题

  1. 返回 Regex 对象的函数是什么?

  2. 为什么在创建 Regex 对象时经常使用原始字符串?

  3. search() 方法返回什么?

  4. 如何从 Match 对象中获取匹配模式的实际字符串?

  5. 在由 r'(\d\d\d)-(\d\d\d-\d\d\d\d)' 创建的正则表达式中,组 0 包括什么?组 1?组 2

  6. 括号和句点在正则表达式语法中有特定的含义。你将如何指定你想要正则表达式匹配实际的括号和句点字符?

  7. findall() 方法返回一个字符串列表或一个字符串元组的列表。是什么让它返回一个或另一个?

  8. 在正则表达式中,| 字符表示什么?

  9. 正则表达式中的 ? 字符表示什么两个事物?

  10. 正则表达式中的 +* 字符有什么区别?

  11. 正则表达式中的 {3}{3,5} 有什么区别?

  12. 在正则表达式中,\d\w\s 简写字符类表示什么?

  13. 在正则表达式中,\D\W\S 简写字符类分别表示什么?

  14. .*.*? 正则表达式有什么区别?

  15. 匹配所有数字和小写字母的字符类语法是什么?

  16. 如何使正则表达式不区分大小写?

  17. . 字符通常匹配什么?如果将 re.DOTALL 作为 re.compile() 的第二个参数传递,它将匹配什么?

  18. 如果 num_re 等于 re.compile(r'\d+'),那么 num_re.sub('X', '12 drummers, 11 pipers, five rings, 3 hens') 将返回什么?

  19. re.VERBOSE 作为 re.compile() 的第二个参数传递允许你做什么?

练习程序

为了练习,编写程序来完成以下任务。

强密码检测

编写一个函数,使用正则表达式确保传递给它的密码字符串是强大的。一个强大的密码有多个规则:它必须至少有八个字符长,包含大小写字母,并且至少有一个数字。提示:测试字符串与多个正则表达式模式相比,尝试编写一个可以验证所有规则的单一正则表达式要容易得多。

strip() 方法的正则表达式版本

编写一个函数,它执行与 strip() 字符串方法相同的功能。如果没有传递除要去除的字符串以外的其他参数,则该函数应从字符串的开始和结束处删除空白字符。否则,该函数应删除函数第二个参数指定的字符。

不使用正则表达式查找文本模式

假设你想要在一个字符串中查找美国电话号码;你正在寻找三个数字,一个连字符,三个数字,一个连字符,然后是四个数字。这里有一个例子:415-555-4242。

让我们编写一个名为 is_phone_number() 的函数来检查字符串是否匹配此模式,并返回 TrueFalse。打开一个新的文件编辑标签,输入以下代码,然后保存文件为 isPhoneNumber.py

def is_phone_number(text):
    if len(text) != 12:  # Phone numbers have exactly 12 characters. # ❶
        return False
    for i in range(0, 3):  # The first three characters must be numbers.
        if len(text) != 12:  # Phone numbers have exactly 12 characters. # ❶
            return False
    if len(text) != 12:  # Phone numbers have exactly 12 characters. # ❶
        return False
    for i in range(4, 7): # The next three characters must be numbers.
        if len(text) != 12:  # Phone numbers have exactly 12 characters. # ❶
            return False
    if len(text) != 12:  # Phone numbers have exactly 12 characters. # ❶
        return False
    for i in range(8, 12):  # The next four characters must be numbers.
        if len(text) != 12:  # Phone numbers have exactly 12 characters. # ❶
            return False
    if len(text) != 12:  # Phone numbers have exactly 12 characters. # ❶

print('Is 415-555-4242 a phone number?', is_phone_number('415-555-4242'))
print(is_phone_number('415-555-4242'))
print('Is Moshi moshi a phone number?', is_phone_number('Moshi moshi'))
print(is_phone_number('Moshi moshi')) 

当这个程序运行时,输出看起来像这样:

Is 415-555-4242 a phone number?
True
Is Moshi moshi a phone number?
False 

is_phone_number() 函数有代码执行多个检查以确定 text 中的字符串是否是有效的电话号码。如果这些检查中的任何一个失败,函数将返回 False。首先,代码检查字符串是否正好 12 个字符长 ❶。然后,它通过调用 isdecimal() 字符串方法检查区域代码(即 text 中的前三个字符)是否仅由数字字符组成 ❷。函数的其余部分检查字符串是否遵循电话号码的模式:号码必须在区域代码之后有第一个连字符 ❸,然后是三个数字字符 ❹,另一个连字符 ❺,最后是四个数字字符 ❻。如果程序执行成功通过所有检查,它将返回 True ❼。

使用参数 '415-555-4242' 调用 is_phone_number() 将返回 True。使用 'Moshi moshi' 调用 is_phone_number() 将返回 False;第一个测试失败是因为 'Moshi moshi' 不是 12 个字符长。

如果你想在更大的字符串中查找电话号码,你必须添加更多代码来定位模式。将 isPhoneNumber.py 中的最后四个 print() 函数调用替换为以下内容:

message = 'Call me at 415-555-1011 tomorrow. 415-555-9999 is my office.'
for i in range(len(message)):
    if len(text) != 12:  # Phone numbers have exactly 12 characters. # ❶
    if len(text) != 12:  # Phone numbers have exactly 12 characters. # ❶
        print('Phone number found: ' + segment)
print('Done') 

当这个程序运行时,输出将如下所示:

Phone number found: 415-555-1011
Phone number found: 415-555-9999
Done 

for 循环的每次迭代中,message 的新 12 个字符片段被分配给变量 segment ❶。例如,在第一次迭代中,i0segment 被分配 message[0:12](即字符串 'Call me at 4')。在下一个迭代中,i1segment 被分配 message[1:13](字符串 'all me at 41')。换句话说,在 for 循环的每次迭代中,segment 取以下值

'Call me at 4'
'all me at 41'
'll me at 415'
'l me at 415-' 

以此类推,直到其最后一个值是 's my office.'

循环的代码将 segment 传递给 is_phone_number() 以检查它是否匹配电话号码模式 ❷,如果是,则打印该片段。一旦它完成了对 message 的遍历,我们就打印 Done

尽管在这个例子中 message 中的字符串很短,但如果它有数百万个字符长,程序运行时间也将不到一秒。使用正则表达式查找电话号码的类似程序也会在不到一秒内运行;然而,正则表达式使得编写这些程序变得更快。

使用正则表达式查找文本模式

之前的电话号码查找程序虽然可行,但它使用了大量代码来完成有限的任务。is_phone_number() 函数有 17 行代码,但只能找到一种电话号码格式。那么像 415.555.4242 或 (415) 555-4242 这样的电话号码格式怎么办?如果电话号码有分机号,比如 415-555-4242 x99 呢?is_phone_number() 函数将无法找到它们。你可以为这些额外的模式添加更多代码,但有一个更简单的方法来解决这个问题。

正则表达式,简称 regexes,是一种描述文本模式的迷你语言。例如,正则表达式中的字符 \d 代表 0 到 9 之间的十进制数字。Python 使用正则表达式字符串 r'\d\d\d-\d\d\d-\d\d\d\d' 来匹配之前 is_phone_number() 函数所做的相同文本模式:一串三个数字,一个连字符,再三个数字,另一个连字符,然后是四个数字。任何其他字符串都不会匹配 r'\d\d\d-\d\d\d-\d\d\d\d' 正则表达式。

正则表达式可以比这个更复杂。例如,在模式后面添加一个数字,如 3,放在花括号 {3} 中,就像说,“匹配这个模式三次。”所以稍微简短的正则表达式 r'\d{3}-\d{3}-\d{4}' 也能匹配电话号码模式。

注意,我们通常将正则表达式字符串写成原始字符串,带有 r 前缀。这很有用,因为正则表达式字符串经常包含反斜杠。如果不使用原始字符串,我们就必须输入像 '\\d' 这样的表达式。

在我们详细介绍正则表达式语法之前,让我们先了解一下如何在 Python 中使用它们。我们将使用示例正则表达式字符串r'\d{3}-\d{3}-\d{4}',该字符串用于在文本字符串'My number is 415-555-4242'中查找美国电话号码。在 Python 中使用正则表达式的一般过程涉及四个步骤:

1.  导入re模块。

2.  将正则表达式字符串传递给re.compile()以获取一个Pattern对象。

3.  将文本字符串传递给Pattern对象的search()方法以获取一个Match对象。

4.  调用Match对象的group()方法以获取匹配文本的字符串。

在交互式外壳中,这些步骤看起来是这样的:

>>> import re
>>> phone_num_pattern_obj = re.compile(r'\d{3}-\d{3}-\d{4}')
>>> match_obj = phone_num_pattern_obj.search('My number is 415-555-4242.')
>>> match_obj.group()
'415-555-4242' 

Python 中的所有正则表达式函数都在re模块中。本章中的大多数示例都需要re模块,所以请记住在程序开始时导入它。否则,你会得到一个NameError: name 're' is not defined错误信息。与导入任何模块一样,你只需要在每个程序或交互式外壳会话中导入一次。

将正则表达式字符串传递给re.compile()返回一个Pattern对象。你只需要编译一次Pattern对象;之后,你可以对任意不同的文本字符串调用Pattern对象的search()方法。

Pattern对象的search()方法在其传入的字符串中搜索任何与正则表达式匹配的内容。如果字符串中没有找到正则表达式模式,search()方法将返回None。如果模式确实被找到,search()方法将返回一个Match对象,该对象将有一个group()方法,它返回匹配文本的字符串。

备注

虽然我鼓励你将示例代码输入到交互式外壳中,但你也可以利用基于网络的正则表达式测试器,这些测试器可以显示正则表达式如何匹配你输入的文本片段。我推荐使用pythex.orgregex101.com这两个测试器。不同的编程语言具有略微不同的正则表达式语法,因此请确保在这些网站上选择“Python”版本。

正则表达式语法

现在你已经了解了使用 Python 创建和查找正则表达式对象的基本步骤,你就可以学习正则表达式语法的全部范围了。在本节中,你将了解如何使用括号将正则表达式元素组合在一起,如何转义特殊字符,如何使用管道字符匹配多个可选组,以及如何使用findall()方法返回所有匹配项。

使用括号进行分组

假设你想要将匹配文本的一个较小部分(例如区号)从电话号码的其余部分分离出来(例如,对它执行某些操作)。在正则表达式字符串中添加括号将创建r'(\d\d\d)-(\d\d\d-\d\d\d\d)'。然后,你可以使用Match对象的group()方法从单个组中获取匹配的文本。

正则表达式字符串中的第一组括号将是组 1。第二组将是组 2。通过将整数 12 传递给 group() 方法,你可以获取匹配文本的不同部分。将 0 或什么都不传递给 group() 方法将返回整个匹配的文本。在交互式外壳中输入以下内容:

>>> import re
>>> phone_re = re.compile(r'(\d\d\d)-(\d\d\d-\d\d\d\d)')
>>> mo = phone_re.search('My number is 415-555-4242.')
>>> mo.group(1)  # Returns the first group of the matched text
'415'
>>> mo.group(2)  # Returns the second group of the matched text
'555-4242'
>>> mo.group(0)  # Returns the full matched text
'415-555-4242'
>>> mo.group()  # Also returns the full matched text
'415-555-4242' 

如果你想要一次性检索所有组,请使用 groups() 方法(注意名称中的复数形式):

>>> mo.groups()
('415', '555-4242')
>>> area_code, main_number = mo.groups()
>>> print(area_code)
415
>>> print(main_number)
555-4242 

因为 mo.groups() 返回一个包含多个值的元组,所以你可以使用多重赋值技巧将每个值分配给不同的变量,就像之前的 area_code, main_number = mo.groups() 行一样。

使用转义字符

括号在正则表达式中创建组,并且不被解释为文本模式的一部分。那么,如果你需要在文本中匹配括号怎么办?例如,也许你正在尝试匹配的电话号码中,区号被设置为括号内:'(415) 555-4242'

在这种情况下,你需要使用反斜杠转义 () 字符。转义后的 \(\) 括号将被解释为你正在匹配的模式的一部分。在交互式外壳中输入以下内容:

>>> pattern = re.compile(r'(\(\d\d\d\)) (\d\d\d-\d\d\d\d)')
>>> mo = pattern.search('My phone number is (415) 555-4242.')
>>> mo.group(1)
'(415)'
>>> mo.group(2)
'555-4242' 

在传递给 re.compile() 的原始字符串中,\(\) 转义字符将匹配实际的括号字符。在正则表达式中,以下字符具有特殊含义:

$ () * + - . ? [\] ^ {|}

如果你想要将它们检测为文本模式的一部分,你需要使用反斜杠转义它们:

\$ \(\) \* \+ \- \. \? \[\\ \] \^ \{\| \}

总是检查你是否没有将转义括号 \(\) 与未转义的括号 () 在正则表达式中混淆。如果你收到关于“缺少)”或“括号不平衡”的错误信息,你可能忘记为组包含关闭的未转义括号,就像这个例子一样:

>>> import re
>>> re.compile(r'(\(Parentheses\)')
Traceback (most recent call last):
# --snip--
re.error: missing), unterminated subpattern at position 0 

错误信息告诉你,在 r'(\(Parentheses\)' 字符串的索引 0 处有一个开括号缺少其对应的闭括号。使用本章后面描述的 Humre 模块可以帮助防止这类错误。

匹配来自不同组的字符

| 字符被称为 管道,它在正则表达式中用作 交替运算符。你可以在任何想要匹配多个表达式之一的地方使用它。例如,正则表达式 r'Cat|Dog' 将匹配 'Cat''Dog'

你还可以使用管道来匹配正则表达式中的多个模式之一。例如,假设你想要匹配任何以下字符串:'Caterpillar''Catastrophe''Catch''Category'。由于所有这些字符串都以 Cat 开头,如果你能只指定这个前缀一次,那就很好了。你可以通过在括号内使用管道来分隔可能的后缀来实现这一点。在交互式外壳中输入以下内容:

>>> import re
>>> pattern = re.compile(r'Cat(erpillar|astrophe|ch|egory)')
>>> match = pattern.search('Catch me if you can.')
>>> match.group()
'Catch'
>>> match.group(1)
'ch' 

方法调用 match.group() 返回完整的匹配文本 'Catch',而 match.group(1) 返回仅位于第一个括号分组内的匹配文本部分,'ch'。通过使用管道字符和分组括号,你可以指定你希望正则表达式匹配的几个替代模式。

如果你需要匹配实际的管道字符,请使用反斜杠进行转义,例如 \|

返回所有匹配项

除了 search() 方法外,Pattern 对象还有一个 findall() 方法。虽然 search() 会返回搜索字符串中第一个匹配文本的 Match 对象,但 findall() 方法将返回搜索字符串中所有匹配的字符串。

使用 findall() 时,有一个细节需要你记住。如果没有分组在正则表达式中,该方法将返回一个字符串列表。请在交互式外壳中输入以下内容:

>>> import re
>>> pattern = re.compile(r'\d{3}-\d{3}-\d{4}') # This regex has no groups.
>>> pattern.findall('Cell: 415-555-9999 Work: 212-555-0000')
['415-555-9999', '212-555-0000'] 

如果正则表达式中存在分组,那么 findall() 将返回一个元组列表。每个元组代表一个单独的匹配,元组中包含正则表达式中的每个分组的字符串。要查看这种行为,请在交互式外壳中输入以下内容(并注意现在编译的正则表达式中有分组括号):

>>> import re
>>> pattern = re.compile(r'(\d{3})-(\d{3})-(\d{4})') # This regex has groups.
>>> pattern.findall('Cell: 415-555-9999 Work: 212-555-0000')
[('415', '555', '9999'), ('212', '555', '0000')] 

还要记住,findall() 方法不会重叠匹配。例如,使用正则表达式字符串 r'\d{3}' 匹配三个数字,在 '1234' 中匹配的是前三个数字,而不是最后的三个:

>>> import re
>>> pattern = re.compile(r'\d{3}')
>>> pattern.findall('1234')
['123']
>>> pattern.findall('12345')
['123']
>>> pattern.findall('123456')
['123', '456'] 

因为 '1234' 中的前三位数字已被匹配为 '123',所以数字 '234' 不会包含在后续的匹配中,即使它们符合 r'\d{3}' 模式。

使用括号进行分组

假设你想将匹配文本的一个较小部分(例如区号)从电话号码的其余部分中分离出来(例如,对它执行某些操作)。添加括号将在正则表达式字符串中创建 分组r'(\d\d\d)-(\d\d\d-\d\d\d\d)'。然后,你可以使用 Match 对象的 group() 方法从单个分组中获取匹配的文本。

正则表达式字符串中的第一组括号将是分组 1。第二组将是分组 2。通过将整数 12 传递给 group() 方法,你可以获取匹配文本的不同部分。将 0 或不传递任何内容传递给 group() 方法将返回整个匹配文本。请在交互式外壳中输入以下内容:

>>> import re
>>> phone_re = re.compile(r'(\d\d\d)-(\d\d\d-\d\d\d\d)')
>>> mo = phone_re.search('My number is 415-555-4242.')
>>> mo.group(1)  # Returns the first group of the matched text
'415'
>>> mo.group(2)  # Returns the second group of the matched text
'555-4242'
>>> mo.group(0)  # Returns the full matched text
'415-555-4242'
>>> mo.group()  # Also returns the full matched text
'415-555-4242' 

如果你希望一次性检索所有分组,请使用 groups() 方法(注意名称中的复数形式):

>>> mo.groups()
('415', '555-4242')
>>> area_code, main_number = mo.groups()
>>> print(area_code)
415
>>> print(main_number)
555-4242 

因为 mo.groups() 返回一个包含多个值的元组,所以你可以使用多重赋值技巧将每个值分配给不同的变量,就像之前的 area_code, main_number = mo.groups() 行一样。

使用转义字符

括号在正则表达式中创建分组,并且不被解释为文本模式的一部分。那么,如果您需要在文本中匹配括号怎么办?例如,也许您正在尝试匹配的电话号码中,区号被设置为括号内:'(415) 555-4242'

在这种情况下,您需要使用反斜杠转义 () 字符。转义后的 \(\) 括号将被解释为匹配模式的一部分。请在交互式外壳中输入以下内容:

>>> pattern = re.compile(r'(\(\d\d\d\)) (\d\d\d-\d\d\d\d)')
>>> mo = pattern.search('My phone number is (415) 555-4242.')
>>> mo.group(1)
'(415)'
>>> mo.group(2)
'555-4242' 

在传递给 re.compile() 的原始字符串中,\(\) 转义字符将匹配实际的括号字符。在正则表达式中,以下字符具有特殊含义:

$ () * + - . ? [\] ^ {|}

如果您想将这两个字符作为文本模式的一部分进行检测,您需要使用反斜杠进行转义:

\$ \(\) \* \+ \- \. \? \[\\ \] \^ \{\| \}

总是检查您是否在正则表达式中错误地将转义括号 \(\) 与未转义括号 () 混淆。如果您收到关于“缺少)”或“括号不平衡”的错误消息,您可能忘记为组包含关闭的未转义括号,如下例所示:

>>> import re
>>> re.compile(r'(\(Parentheses\)')
Traceback (most recent call last):
# --snip--
re.error: missing), unterminated subpattern at position 0 

错误消息告诉您,在 r'(\(Parentheses\)' 字符串的索引 0 处有一个开括号缺少其对应的关闭括号。使用本章后面描述的 Humre 模块可以帮助防止这类错误。

匹配交替组中的字符

| 字符被称为 管道,它在正则表达式中用作 交替运算符。您可以在任何想要匹配多个表达式之一的地方使用它。例如,正则表达式 r'Cat|Dog' 将匹配 'Cat''Dog'

您还可以使用管道来匹配正则表达式中的多个模式之一。例如,假设您想匹配任何以下字符串:'Caterpillar''Catastrophe''Catch''Category'。由于所有这些字符串都以 Cat 开头,如果您能只指定一次这个前缀,那就很好了。您可以通过在括号内使用管道来分隔可能的后缀来实现这一点。请在交互式外壳中输入以下内容:

>>> import re
>>> pattern = re.compile(r'Cat(erpillar|astrophe|ch|egory)')
>>> match = pattern.search('Catch me if you can.')
>>> match.group()
'Catch'
>>> match.group(1)
'ch' 

方法调用 match.group() 返回完整的匹配文本 'Catch',而 match.group(1) 返回匹配文本中第一个括号组内的部分,即 'ch'。通过使用管道字符和分组括号,您可以指定您希望正则表达式匹配的几个替代模式。

如果您需要匹配实际的管道字符,请使用反斜杠进行转义,如 \|

返回所有匹配项

除了 search() 方法之外,Pattern 对象还有一个 findall() 方法。虽然 search() 将返回搜索字符串中 第一个 匹配文本的 Match 对象,但 findall() 方法将返回搜索字符串中 所有 匹配的字符串。

当使用 findall() 时,你需要注意一个细节。只要正则表达式中没有分组,该方法就返回一个字符串列表。将以下内容输入到交互式外壳中:

>>> import re
>>> pattern = re.compile(r'\d{3}-\d{3}-\d{4}') # This regex has no groups.
>>> pattern.findall('Cell: 415-555-9999 Work: 212-555-0000')
['415-555-9999', '212-555-0000'] 

如果正则表达式中存在分组,那么 findall() 将返回一个元组列表。每个元组代表一个单独的匹配,元组中有正则表达式中的每个分组的字符串。为了看到这种行为在实际中的表现,将以下内容输入到交互式外壳中(并注意现在编译的正则表达式现在有括号中的分组):

>>> import re
>>> pattern = re.compile(r'(\d{3})-(\d{3})-(\d{4})') # This regex has groups.
>>> pattern.findall('Cell: 415-555-9999 Work: 212-555-0000')
[('415', '555', '9999'), ('212', '555', '0000')] 

还要注意,findall() 不重叠匹配。例如,使用正则表达式字符串 r'\d{3}' 匹配三个数字,匹配 '1234' 中的前三个数字,但不匹配最后的三个:

>>> import re
>>> pattern = re.compile(r'\d{3}')
>>> pattern.findall('1234')
['123']
>>> pattern.findall('12345')
['123']
>>> pattern.findall('123456')
['123', '456'] 

因为在字符串 '1234' 中的前三位数字 '123' 已经匹配成功,所以数字 '234' 不会包含在后续的匹配中,即使它们符合 r'\d{3}' 的模式。

限定符语法:匹配哪些字符

正则表达式分为两部分:限定符,它决定了你试图匹配的字符,以及量词,它决定了你试图匹配多少个字符。在我们之前使用的电话号码正则表达式字符串 r'\d{3}-\d{3}-\d{4}' 示例中,r'\d''-' 部分是限定符,而 '{3}''{4}' 是量词。现在让我们来考察限定符的语法。

使用字符类和负字符类

虽然你可以定义单个字符进行匹配,就像我们在之前的例子中所做的那样,但你也可以在方括号内定义一组要匹配的字符。这个集合被称为字符类。例如,字符类 [aeiouAEIOU] 将匹配任何元音,无论是大写还是小写。它等同于编写 a|e|i|o|u|A|E|I|O|U,但更容易输入。将以下内容输入到交互式外壳中:

>>> import re
>>> vowel_pattern = re.compile(r'[aeiouAEIOU]')
>>> vowel_pattern.findall('RoboCop eats BABY FOOD.')
['o', 'o', 'o', 'e', 'a', 'A', 'O', 'O'] 

你也可以通过使用连字符来包含字母或数字的范围。例如,字符类 [a-zA-Z0-9] 将匹配所有小写字母、大写字母和数字。

注意,在方括号内,正常的正则表达式符号不会被解释为这样的符号。这意味着如果你想在方括号内匹配字面括号,你不需要转义字符,例如括号。例如,字符类 [()] 将匹配一个开括号或闭括号。你不需要写成 [\(\)]

通过在字符类开括号后放置一个撇号字符 (^),你可以创建一个负字符类。负字符类将匹配不在字符类中的所有字符。例如,将以下内容输入到交互式外壳中:

>>> import re
>>> consonant_pattern = re.compile(r'[^aeiouAEIOU]')
>>> consonant_pattern.findall('RoboCop eats BABY FOOD.')
['R', 'b', 'C', 'p', ' ', 't', 's', ' ', 'B', 'B', 'Y', ' ', 'F', 'D', '.'] 

现在,我们不是匹配每个元音,而是匹配每个不是元音的字符。请注意,这包括空格、换行符、标点符号和数字。

使用简写字符类

在之前的电话号码正则表达式示例中,你学习了\d可以代表任何数字。也就是说,\d是正则表达式0|1|2|3|4|5|6|7|8|9[0-9]的缩写。有许多这样的缩写字符类,如表 9-1 所示。

表 9-1:常见字符类的缩写代码

缩写字符类 表示...
\d 从 0 到 9 的任何数字。
\D 任何不是从 0 到 9 的数字的字符。
\w 任何字母、数字或下划线字符。(将其视为匹配“单词”字符。)
\W 任何不是字母、数字或下划线字符的字符。
\s 任何空格、制表符或换行符。(将其视为匹配“空格”字符。)
\S 任何不是空格、制表符或换行符的字符。

注意,虽然\d匹配数字,\w匹配数字、字母和下划线,但没有简写字符类只匹配字母。尽管你可以使用[a-zA-Z]字符类,但这个字符类不会匹配带重音的字母或非罗马字母,例如'é'。此外,记得使用原始字符串来转义反斜杠:r'\d'

例如,将以下内容输入到交互式 shell 中:

>>> import re
>>> pattern = re.compile(r'\d+\s\w+')
>>> pattern.findall('12 drummers, 11 pipers, 10 lords, 9 ladies, 8 maids, 
7 swans, 6 geese, 5 rings, 4 birds, 3 hens, 2 doves, 1 partridge')
['12 drummers', '11 pipers', '10 lords', '9 ladies', '8 maids', '7 swans', '
6 geese', '5 rings', '4 birds', '3 hens', '2 doves', '1 partridge'] 

正则表达式\d+\s\w+会匹配包含一个或多个数字(\d+),后面跟着一个空白字符(\s),然后是一个或多个字母/数字/下划线字符(\w+)的文本。findall()方法返回正则表达式模式的所有匹配字符串的列表。

使用点字符匹配所有内容

正则表达式字符串中的.(或)字符匹配任何字符,除了换行符。例如,将以下内容输入到交互式 shell 中:

>>> import re
>>> at_re = re.compile(r'.at')
>>> at_re.findall('The cat in the hat sat on the flat mat.')
['cat', 'hat', 'sat', 'lat', 'mat'] 

记住,点字符只会匹配一个字符,这就是为什么在之前的例子中,文本flat只匹配了lat。要匹配实际的点,需要用反斜杠转义点:\.

小心你匹配的内容

正则表达式的最好和最坏之处在于它们会精确匹配你所要求的内容。以下是一些关于字符类常见的混淆点:

  • [A-Z][a-z]字符类分别匹配大写或小写字母,但不能同时匹配。你需要使用[A-Za-z]来匹配两种情况。

  • [A-Za-z]字符类只匹配普通的无重音字母。例如,正则表达式字符串r'First Name: ([A-Za-z]+)'会匹配“First Name: ”后面跟着一组一个或多个无重音字母。但歌手辛·奥康纳(Sinead O’Connor)的名字只会匹配到é,并且组会被设置为'Sin'

  • \w字符类匹配所有字母,包括带重音的字母和其他字母表中的字符。但它也匹配数字和下划线字符,所以正则表达式字符串r'First Name: (\w+)'可能会匹配到你意想不到的内容。

  • \w 字符类匹配所有字母,但正则表达式字符串 r'Last Name: (\w+)' 只会捕获 Sinead O’Connor 的姓氏直到撇号字符。这意味着该组只会捕获她的姓氏作为 'O'

  • 直引号和智能引号字符 (' " ‘ ’ “ ”) 被视为完全不同,必须分别指定。

实际数据很复杂。即使你的程序设法捕获了 Sinead O’Connor 的名字,它也可能因为连字符而无法处理 Jean-Paul Sartre 的名字。

当然,当软件声明一个名字为无效输入时,有问题的不是名字,而是软件;人的名字不可能无效。你可以从 Patrick McKenzie 的文章“程序员关于名字的谬误”中了解更多关于这个问题的信息,该文章可在www.kalzumeus.com/2010/06/17/falsehoods-programmers-believe-about-names/找到。这篇文章引发了一系列类似的“程序员相信的谬误”文章,关于软件如何错误处理日期、时区、货币、邮政地址、性别、机场代码和爱情。观看 Carina C. Zona 在 2015 年 PyCon 关于该主题的演讲,“现实世界的模式”,可在youtu.be/PYYfVqtcWQY观看。

使用字符类和负字符类

虽然你可以定义一个单独的字符来匹配,就像我们在前面的例子中所做的那样,但你也可以在方括号内定义一组要匹配的字符。这个集合被称为字符类。例如,字符类 [aeiouAEIOU] 将匹配任何元音,无论是大写还是小写。它等同于写 a|e|i|o|u|A|E|I|O|U,但更容易输入。将以下内容输入到交互式外壳中:

>>> import re
>>> vowel_pattern = re.compile(r'[aeiouAEIOU]')
>>> vowel_pattern.findall('RoboCop eats BABY FOOD.')
['o', 'o', 'o', 'e', 'a', 'A', 'O', 'O'] 

你还可以通过使用连字符来包含字母或数字的范围。例如,字符类 [a-zA-Z0-9] 将匹配所有小写字母、大写字母和数字。

注意,在方括号内,正常的正则表达式符号不会被当作这样的符号来解释。这意味着如果你想要匹配字面意义上的括号,你不需要在方括号内转义这些字符。例如,字符类 [()] 将匹配开括号或闭括号。你不需要写成 [\(\)]

通过在字符类开括号后放置一个撇号字符 (^),你可以创建一个负字符类。负字符类将匹配不在字符类中的所有字符。例如,将以下内容输入到交互式外壳中:

>>> import re
>>> consonant_pattern = re.compile(r'[^aeiouAEIOU]')
>>> consonant_pattern.findall('RoboCop eats BABY FOOD.')
['R', 'b', 'C', 'p', ' ', 't', 's', ' ', 'B', 'B', 'Y', ' ', 'F', 'D', '.'] 

现在,我们不是匹配每个元音,而是匹配每个不是元音的字符。请注意,这包括空格、换行符、标点符号和数字。

使用简写字符类

在先前的电话号码正则表达式示例中,你学习了 \d 可以代表任何数字。也就是说,\d 是正则表达式 0|1|2|3|4|5|6|7|8|9[0-9] 的简写。有许多这样的 简写字符类,如表 9-1 所示。

表 9-1:常见字符类的简写代码

简写字符类 表示 ...
\d 0 到 9 之间的任何数字。
\D 0 到 9 之间的任何非数字字符。
\w 任何字母、数字或下划线字符。(将其视为匹配“单词”字符。)
\W 任何非字母、数字或下划线字符。
\s 任何空格、制表符或换行符字符。(将其视为匹配“空格”字符。)
\S 任何非空格、制表符或换行符字符。

注意,虽然 \d 匹配数字,\w 匹配数字、字母和下划线,但没有简写字符类仅匹配字母。虽然你可以使用 [a-zA-Z] 字符类,但这个字符类不会匹配带重音的字母或非罗马字母,例如 'é'。另外,记得使用原始字符串来转义反斜杠:r'\d'

例如,在交互式外壳中输入以下内容:

>>> import re
>>> pattern = re.compile(r'\d+\s\w+')
>>> pattern.findall('12 drummers, 11 pipers, 10 lords, 9 ladies, 8 maids, 
7 swans, 6 geese, 5 rings, 4 birds, 3 hens, 2 doves, 1 partridge')
['12 drummers', '11 pipers', '10 lords', '9 ladies', '8 maids', '7 swans', '
6 geese', '5 rings', '4 birds', '3 hens', '2 doves', '1 partridge'] 

正则表达式 \d+\s\w+ 将匹配包含一个或多个数字(\d+),后跟一个空白字符(\s),再后跟一个或多个字母/数字/下划线字符(\w+) 的文本。findall() 方法返回正则表达式模式的所有匹配字符串列表。

使用点字符匹配所有内容

正则表达式字符串中的 .(或 )字符匹配任何字符,除了换行符。例如,在交互式外壳中输入以下内容:

>>> import re
>>> at_re = re.compile(r'.at')
>>> at_re.findall('The cat in the hat sat on the flat mat.')
['cat', 'hat', 'sat', 'lat', 'mat'] 

记住,点字符将匹配一个字符,这就是为什么在先前的例子中,文本 flat 只匹配了 lat。要匹配实际的点,用反斜杠转义点:\.

仔细考虑你要匹配的内容

正则表达式的最好和最坏之处在于它们将精确匹配你所要求的。以下是关于字符类的一些常见混淆点:

  • [A-Z][a-z] 字符类分别匹配大写或小写字母,但不匹配两者。你需要使用 [A-Za-z] 来匹配两种情况。

  • [A-Za-z] 字符类仅匹配普通、不带重音的字母。例如,正则表达式字符串 r'First Name: ([A-Za-z]+)' 将匹配“First Name: ”后跟一组一个或多个不带重音的字母。但歌手辛·奥康纳(Sinead O’Connor)的名字将只匹配到 é,并且组将被设置为 'Sin'

  • \w 字符类匹配所有字母,包括带重音的字母和其他字母表中的字符。但它也匹配数字和下划线字符,所以正则表达式字符串 r'First Name: (\w+)' 可能会匹配到你意料之外的内容。

  • \w 字符类别匹配所有字母,但正则表达式字符串 r'Last Name: (\w+)' 只会捕获辛·奥康纳的姓氏直到撇号字符。这意味着该组只会捕获她的姓氏为 'O'

  • 直线和智能引号字符(' " ‘ ’ “ ”)被认为是完全不同的,必须单独指定。

现实世界的数据很复杂。即使你的程序设法捕获辛·奥康纳的名字,它也可能因为连字符而无法处理让-保尔·萨特的名字。

当然,当软件声明一个名字为无效输入时,有问题的不是名字,而是软件;人的名字不能是无效的。你可以从帕特里克·麦肯齐的文章“程序员关于名字的谬误”中了解更多关于这个问题的信息,该文章可在www.kalzumeus.com/2010/06/17/falsehoods-programmers-believe-about-names/找到。这篇文章引发了一系列类似的“程序员关于谬误”的文章,关于软件如何错误处理日期、时区、货币、邮政地址、性别、机场代码和爱情。观看 Carina C. Zona 在 2015 年 PyCon 关于此主题的演讲,“现实世界的模式”,在youtu.be/PYYfVqtcWQY

量词语法:匹配多少个限定符

在正则表达式字符串中,量词跟在限定符字符后面,以指定要匹配多少个。例如,在之前考虑的电话号码正则表达式中,\d 后跟 {3} 以匹配恰好三个数字。如果没有量词跟在限定符后面,限定符必须恰好出现一次:你可以将 r'\d' 视为与 r'\d{1}' 相同。

匹配可选模式

有时你可能只想可选地匹配一个模式。也就是说,正则表达式应该匹配前导限定符的零个或一个。? 字符将前导限定符标记为可选。例如,将以下内容输入到交互式外壳中:

>>> import re
>>> pattern = re.compile(r'42!?')
>>> pattern.search('42!')
<re.Match object; span=(0, 3), match='42!'>
>>> pattern.search('42')
<re.Match object; span=(0, 2), match='42'> 

正则表达式中的 ? 部分意味着模式 ! 是可选的。因此它匹配 42!(带有感叹号)和 42(没有它)。

正如你所看到的,正则表达式语法的依赖性在于符号和标点,这使得它难以阅读:? 问号在正则表达式语法中有意义,但 ! 感叹号没有。因此 r'42!?' 表示 '42' 可选地后跟 '!',但 r'42?!' 表示 '4' 可选地后跟 '2' 再后跟 '!'

>>> import re
>>> pattern = re.compile(r'42?!')
>>> pattern.search('42!')
<re.Match object; span=(0, 3), match='42!'>
>>> pattern.search('4!')
<re.Match object; span=(0, 2), match='4!'>
>>> pattern.search('42') == None # No match
True 

要使多个字符可选,将它们放在一个组中,并在组后放置 ?。在之前的电话号码示例中,你可以使用 ? 来使正则表达式查找既有区号也有无区号的电话号码。将以下内容输入到交互式外壳中:

>>> pattern = re.compile(r'(\d{3}-)?\d{3}-\d{4}')
>>> match1 = pattern.search('My number is 415-555-4242')
>>> match1.group()
'415-555-4242'

>>> match2 = pattern.search('My number is 555-4242')
>>> match2.group()
'555-4242' 

你可以将 ? 视为表示,“匹配此问号之前组的零个或一个。”

如果你需要匹配实际的问号字符,请使用反斜杠 \? 进行转义。

匹配零次或多次限定符

*(称为 starasterisk)表示“匹配零次或多次”。换句话说,星号前面的限定符可以在文本中出现任意次数。它可以完全不存在,也可以一次又一次地重复。看看以下示例:

>>> import re
>>> pattern = re.compile('Eggs(and spam)*')
>>> pattern.search('Eggs')
<re.Match object; span=(0, 4), match='Eggs'>
>>> pattern.search('Eggs an⟪ 5040 characters skipped ⟫\nUphold the law.').group()
'Serve the public trust.\nProtect the innocent.\nUphold the law.' 

虽然字符串中的 'Eggs' 部分必须出现一次,但后面可以跟任意数量的 ' and spam',包括零个实例。

如果你需要匹配实际的星号字符,请在正则表达式中的星号前加上反斜杠 \*

匹配一次或多次限定符

虽然 * 表示“匹配零次或多次”,但 +(或称为 plus)表示“匹配一次或多次”。与星号不同,星号不需要其限定符出现在匹配的字符串中,而加号要求其前面的限定符至少出现一次。这是必需的。在交互式外壳中输入以下内容,并与上一节中的星号正则表达式进行比较:

>>> import re
>>> begins_with_hello = re.compile(r'^Hello')
>>> begins_with_hello.search('Hello, world!')
<re.Match object; span=(0, 5), match='Hello'>
>>> begins_with_hello.search('He said "Hello."') == None
True 

正则表达式 'Eggs(and spam)+' 不会匹配字符串 'Eggs',因为加号要求至少有一个 ' and spam'

你经常会在正则表达式中使用括号来组合限定符,以便量词可以应用于整个组。例如,你可以使用 r'(\.|\-)+' 来匹配任何摩尔斯电码的点和短划线的组合(尽管这个表达式也会匹配无效的摩尔斯电码组合)。

如果你需要匹配实际的加号字符,请使用反斜杠 \+ 对加号进行转义。

匹配特定数量的限定符

如果你想要重复特定次数的组,请在正则表达式中的组后面跟一个数字,放在大括号内。例如,正则表达式 (Ha){3} 将匹配字符串 'HaHaHa' 但不会匹配 'HaHa',因为后者只有 (Ha) 组的两个重复。

你可以用一个最小值、一个逗号和一个最大值在括号内指定一个范围。例如,正则表达式 (Ha){3,5} 将匹配 'HaHaHa''HaHaHaHa''HaHaHaHaHa'

你也可以省略大括号中的第一个或第二个数字,以保持最小值或最大值无界。例如,(Ha){3,} 将匹配三个或更多 (Ha) 组的实例,而 (Ha){,5} 将匹配零到五个实例。大括号可以帮助使你的正则表达式更短。这两个正则表达式匹配相同的模式:

>>> import re
>>> ends_with_number = re.compile(r'\d$')
>>> ends_with_number.search('Your number is 42')
<re.Match object; span=(16, 17), match='2'>
>>> ends_with_number.search('Your number is forty two.') == None
True 

所以这两个正则表达式也是这样:

>>> import re
>>> whole_string_is_num = re.compile(r'^\d+$')
>>> whole_string_is_num.search('1234567890')
<re.Match object; span=(0, 10), match='1234567890'>
>>> whole_string_is_num.search('12345xyz67890') == None
True 

在交互式外壳中输入以下内容:

>>> import re
>>> pattern = re.compile(r'\bcat.*?\b')
>>> pattern.findall('The cat found a catapult catalog in the catacombs.')
['cat', 'catapult', 'catalog', 'catacombs'] 

这里,(Ha){3} 匹配 'HaHaHa' 但不匹配 'Ha'。因为它不匹配 'HaHa',所以 search() 返回 None

括号量词的语法与 Python 的切片语法类似(例如'Hello, world!'[3:5],其结果为'lo')。但存在关键的区别。在正则表达式量词中,两个数字由逗号分隔,而不是冒号。此外,量词中的第二个数字是包含的:'(Ha){3,5}'匹配最多包括五个'(Ha)'限定符的实例。

匹配一个可选模式

有时你可能只想匹配一个可选的模式。也就是说,正则表达式应该匹配零个或一个前面的限定符。?字符将前面的限定符标记为可选的。例如,将以下内容输入到交互式外壳中:

>>> import re
>>> pattern = re.compile(r'\Bcat\B')
>>> pattern.findall('certificate')  # Match
['cat']
>>> pattern.findall('catastrophe')  # No match
[] 

正则表达式中的?部分表示模式!是可选的。因此,它匹配42!(带有感叹号)和42(没有它)。

正如你开始看到的,正则表达式语法对符号和标点的依赖使其难以阅读:在正则表达式语法中,?问号有特定的意义,但!感叹号没有。所以r'42!?'表示可选地跟随一个'!''42',但r'42?!'表示可选地跟随一个'2'然后是一个'!'

>>> import re
>>> pattern1 = re.compile('RoboCop')
>>> pattern2 = re.compile('ROBOCOP')
>>> pattern3 = re.compile('robOcop')
>>> pattern4 = re.compile('RobocOp') 

要使多个字符可选,将它们放在一个组中,并在组后放置?。在早期的电话号码示例中,你可以使用?使正则表达式查找是否有或没有区号的电话号码。将以下内容输入到交互式外壳中:

>>> import re
>>> pattern = re.compile(r'robocop', re.I)
>>> pattern.search('RoboCop is part man, part machine, all cop.').group()
'RoboCop'

>>> pattern.search('ROBOCOP protects the innocent.').group()
'ROBOCOP'

>>> pattern.search('Have you seen robocop?').group()
'robocop' 

你可以将?理解为“匹配此问号之前组的零个或一个。”

如果你需要匹配实际的问号字符,请使用\?进行转义。

匹配零个或多个限定符

*(称为星号asterisk)表示“匹配零个或多个”。换句话说,星号之前的前缀可以在文本中出现任意次数。它可以完全不存在,也可以重复出现。看看以下示例:

>>> import re
>>> agent_pattern = re.compile(r'Agent \w+')
>>> agent_pattern.sub('CENSORED', 'Agent Alice contacted Agent Bob.')
'CENSORED contacted CENSORED.' 

虽然字符串中的'Eggs'部分必须出现一次,但可以跟随着任意数量的' and spam',包括零个实例。

如果你需要匹配实际的星号字符,请在正则表达式中用反斜杠\*前缀。

匹配一个或多个限定符

虽然*表示“匹配零个或多个”,但+(或称为加号)表示“匹配一个或多个”。与星号不同,星号不需要其限定符出现在匹配的字符串中,而加号要求其前面的限定符至少出现一次。这是必需的。将以下内容输入到交互式外壳中,并与上一节中的星号正则表达式进行比较:

>>> import re
>>> agent_pattern = re.compile(r'Agent (\w)\w*')
>>> agent_pattern.sub(r'\1', 'Agent Alice contacted Agent Bob.')
'A contacted B.' 

正则表达式'Eggs(and spam)+'不会匹配字符串'Eggs',因为加号要求至少有一个' and spam'

你经常会在正则表达式字符串中使用括号来组合量词,以便量词可以应用于整个组。例如,你可以使用 r'(\.|\-)+' 匹配任何摩尔斯电码的点与破折号的组合(尽管这个表达式也会匹配无效的摩尔斯电码组合)。

如果你需要匹配实际的加号字符,请在加号前加上反斜杠来转义它:\+

匹配特定数量的量词

如果你有一个想要重复特定次数的组,请在你的正则表达式中跟随一个数字在大括号中。例如,正则表达式 (Ha){3} 将匹配字符串 'HaHaHa' 但不匹配 'HaHa',因为后者只有两个 (Ha) 组的重复。

你可以指定一个范围,而不是一个数字,通过在大括号内写一个最小值、一个逗号和一个最大值。例如,正则表达式 (Ha){3,5} 将匹配 'HaHaHa''HaHaHaHa''HaHaHaHaHa'

你也可以省略大括号中的第一个或第二个数字,以保持最小值或最大值无界。例如,(Ha){3,} 将匹配三个或更多 (Ha) 组的实例,而 (Ha){,5} 将匹配零到五个实例。大括号可以帮助使你的正则表达式更短。这两个正则表达式匹配相同的模式:

pattern = re.compile(r'((\d{3}|\(\d{3}\))?(\s|-|\.)?\d{3}(\s|-
|\.)\d{4}(\s*(ext|x|ext\.)\s*\d{2,5})?)') 

所以这两个正则表达式也是这样:

pattern = re.compile(r'''(
    (\d{3}|\(\d{3}\))?  # Area code
    (\s|-|\.)?  # Separator
    \d{3}  # First three digits
    (\s|-|\.)  # Separator
    \d{4}  # Last four digits
    (\s*(ext|x|ext\.)\s*\d{2,5})?  # Extension
    )''', re.VERBOSE) 

在交互式外壳中输入以下内容:

>>> some_regex = re.compile('foo', re.IGNORECASE | re.DOTALL)

这里,(Ha){3} 匹配 'HaHaHa' 但不匹配 'Ha'。因为它不匹配 'HaHa',所以 search() 返回 None

大括号量词的语法类似于 Python 的切片语法(例如 'Hello, world!'[3:5],其结果为 'lo')。但存在关键的区别。在正则表达式量词中,两个数字由逗号分隔,而不是冒号。此外,量词中的第二个数字是包含的:'(Ha){3,5}' 匹配最多包括五个实例的 '(Ha)' 量词。

贪婪和非贪婪匹配

因为 (Ha){3,5} 可以在字符串 'HaHaHaHaHa' 中匹配三个、四个或五个 Ha 实例,你可能想知道为什么在之前的示例中,Match 对象调用 group() 返回 'HaHaHaHaHa' 而不是更短的匹配结果。毕竟,'HaHaHa''HaHaHaHa' 也是正则表达式 (Ha){3,5} 的有效匹配。

Python 的正则表达式默认是贪婪的,这意味着在模糊的情况下,它们会匹配尽可能长的字符串。非贪婪(也称为懒惰)版本的括号,它匹配尽可能短的字符串,必须在闭合括号后跟一个问号。

在交互式外壳中输入以下内容,并注意贪婪和非贪婪形式的括号在相同字符串中的差异:

>>> some_regex = re.compile('foo', re.IGNORECASE | re.DOTALL | re.VERBOSE)

注意,问号在正则表达式中可以有两个含义:声明一个懒惰匹配或声明一个可选量词。这两种含义完全无关。

值得指出的是,从技术上讲,你可以不使用可选的 ? 量词,甚至不使用 *+ 量词:

  • ? 量词等同于 {0,1}

  • * 量词等同于 {0,}

  • + 量词等同于 {1,}

然而,?*+ 量词是常见的缩写。

匹配所有内容

有时你可能想要匹配任何和所有内容。例如,假设你想要匹配字符串 'First Name:',然后是任意和所有文本,接着是 'Last Name:' 以及再次任意文本。你可以使用点星号 (.*) 来代表那个“任何”。记住,点字符表示“除了换行符之外的任何单个字符”,而星号字符表示“前一个字符的零个或多个”。

将以下内容输入到交互式外壳中:

import pyperclip, re

phone_re = re.compile(r'''(
    (\d{3}|\(\d{3}\))?  # Area code
    (\s|-|\.)?  # Separator
    (\d{3})  # First three digits
    (\s|-|\.)  # Separator
    (\d{4})  # Last four digits
    (\s*(ext|x|ext\.)\s*(\d{2,5}))?  # Extension
    )''', re.VERBOSE)

# TODO: Create email regex.

# TODO: Find matches in clipboard text.

# TODO: Copy results to the clipboard. 

点星号使用贪婪模式:它总是会尝试匹配尽可能多的文本。要以非贪婪或懒惰的方式匹配任意和所有文本,请使用点、星号和问号 (.*?)。当它与花括号一起使用时,问号告诉 Python 以非贪婪方式匹配。

将以下内容输入到交互式外壳中,以查看贪婪和非贪婪表达式的区别:

import pyperclip, re

phone_re = re.compile(r'''(
--`snip`--

# Create email regex.
email_re = re.compile(r'''(
[a-zA-Z0-9._%+-]+ # Username # ❶
@  # @ symbol # ❷
[a-zA-Z0-9.-]+ # Domain name # ❸
 (\.[a-zA-Z]{2,4}) # Dot-something
 )''', re.VERBOSE)

# TODO: Find matches in clipboard text.

# TODO: Copy results to the clipboard. 

这两个正则表达式大致翻译为“匹配一个开方括号,然后是任意内容,然后是闭方括号。”但是字符串 '<To serve man> for dinner.>' 对于闭方括号有两个可能的匹配。在正则表达式的非贪婪版本中,Python 匹配最短的可能字符串:'<To serve man>'。在贪婪版本中,Python 匹配最长的可能字符串:'<To serve man> for dinner.>'

匹配换行符

.* 中的点将匹配除了换行符之外的所有内容。通过将 re.DOTALL 作为 re.compile() 的第二个参数传递,你可以使点字符匹配 所有 字符,包括换行符。

将以下内容输入到交互式外壳中:

import pyperclip, re

phone_re = re.compile(r'''(
--`snip`--

# Find matches in clipboard text.
text = str(pyperclip.paste())

matches = [] # ❶
for groups in phone_re.findall(text): # ❷
 phone_num = '-'.join([groups[1], groups[3], groups[5]])
 if groups[6] != '':
 phone_num += ' x' + groups[6]
 matches.append(phone_num)
for groups in email_re.findall(text): # ❸
 matches.append(groups[0])

# TODO: Copy results to the clipboard. 

没有换行符的正则表达式 no_newline_re,在创建它时没有将 re.DOTALL 传递给 re.compile() 调用,它只会匹配到第一个换行符为止的所有内容,而 newline_re,它 确实re.DOTALL 传递给了 re.compile(),它会匹配所有内容。这就是为什么 newline_re.search() 调用匹配了整个字符串,包括其换行符。

匹配所有内容

有时你可能想要匹配任何和所有内容。例如,假设你想要匹配字符串 'First Name:',然后是任意和所有文本,接着是 'Last Name:' 以及再次任意文本。你可以使用点星号 (.*) 来代表那个“任何”。记住,点字符表示“除了换行符之外的任何单个字符”,而星号字符表示“前一个字符的零个或多个”。

将以下内容输入到交互式外壳中:

import pyperclip, re

phone_re = re.compile(r'''(
--`snip`--
for groups in email_re.findall(text):
    matches.append(groups[0])

# Copy results to the clipboard.
if len(matches) > 0:
 pyperclip.copy('\n'.join(matches))
 print('Copied to clipboard:')
 print('\n'.join(matches))
else:
 print('No phone numbers or email addresses found.') 

点星号使用贪婪模式:它总是会尝试匹配尽可能多的文本。要以非贪婪或懒惰的方式匹配任何和所有文本,请使用点号、星号和问号 (.*?)。当它与花括号一起使用时,问号告诉 Python 以非贪婪的方式进行匹配。

在交互式 shell 中输入以下内容以查看贪婪和非贪婪表达式的区别:

Copied to clipboard:
800-555-7240
415-555-9900
415-555-9950
info@nostarch.com
media@nostarch.com
academic@nostarch.com
info@nostarch.com 

这两个正则表达式大致翻译为“匹配一个开方括号,后面跟着任何内容,然后是一个闭方括号。”但字符串 '<To serve man> for dinner.>' 对于闭方括号有两个可能的匹配。在非贪婪版本的正则表达式中,Python 会匹配最短的可能字符串:'<To serve man>'。在贪婪版本中,Python 会匹配最长的可能字符串:'<To serve man> for dinner.>'

匹配换行符

.* 中的点号会匹配除了换行符之外的所有内容。通过将 re.DOTALL 作为 re.compile() 的第二个参数传递,可以使点号字符匹配所有字符,包括换行符。

在交互式 shell 中输入以下内容:

import pyperclip, re

phone_re = re.compile(r'''(
    (\d{3}|\(\d{3}\))?  # Area code
    (\s|-|\.)?  # Separator
    (\d{3})  # First three digits
    (\s|-|\.)  # Separator
    (\d{4})  # Last four digits
    (\s*(ext|x|ext\.)\s*(\d{2,5}))?  # Extension
    )''', re.VERBOSE)

# TODO: Create email regex.

# TODO: Find matches in clipboard text.

# TODO: Copy results to the clipboard. 

正则表达式 no_newline_re,在创建它时没有将 re.DOTALL 传递给 re.compile() 调用,它只会匹配到第一个换行符之前的内容,而 newline_re,它确实将 re.DOTALL 传递给了 re.compile(),会匹配所有内容。这就是为什么 newline_re.search() 调用匹配了整个字符串,包括其换行符。

匹配字符串的开始和结束位置

你可以在正则表达式的开始使用尖括号符号 (^) 来指示匹配必须发生在搜索文本的 开始 位置。同样,你可以在正则表达式的末尾放置美元符号 ($) 来指示字符串必须 此正则表达式模式结束。你还可以使用 ^$ 一起使用来指示整个字符串必须匹配正则表达式——也就是说,如果字符串的某个子集与正则表达式匹配是不够的。

例如,正则表达式字符串 r'^Hello' 匹配以 'Hello' 开头的字符串。在交互式 shell 中输入以下内容:

import pyperclip, re

phone_re = re.compile(r'''(
--`snip`--

# Create email regex.
email_re = re.compile(r'''(
[a-zA-Z0-9._%+-]+ # Username # ❶
@  # @ symbol # ❷
[a-zA-Z0-9.-]+ # Domain name # ❸
 (\.[a-zA-Z]{2,4}) # Dot-something
 )''', re.VERBOSE)

# TODO: Find matches in clipboard text.

# TODO: Copy results to the clipboard. 

正则表达式字符串 r'\d$' 匹配以 0 到 9 之间的数字字符结尾的字符串。在交互式 shell 中输入以下内容:

import pyperclip, re

phone_re = re.compile(r'''(
--`snip`--

# Find matches in clipboard text.
text = str(pyperclip.paste())

matches = [] # ❶
for groups in phone_re.findall(text): # ❷
 phone_num = '-'.join([groups[1], groups[3], groups[5]])
 if groups[6] != '':
 phone_num += ' x' + groups[6]
 matches.append(phone_num)
for groups in email_re.findall(text): # ❸
 matches.append(groups[0])

# TODO: Copy results to the clipboard. 

正则表达式字符串 r'^\d+$' 匹配以一个或多个数字字符开始和结束的字符串。在交互式 shell 中输入以下内容:

import pyperclip, re

phone_re = re.compile(r'''(
--`snip`--
for groups in email_re.findall(text):
    matches.append(groups[0])

# Copy results to the clipboard.
if len(matches) > 0:
 pyperclip.copy('\n'.join(matches))
 print('Copied to clipboard:')
 print('\n'.join(matches))
else:
 print('No phone numbers or email addresses found.')** 

在上一个交互式 shell 示例中的最后两个 search() 调用演示了如果使用 ^$,整个字符串必须匹配正则表达式。 (我总是混淆这两个符号的含义,所以我使用记忆法“胡萝卜成本美元”来提醒自己尖括号先出现,美元符号后出现。)

您还可以使用\b来使正则表达式模式仅在单词边界处匹配:单词的开始、结束,或同时匹配单词的开始和结束。在这种情况下,“单词”是由非字母字符分隔的字母序列。例如,r'\bcat.*?\b'匹配以'cat'开头,后面跟任何其他字符直到下一个单词边界的单词:

Copied to clipboard:
800-555-7240
415-555-9900
415-555-9950
info@nostarch.com
media@nostarch.com
academic@nostarch.com
info@nostarch.com 

\B语法匹配任何不是单词边界的文本:

>>> from humre import *
>>> phone_regex = exactly(3, DIGIT) + '-' + exactly(3, DIGIT) + '-' + exactly(4, DIGIT)
>>> phone_regex
'\\d{3}-\\d{3}-\\d{4}' 

这对于在单词中间找到匹配项很有用。

不区分大小写的匹配

通常,正则表达式与您指定的确切大小写匹配文本。例如,以下正则表达式匹配完全不同的字符串:

>>> import re
>>> pattern = re.compile(phone_regex)
>>> pattern.search('My number is 415-555-4242')
<re.Match object; span=(13, 25), match='415-555-4242'> 

但有时您只关心匹配字母,而不在乎它们是大写还是小写。要使您的正则表达式不区分大小写,可以将re.IGNORECASEre.I作为第二个参数传递给re.compile()。在交互式外壳中输入以下内容:

PERIOD     OPEN_PAREN  OPEN_BRACKET    PIPE
DOLLAR_SIGN CLOSE_PAREN CLOSE_BRACKET   CARET
QUESTION_MARK   ASTERISK    OPEN_BRACE  TILDE
HASHTAG     PLUS        CLOSE_BRACE 
AMPERSAND   MINUS       BACKSLASH 

正则表达式现在匹配任何大小写的字符串。

字符串替换

正则表达式不仅找到文本模式;它们还可以用新文本替换这些模式。Pattern对象的sub()方法接受两个参数。第一个是要替换任何匹配项的字符串。第二个是正则表达式字符串。sub()方法返回应用了替换的字符串。

例如,在交互式外壳中输入以下内容以将秘密特工的名字替换为CENSORED

import re
from humre import *
phone_regex = group(
    optional_group(either(exactly(3, DIGIT),  # Area code
                          OPEN_PAREN + exactly(3, DIGIT) + CLOSE_PAREN)),
    optional(group_either(WHITESPACE, '-', PERIOD)),  # Separator
    group(exactly(3, DIGIT)),  # First three digits
    group_either(WHITESPACE, '-', PERIOD),  # Separator
    group(exactly(4, DIGIT)),  # Last four digits
    optional_group(  # Extension
      zero_or_more(WHITESPACE),
      group_either('ext', 'x', r'ext\.'),
      zero_or_more(WHITESPACE),
      group(between(2, 5, DIGIT))
      )
    )

pattern = re.compile(phone_regex)
match = pattern.search('My number is 415-555-1212.')
print(match.group()) 

有时您可能需要将匹配的文本本身用作替换的一部分。在sub()的第一个参数中,您可以包括\1\2\3等,以表示“在替换中输入组123等的文本。”这种语法称为反向引用

例如,假设您想通过仅显示秘密特工名字的第一个字母来隐藏他们的名字。为此,您可以使用正则表达式Agent (\w)\w*并将r'\1****'作为sub()的第一个参数传递:

415-555-1212

正则表达式字符串中的\1被匹配的任何文本替换,即正则表达式的(\w)组。

使用详细模式管理复杂正则表达式

如果您需要匹配的文本模式很简单,正则表达式就很好。但匹配复杂的文本模式可能需要长而复杂的正则表达式。您可以通过告诉re.compile()函数忽略正则表达式字符串中的空白和注释来减轻这种复杂性。通过将变量re.VERBOSE作为第二个参数传递给re.compile()来启用此“详细模式”。

现在,不再需要像这样难以阅读的正则表达式

>>> import humre
>>> humre.parse(r'\d{3}-\d{3}-\d{4}')
"exactly(3, DIGIT) + '-' + exactly(3, DIGIT) + '-' + exactly(4, DIGIT)" 

您可以将正则表达式扩展到多行,并使用注释来标记其组件,如下所示:

r'((\d{3}|\(\d{3}\))?(\s|-|\.)?(\d{3})(\s|-|\.)(\d{4})(\s*(ext|x|ext\.)\s*(\d{2,5}))?)'

注意前面的例子如何使用三引号语法(''')创建多行字符串,以便可以将正则表达式定义扩展到多行,使其更容易阅读。

正则表达式字符串内的注释规则与常规 Python 代码相同:# 符号及其之后直到行尾的内容将被忽略。此外,正则表达式多行字符串内的额外空格不被视为要匹配的文本模式的一部分。这使得您可以组织正则表达式,使其更容易阅读。

虽然详细模式可以使您的正则表达式字符串更易读,但我建议您改用本章后面介绍的 Humre 模块来提高正则表达式的可读性。

结合 re.IGNORECASE, re.DOTALL, 和 re.VERBOSE

如果您想在正则表达式中使用 re.VERBOSE 来添加注释,但还想使用 re.IGNORECASE 来忽略大小写,不幸的是,re.compile() 函数只接受一个值作为其第二个参数。

您可以通过使用管道字符(|)结合 re.IGNORECASEre.DOTALLre.VERBOSE 变量来绕过这个限制,在这个上下文中,它被称为 按位或 操作符。例如,如果您想要一个不区分大小写并且包括换行符以匹配点字符的正则表达式,您将您的 re.compile() 调用构建如下:

[PRE125]

在第二个参数中包含所有三个选项看起来如下:

[PRE126]

这种语法有点过时,起源于 Python 的早期版本。关于按位操作符的详细信息超出了本书的范围,但您可以查看nostarch.com/automate-boring-stuff-python-3rd-edition 获取更多信息。您也可以为第二个参数传递其他选项;它们不常见,但您可以在资源中了解更多关于它们的信息。

项目 3:从大型文档中提取联系信息

假设您被分配了一个无聊的任务,即在一个长网页或文档中找到每一个电话号码和电子邮件地址。如果您手动滚动页面,可能会花费很长时间。但如果有程序可以搜索剪贴板中的文本以查找电话号码和电子邮件地址,您只需按 CTRL-A 选择所有文本,按 CTRL-C 复制到剪贴板,然后运行您的程序。它可以将剪贴板上的文本替换为它找到的电话号码和电子邮件地址。

每当您着手一个新的项目时,直接编写代码可能会很有吸引力。但更常见的情况是,最好退一步,考虑更大的图景。我建议首先为您的程序需要做什么制定一个高级计划。现在不要考虑实际的代码;您可以在稍后担心这个问题。现在,只关注大致的轮廓。

例如,您的电话号码和电子邮件地址提取器需要执行以下操作:

  • 从剪贴板获取文本。

  • 在文本中查找所有电话号码和电子邮件地址。

  • 将它们粘贴到剪贴板。

现在,你可以开始思考这如何在代码中工作。代码需要执行以下操作:

  • 使用 pyperclip 模块来复制和粘贴字符串。

  • 创建两个正则表达式,一个用于匹配电话号码,另一个用于匹配电子邮件地址。

  • 找到两个正则表达式的所有匹配项(不仅仅是第一个匹配项)。

  • 将匹配的字符串整洁地格式化为单个字符串以粘贴。

  • 如果在文本中没有找到匹配项,则显示某种消息。

这个列表就像项目的路线图。随着你编写代码,你可以单独关注这些步骤中的每一个,每个步骤应该看起来相当容易管理。它们也用你在 Python 中已经知道如何做的事情来表达。

第一步:创建电话号码的正则表达式

首先,你必须创建一个用于搜索电话号码的正则表达式。创建一个新的文件,输入以下内容,并将其保存为 phoneAndEmail.py

[PRE127]

TODO 注释只是程序的骨架。随着你编写实际的代码,它们将被替换。

电话号码以一个可选的区号开头,因此我们用问号跟随区号组。由于区号可以是三位数字(即,\d{3})或者括号内的三位数字(即,\(\d{3}\)),你应该用管道符号连接这些部分。你可以在多行字符串的这一部分添加正则表达式注释 # Area code 来帮助你记住 (\d{3}|\(\d{3}\))? 应该匹配什么。

电话号码的分隔符字符可以是可选的空格(\s)、连字符(-)或句点(.),因此我们也应该用管道符号连接这些部分。正则表达式的下一部分是直截了当的:三位数字,然后是另一个分隔符,然后是四位数字。最后一部分是一个可选的扩展,由任意数量的空格后跟 extxext.,然后是两到五个数字。

注意

在编写包含括号组 () 和转义括号 () 的正则表达式时很容易混淆。如果你收到“missing), unterminated subpattern”错误消息,请记住要仔细检查你使用的是正确的语法。

第二步:创建电子邮件地址的正则表达式

你还需要一个可以匹配电子邮件地址的正则表达式。让你的程序看起来像以下这样:

[PRE128]

电子邮件地址的用户名部分 ❶ 由一个或多个字符组成,可以是以下任何一种:小写和大写字母、数字、点、下划线、百分号、加号或连字符。你可以将这些全部放入一个字符类:[a-zA-Z0-9._%+-]

域名和用户名由一个 @ 符号分开 ❷。域名 ❸ 有一个稍微不那么宽容的字符类,只包含字母、数字、点和连字符:[a-zA-Z0-9.-]。最后是“dot-com”部分(技术上称为 顶级域名),它可以真的是点-任何东西。

电子邮件地址的格式有很多奇怪的规则。这个正则表达式不会匹配所有可能的有效电子邮件地址,但它会匹配你遇到的几乎所有典型电子邮件地址。

第 3 步:在剪贴板文本中找到所有匹配项

现在你已经指定了电话号码和电子邮件地址的正则表达式,你可以让 Python 的re模块做寻找剪贴板上的所有匹配项的艰苦工作。pyperclip.paste()函数将获取剪贴板上的文本的字符串值,而findall()正则表达式方法将返回一个包含元组的列表。

让你的程序看起来像以下这样:

[PRE129]

每个匹配项都有一个元组,每个元组包含正则表达式中的每个组的字符串。记住,组0匹配整个正则表达式,所以元组索引0的组是你感兴趣的。

如您所见❶,你将匹配项存储在名为matches的列表变量中。它最初是一个空列表,并包含几个for循环。对于电子邮件地址,你将每个匹配项的组0追加❸。对于匹配的电话号码,你不想只追加组0。虽然程序检测几种格式的电话号码,但你希望追加的电话号码是单一、标准的格式。phone_num变量包含从匹配文本的组1356构建的字符串❷。(这些组是区号、前三位数字、最后四位数字和分机号。)

第 4 步:将匹配项合并成一个字符串

现在你已经将电子邮件地址和电话号码作为字符串列表存储在matches中,你想要将它们放在剪贴板上。pyperclip.copy()函数只接受单个字符串值,不接受字符串列表,所以你必须对matches调用join()方法。

让你的程序看起来像以下这样:

[PRE130]

为了更容易地看到程序是否在运行,我们还打印出你找到的任何匹配项到终端窗口。如果没有找到电话号码或电子邮件地址,程序会告诉用户这一点。

要测试你的程序,打开你的网络浏览器到 No Starch Press 的联系页面,网址为nostarch.com/contactus,按 CTRL-A 选择页面上的所有文本,然后按 CTRL-C 将其复制到剪贴板。当你运行这个程序时,输出应该看起来像这样:

[PRE131]

你可以修改这个脚本以搜索邮寄地址、社交媒体昵称和许多其他类型的文本模式。

类似程序的思路

识别文本模式(并可能用sub()方法替换它们)有许多不同的潜在应用。例如,你可以做以下事情:

  • 查找以http://https://开头的网站 URL。

  • 通过将它们替换为单一、标准的格式来清理不同格式的日期(例如 3/14/2030、03-14-2030 和 2030/3/14)。

  • 删除敏感信息,例如社会保障号码或信用卡号码。

  • 查找常见的错误,如单词之间的多个空格、不小心重复的单词或句子末尾的多个感叹号。这些都是令人烦恼的!!

第 1 步:创建用于电话号码的正则表达式

首先,你必须创建一个正则表达式来搜索电话号码。创建一个新文件,输入以下内容,并将其保存为 phoneAndEmail.py

[PRE132]

TODO 注释只是程序的框架。随着你编写实际的代码,它们将被替换。

电话号码以一个 可选 的区号开头,因此我们用问号跟随区号组。由于区号可以是三位数字(即,\d{3} 括号内的三位数字(即,\(\d{3}\)),你应该在这些部分之间使用竖线连接。你可以在多行字符串的这一部分添加正则表达式注释 # Area code 来帮助你记住 (\d{3}|\(\d{3}\))? 应该匹配什么。

电话号码的分隔符字符可以是 可选 的空格 (\s)、连字符 (-) 或点 (.),因此我们也应该使用竖线连接这些部分。正则表达式的下一部分是直截了当的:三位数字,然后是另一个分隔符,然后是四位数字。最后一部分是一个可选的扩展,由任意数量的空格后跟 extxext.,然后是两到五个数字。

注意

在编写包含括号组 () 和转义括号 () 的正则表达式时很容易混淆。如果你收到“missing), unterminated subpattern”错误信息,请记住要仔细检查你使用的语法是否正确。

第 2 步:创建用于电子邮件地址的正则表达式

你还需要一个可以匹配电子邮件地址的正则表达式。让你的程序看起来像以下这样:

[PRE133]

电子邮件地址的用户名部分 ❶ 由一个或多个字符组成,可以是以下任何一种:小写和大写字母、数字、点、下划线、百分号、加号或连字符。你可以将这些全部放入一个字符类中:[a-zA-Z0-9._%+-]

域名和用户名由一个 @ 符号分隔 ❷。域名 ❸ 有一个稍微不那么宽容的字符类,只包含字母、数字、点和连字符:[a-zA-Z0-9.-]。最后是“dot-com”部分(技术上称为 顶级域名),实际上可以是任何点开头的名称。

电子邮件地址的格式有很多奇怪的规则。这个正则表达式不会匹配每个可能的有效电子邮件地址,但它会匹配你遇到的几乎所有典型电子邮件地址。

第 3 步:在剪贴板文本中查找所有匹配项

现在你已经指定了电话号码和电子邮件地址的正则表达式,你可以让 Python 的 re 模块来完成在剪贴板上查找所有匹配项的繁重工作。pyperclip.paste() 函数将获取剪贴板上的文本的字符串值,而 findall() 正则表达式方法将返回一个元组列表。

让你的程序看起来像以下这样:

[PRE134]

每个匹配项都有一个元组,每个元组包含正则表达式中的每个组的字符串。记住,组0匹配整个正则表达式,所以元组索引0的组是你感兴趣的。

如您所见❶,您将匹配项存储在名为matches的列表变量中。它最初是一个空列表,包含几个for循环。对于电子邮件地址,您将每个匹配项的组0追加❸。对于匹配的电话号码,您不希望只是追加组0。虽然程序检测电话号码的几种格式,但您想要追加的电话号码应该是单一、标准格式的。phone_num变量包含从匹配文本的组1356构建的字符串❷。(这些组是区号、前三位数字、最后四位数字和分机号。)

第 4 步:将匹配项合并成一个字符串

现在你已经将电子邮件地址和电话号码作为字符串列表存储在matches中,你想要将它们放在剪贴板上。pyperclip.copy()函数只接受单个字符串值,而不是字符串列表,所以你必须对matches调用join()方法。

让你的程序看起来如下:

[PRE135]

为了更容易地看到程序是否在运行,我们还打印出你找到的任何匹配项到终端窗口。如果没有找到电话号码或电子邮件地址,程序会告诉用户这一点。

要测试你的程序,请在你的网络浏览器中打开 No Starch Press 的联系页面,网址为nostarch.com/contactus,按 CTRL-A 选择页面上的所有文本,然后按 CTRL-C 将其复制到剪贴板。当你运行这个程序时,输出应该看起来像这样:

[PRE136]

你可以修改这个脚本以搜索邮寄地址、社交媒体名称和其他许多类型的文本模式。

相似程序的思路

识别文本模式(并可能用sub()方法替换它们)有许多不同的潜在应用。例如,你可以做以下事情:

  • 查找以http://https://开头的网站 URL。

  • 通过将它们替换为单一、标准格式的日期来清理不同格式的日期(例如 3/14/2030、03-14-2030 和 2030/3/14)。

  • 删除敏感信息,例如社会保障号码或信用卡号码。

  • 查找常见的错误,例如单词之间的多个空格、不小心重复的单词或句子末尾的多个感叹号。那些都很烦人!!

Humre:一个用于人类可读正则表达式的模块

代码的阅读次数远多于编写次数,因此你的代码的可读性很重要。但是,正则表达式的标点密集语法即使是经验丰富的程序员也难以阅读。为了解决这个问题,第三方 Humre Python 模块通过使用人类可读的、简单的英语名称来创建可读的正则表达式代码,进一步扩展了详细模式的优点。你可以按照附录 A 中的说明安装 Humre。

让我们回到本章开头提到的 r'\d{3}-\d{3}-\d{4}' 电话号码示例。Humre 中的函数和常量可以用简单的英语生成相同的正则表达式字符串:

[PRE137]

Humre 的常量(如 DIGIT)包含字符串,Humre 的函数(如 exactly())返回字符串。Humre 不会替换 re 模块。相反,它生成可以传递给 re.compile() 的正则表达式字符串:

[PRE138]

Humre 有针对正则表达式语法每个特征的常量和函数。然后你可以像其他字符串一样连接常量和返回的字符串。例如,这里有一些 Humre 的常量,用于简写字符类:

  • DIGITNONDIGIT 分别代表 r'\d'r'\D'

  • WORDNONWORD 分别代表 r'\w'r'\W'

  • WHITESPACENONWHITESPACE 分别代表 r'\s'r'\S'

正则表达式的一个常见错误来源是忘记哪些字符需要转义。你可以使用 Humre 的常量而不是自己输入转义字符。例如,假设你想匹配一个十进制小数,小数点后有一位数字,如 '0.9''4.5'。然而,如果你使用正则表达式字符串 r'\d.\d',你可能不会意识到点匹配了句点(如在 '4.5' 中),但也匹配了任何其他字符(如在 '4A5' 中)。

相反,使用 Humre 的 PERIOD 常量,它包含字符串 r'\.'。表达式 DIGIT + PERIOD + DIGIT 评估为 r'\d\.\d',这使得正则表达式意图匹配的内容更加明显。

存在以下用于转义字符的 Humre 常量:

[PRE139]

此外,还有 NEWLINETABQUOTEDOUBLE_QUOTE 的常量。从 r'\1'r'\99' 的回溯引用表示为 BACK_1BACK_99

然而,使用 Humre 的函数可以获得最大的可读性提升。表 9-2 展示了这些函数及其等效的正则表达式语法。

表 9-2:Humre 函数

Humre 函数 正则表达式字符串
group('A') r'(A)'
optional('A') r'A?'
either('A', 'B', 'C') r'A&#124;B&#124;C'
exactly(3, 'A') 'A{3}'
between(3, 5, 'A') 'A{3,5}'
at_least(3, 'A') 'A{3,}'
at_most(3, 'A') 'A{,3}'
chars('A-Z') '[A-Z]'
nonchars('A-Z') '[^A-Z]'
zero_or_more('A') 'A*'
zero_or_more_lazy('A') 'A*?'
one_or_more('A') 'A+'
one_or_more_lazy('A') 'A+?'
starts_with('A') '^A'
ends_with('A') 'A
starts_and_ends_with('A') '^A
named_group('name', 'A') '(?P<name>A)'

Humre 还提供了一些方便的函数,它们结合了常见的函数调用对。例如,你可以直接调用optional_group('A')来创建'(A)?',而不是使用optional(group('A'))。表 9-3 列出了 Humre 的完整方便函数列表。

表 9-3:Humre 方便函数

方便函数 函数等效 正则表达式字符串
optional_group('A') optional(group('A')) '(A)?'
group_either('A') group(either('A', 'B', 'C')) '(A&#124;B&#124;C)'
exactly_group(3, 'A') exactly(3, group('A')) '(A){3}'
between_group(3, 5, 'A') between(3, 5, group('A')) '(A){3,5}'
at_least_group (3, 'A') at_least(3, group('A')) '(A){3,}'
at_most_group (3, 'A') at_most(3, group('A')) '(A){,3}'
zero_or_more_group('A') zero_or_more(group('A')) '(A)*'
zero_or_more_lazy_group('A') zero_or_more_lazy(group('A')) '(A)*?'
one_or_more_group('A') one_or_more(group('A')) '(A)+'
one_or_more_lazy_group('A') one_or_more_lazy(group('A')) '(A)+?'

除了either()group_either()之外,Humre 的所有函数都允许你传递多个字符串以自动连接它们。这意味着调用group(DIGIT, PERIOD, DIGIT)产生的正则表达式字符串与group(DIGIT + PERIOD + DIGIT)相同。它们都返回正则表达式字符串r'(\d\.\d)'

最后,Humre 为常见的正则表达式模式提供了常量:

ANY_SINGLE 匹配任何单个字符(除了换行符)的.模式

ANYTHING_LAZY 零次或多次的.*?模式

ANYTHING_GREEDY 零次或多次的.*模式

SOMETHING_LAZY 一次或多次的.+?模式

SOMETHING_GREEDY 一次或多次的.+模式

当考虑大型、复杂的正则表达式时,使用 Humre 编写的正则表达式的可读性变得更加明显。让我们用 Humre 重写之前电话号码提取项目中的电话号码正则表达式:

[PRE140]

当你运行这个程序时,输出如下:

[PRE141]

这段代码比即使是详细模式的正则表达式还要冗长。使用from humre import *语法导入 Humre 可以帮助你不需要在每个函数和常量前都加上humre.。但代码的长度并不像可读性那样重要。

你可以通过调用humre.parse()函数将现有的正则表达式转换为 Humre 代码,该函数返回一个 Python 源代码字符串:

[PRE142]

当与 PyCharm 或 Visual Studio Code 等现代编辑器结合使用时,Humre 提供了几个额外的优势:

  • 你可以通过缩进来使正则表达式的各个部分之间的关系更加明显。

  • 你的编辑器的括号匹配功能正常工作。

  • 你的编辑器的语法高亮功能正常工作。

  • 你的编辑器的代码检查器和类型提示工具可以捕捉到错误。

  • 你的编辑器的自动完成功能填充函数和常量名称。

  • Humre 会为你处理原始字符串和转义。

  • 你可以将 Python 注释放在你的 Humre 代码旁边。

  • 错别字会导致更有帮助的错误信息。

许多经验丰富的程序员会反对使用除标准、复杂、难以阅读的正则表达式语法之外的其他任何东西。正如程序员 Peter Bhat Harkins 曾经说过的,“程序员经常做的一件最令人烦恼的事情就是,他们对自己学习困难的事情感到如此满意,以至于他们不去寻找使其变得容易的方法,甚至反对那些可以做到这一点的事情。”

然而,如果同事反对你使用 Humre,你可以简单地打印出你的 Humre 代码生成的底层正则表达式字符串,并将其放回你的源代码中。例如,电话号码提取项目中的 phone_regex 变量的内容如下:

[PRE143]

如果同事认为这个正则表达式字符串更合适,他们可以欢迎使用它。

摘要

虽然计算机可以快速搜索文本,但它必须被精确地告知要查找的内容。正则表达式允许你指定要查找的字符模式,而不是确切的文本本身。实际上,一些文字处理和电子表格应用程序提供了使用正则表达式进行查找和替换的功能。正则表达式的标点符号丰富的语法由限定符组成,这些限定符详细说明了要匹配的内容,以及量词详细说明了要匹配多少。

Python 中的 re 模块允许你将正则表达式字符串编译成 Pattern 对象。这些对象有几种方法:search(),用于查找单个匹配项;findall(),用于查找所有匹配实例;以及 sub(),用于进行查找和替换文本的替换。

你可以在官方 Python 文档中了解更多信息,网址为 docs.python.org/3/library/re.html。另一个有用的资源是教程网站 www.regular-expressions.info。Humre 在 Python 包索引上的页面是 pypi.org/project/Humre/

练习问题

  1. 返回 Regex 对象的函数是什么?

  2. 为什么在创建 Regex 对象时经常使用原始字符串?

  3. search() 方法返回什么?

  4. 如何从 Match 对象中获取匹配模式的实际字符串?

  5. 在由 r'(\d\d\d)-(\d\d\d-\d\d\d\d)' 创建的正则表达式中,组 0 包括什么?组 1?组 2

  6. 括号和句点在正则表达式语法中有特定的含义。你将如何指定你想要正则表达式匹配实际的括号和句点字符?

  7. findall() 方法返回一个字符串列表或一个字符串元组列表。是什么让它返回一个或另一个?

  8. 正则表达式中的 | 字符表示什么?

  9. 正则表达式中的 ? 字符表示什么两个事物?

  10. 正则表达式中的 +* 字符之间的区别是什么?

  11. 正则表达式中的 {3}{3,5} 之间有什么区别?

  12. 在正则表达式中,\d\w\s 简写字符类分别表示什么?

  13. 在正则表达式中,\D\W\S 简写字符类分别表示什么?

  14. .*.*? 正则表达式之间的区别是什么?

  15. 匹配所有数字和小写字母的字符类语法是什么?

  16. 如何使正则表达式不区分大小写?

  17. . 字符通常匹配什么?如果将 re.DOTALL 作为 re.compile() 的第二个参数传递,它将匹配什么?

  18. 如果 num_re = re.compile(r'\d+'),则 num_re.sub('X', '12 drummers, 11 pipers, five rings, 3 hens') 将返回什么?

  19. re.VERBOSE 作为 re.compile() 的第二个参数传递允许你做什么?

练习程序

为了练习,编写程序来完成以下任务。

强密码检测

编写一个函数,使用正则表达式确保传递给它的密码字符串是安全的。一个安全的密码有多个规则:它必须至少有八个字符长,包含大小写字母,并且至少有一个数字。提示:与尝试创建一个可以验证所有规则的单一正则表达式相比,测试字符串以多个正则表达式模式为更容易。

strip() 方法的正则表达式版本

编写一个函数,该函数接受一个字符串并执行与 strip() 字符串方法相同的功能。如果没有传递其他参数,除了要去除的字符串,则该函数应从字符串的开始和结束处去除空白字符。否则,该函数应去除函数第二个参数指定的字符。

强密码检测

编写一个函数,使用正则表达式确保传递给它的密码字符串是安全的。一个安全的密码有多个规则:它必须至少有八个字符长,包含大小写字母,并且至少有一个数字。提示:与尝试创建一个可以验证所有规则的单一正则表达式相比,测试字符串以多个正则表达式模式为更容易。

strip() 方法的正则表达式版本

编写一个函数,接受一个字符串并执行与 strip() 字符串方法相同的功能。如果没有传递其他参数,除了要去除的字符串,则该函数应从字符串的开始和结束处去除空白字符。否则,该函数应去除函数第二个参数指定的字符。

posted @ 2026-02-06 10:27  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报