正则表达式匹配"|"联合类型的分隔值



我正在尝试匹配像int | str这样的类型注释,并使用正则表达式替换将它们替换为字符串Union[int, str]

期望的替换(之前和之后):

  • str|int|bool->Union[str,int,bool]
  • Optional[int|tuple[str|int]]->Optional[Union[int,tuple[Union[str,int]]]]
  • dict[str | int, list[B | C | Optional[D]]]->dict[Union[str,int], list[Union[B,C,Optional[D]]]]

到目前为止,我想出的正则表达式如下:

r"w*(?:[|,|^)[t ]*((?'type'[a-zA-Z0-9_.[]]+)(?:[t ]*|[t ]*(?&type))+)(?:]|,|$)"

您可以在正则表达式演示上试用它。它并没有真正按照我想要的方式工作。到目前为止,我注意到的问题:

  • 到目前为止,它似乎没有处理嵌套的联盟条件。例如,int | tuple[str|int] | bool似乎会导致一个匹配项,而不是两个匹配项(包括内部联合条件)。

  • 正则表达式似乎在最后消耗了不必要的]

  • 可能是最重要的一个,但我注意到 Python 中的re模块似乎不支持正则表达式子例程。这是我想到使用它的地方。

附加信息

这主要是为了支持 Python 3.7+ 的 PEP 604 语法,该语法要求支持前向声明注释(例如声明为字符串),否则内置类型不支持|运算符。

这是我想出的示例代码:

from __future__ import annotations
import datetime
from decimal import Decimal
from typing import Optional

class A:
field_1: str|int|bool
field_2: int  |  tuple[str|int]  |  bool
field_3: Decimal|datetime.date|str
field_4: str|Optional[int]
field_5: Optional[int|str]
field_6: dict[str | int, list[B | C | Optional[D]]]
class B: ...
class C: ...
class D: ...

对于 3.10 之前的 Python 版本,我使用__future__导入来避免以下错误:

TypeError: unsupported operand type(s) for |: 'type' and 'type'

这实质上是将所有注释转换为字符串,如下所示:

>>> A.__annotations__
{'field_1': 'str | int | bool', 'field_2': 'int | tuple[str | int] | bool', 'field_3': 'Decimal | datetime.date | str', 'field_4': 'str | Optional[int]', 'field_5': 'Optional[int | str]', 'field_6': 'dict[str | int, list[B | C | Optional[D]]]'}

但是在代码中(比如在另一个模块中),我想评估 A 中的注释。这在 Python 3.10 中有效,但在 Python 3.7+ 中失败,即使__future__导入支持向前声明的注释。

>>> from typing import get_type_hints
>>> hints = get_type_hints(A)
Traceback (most recent call last):
eval(self.__forward_code__, globalns, localns),
File "<string>", line 1, in <module>
TypeError: unsupported operand type(s) for |: 'type' and 'type'

似乎完成这项工作的最佳方法是将int | str的所有出现(例如)替换为Union[int, str],然后typing.Union包含在用于评估注释的其他localns中,然后应该可以评估 Python 604+ 的 PEP 3.7 样式注释。

您可以安装 PyPiregex模块(因为re不支持递归)并使用

import regex
text = "str|int|boolnOptional[int|tuple[str|int]]ndict[str | int, list[B | C | Optional[D]]]"
rx = r"(w+[)(w+([(?:[^][|]++|(?3))*])?(?:s*|s*w+([(?:[^][|]++|(?4))*])?)+)]"
n = 1
res = text
while n != 0:
res, n = regex.subn(rx, lambda x: "{}Union[{}]]".format(x.group(1), regex.sub(r's*|s*', ',', x.group(2))), res) 
print( regex.sub(r'w+(?:s*|s*w+)+', lambda z: "Union[{}]".format(regex.sub(r's*|s*', ',', z.group())), res) )

输出:

Union[str,int,bool]
Optional[Union[int,tuple[Union[str,int]]]]
dict[Union[str,int], list[Union[B,C,Optional[D]]]]

请参阅 Python 演示。

第一个正则表达式查找包含管道字符和其他WORDWORD[...]的各种WORD[...],其中没有管道字符。

w+(?:s*|s*w+)+正则表达式匹配 2 个或更多用管道和可选空格分隔的单词。

第一个模式细节:

  • (w+[)- 第 1 组(这将保持替换开始时的原样):一个或多个单词字符,然后是一个[字符
  • (w+([(?:[^][|]++|(?3))*])?(?:s*|s*w+([(?:[^][|]++|(?4))*])?)+)- 第 2 组(它将放在Union[...]内,所有s*|s*模式都替换为,):
    • w+- 一个或多个单词字符
    • ([(?:[^][|]++|(?3))*])?- 一个可选的组 3,它匹配
    • 一个[字符,后跟一个或多个[]字符或整个组 3 递归的零个或多次出现(因此,它匹配嵌套括号),然后是一个]
    • 字符
    • (?:s*|s*w+([(?:[^][|]++|(?4))*])?)+- 以下一次或多次出现(因此匹配项至少包含一个要替换为,的管道字符):
      • s*|s*- 用零个或多个空格括起来的管道字符
      • w+- 一个或多个单词字符
      • ([(?:[^][|]++|(?4))*])?- 可选的组 4(与组 3 匹配相同的内容,请注意(?4)子例程重复组 4 模式)
  • ]- 一个]字符。

只是一个更新,但我终于能够用一种(完全有效的)非正则表达式方法来解决这个问题。我之所以花了这么长时间,是因为它实际上需要我进行一些激烈的思考和深思熟虑。事实上,这并不容易做到;我花了两天的间歇性工作才真正将所有内容拼凑在一起,也让我能够完全理解我想要完成的事情。

@Wiktor提出的正则表达式解决方案是目前公认的答案,并且总体上效果非常好。实际上(稍后回去)发现只有少数边缘情况无法处理,我在这里进行了介绍。但是,有几个原因我不得不怀疑非正则表达式解决方案是否可能是更好的选择:

  • 的实际用例是我正在构建一个库(包),因此如果可能的话,我想减少依赖关系。巨大的遗憾是,regex模块是一个外部依赖,其大小也不能忽略不计;就我而言,我可能需要将此依赖项作为额外功能添加到我的库中。

  • 正则表达式匹配似乎没有我希望的那么快或高效。不要误会我的意思,匹配帖子中提到的复杂用例仍然非常快(平均约 1-3 毫秒),但给定一个类的大量注释,我可以理解这会很快加起来。因此,我怀疑非正则表达式方法几乎肯定会更快,并且很想测试一下。

因此,我正在发布我能够在下面拼凑的非正则表达式实现。这解决了我将 Union 类型注释(如X|Y)转换为Union[X, Y]等注释的原始问题,并且还超越了支持更复杂的用例,我发现正则表达式实现实际上没有考虑到。我仍然更喜欢正则表达式版本,因为我相信它要简单得多,并且在大多数情况下,我相信它最终会完美运行并且没有问题。

但是,请注意,这是我能够针对此特定问题组合的第一个也是唯一一个非正则表达式实现。事不宜迟,这里是:

from typing import Iterable, Dict, List

# Constants
OPEN_BRACKET = '['
CLOSE_BRACKET = ']'
COMMA = ','
OR = '|'

def repl_or_with_union(s: str):
"""
Replace all occurrences of PEP 604- style annotations (i.e. like `X | Y`)
with the Union type from the `typing` module, i.e. like `Union[X, Y]`.
This is a recursive function that splits a complex annotation in order to
traverse and parse it, i.e. one that is declared as follows:
dict[str | Optional[int], list[list[str] | tuple[int | bool] | None]]
"""
return _repl_or_with_union_inner(s.replace(' ', ''))

def _repl_or_with_union_inner(s: str):
# If there is no '|' character in the annotation part, we just return it.
if OR not in s:
return s
# Checking for brackets like `List[int | str]`.
if OPEN_BRACKET in s:
# Get any indices of COMMA or OR outside a braced expression.
indices = _outer_comma_and_pipe_indices(s)
outer_commas = indices[COMMA]
outer_pipes = indices[OR]
# We need to check if there are any commas *outside* a bracketed
# expression. For example, the following cases are what we're looking
# for here:
#     value[test], dict[str | int, tuple[bool, str]]
#     dict[str | int, str], value[test]
# But we want to ignore cases like these, where all commas are nested
# within a bracketed expression:
#     dict[str | int, Union[int, str]]
if outer_commas:
return COMMA.join(
[_repl_or_with_union_inner(i)
for i in _sub_strings(s, outer_commas)])
# We need to check if there are any pipes *outside* a bracketed
# expression. For example:
#     value | dict[str | int, list[int | str]]
#     dict[str, tuple[int | str]] | value
# But we want to ignore cases like these, where all pipes are
# nested within the a bracketed expression:
#     dict[str | int, list[int | str]]
if outer_pipes:
or_parts = [_repl_or_with_union_inner(i)
for i in _sub_strings(s, outer_pipes)]
return f'Union{OPEN_BRACKET}{COMMA.join(or_parts)}{CLOSE_BRACKET}'
# At this point, we know that the annotation does not have an outer
# COMMA or PIPE expression. We also know that the following syntax
# is invalid: `SomeType[str][bool]`. Therefore, knowing this, we can
# assume there is only one outer start and end brace. For example,
# like `SomeType[str | int, list[dict[str, int | bool]]]`.
first_start_bracket = s.index(OPEN_BRACKET)
last_end_bracket = s.rindex(CLOSE_BRACKET)
# Replace the value enclosed in the outermost brackets
bracketed_val = _repl_or_with_union_inner(
s[first_start_bracket + 1:last_end_bracket])
start_val = s[:first_start_bracket]
end_val = s[last_end_bracket + 1:]
return f'{start_val}{OPEN_BRACKET}{bracketed_val}{CLOSE_BRACKET}{end_val}'
elif COMMA in s:
# We are dealing with a string like `int | str, float | None`
return COMMA.join([_repl_or_with_union_inner(i)
for i in s.split(COMMA)])
# We are dealing with a string like `int | str`
return f'Union{OPEN_BRACKET}{s.replace(OR, COMMA)}{CLOSE_BRACKET}'

def _sub_strings(s: str, split_indices: Iterable[int]):
"""Split a string on the specified indices, and return the split parts."""
prev = -1
for idx in split_indices:
yield s[prev+1:idx]
prev = idx
yield s[prev+1:]

def _outer_comma_and_pipe_indices(s: str) -> Dict[str, List[int]]:
"""Return any indices of ',' and '|' that are outside of braces."""
indices = {OR: [], COMMA: []}
brace_dict = {OPEN_BRACKET: 1, CLOSE_BRACKET: -1}
brace_count = 0
for i, char in enumerate(s):
if char in brace_dict:
brace_count += brace_dict[char]
elif not brace_count and char in indices:
indices[char].append(i)
return indices

我已经针对上面问题中列出的常见用例以及更复杂的用例对其进行了测试,即使是正则表达式实现似乎也在努力解决。

例如,给定以下示例测试用例:

test_cases = """
str|int|bool
Optional[int|tuple[str|int]]
dict[str | int, list[B | C | Optional[D]]]
dict[str | Optional[int], list[list[str] | tuple[int | bool] | None]]
tuple[str|OtherType[a,b|c,d], ...] | SomeType[str | int, list[dict[str, int | bool]]] | dict[str | int, str]
"""
for line in test_cases.strip().split('n'):
print(repl_or_with_union(line).replace(',', ', '))

然后结果如下(请注意,我已将,替换为,,因此更容易阅读)

Union[str, int, bool]
Optional[Union[int, tuple[Union[str, int]]]]
dict[Union[str, int], list[Union[B, C, Optional[D]]]]
dict[Union[str, Optional[int]], list[Union[list[str], tuple[Union[int, bool]], None]]]
Union[tuple[Union[str, OtherType[a, Union[b, c], d]], ...], SomeType[Union[str, int], list[dict[str, Union[int, bool]]]], dict[Union[str, int], str]]

现在正则表达式实现无法正确解析的唯一情况是最后两种情况,可以说一开始就非常复杂。这是最后两个的正则表达式解决方案 - 不幸的是,这不是我们想要的(再次,我确保每个逗号后都有一个空格,所以更容易阅读)

dict[Union[str, Optional][int],  list[Union[list[str], tuple[Union[int, bool]], None]]]
tuple[Union[str, OtherType][a, Union[b, c], d],  ...] | SomeType[Union[str, int],  list[dict[str,  Union[int, bool]]]] | dict[Union[str, int],  str]

也许值得回顾一下为什么这些情况没有按照正则表达式版本的预期处理?我的怀疑,并在测试后实际证实,|表达式中包含括号[]的任何值似乎都无法正确解析。例如,str | Optional[int]当前解析为Union[str,Optional][int],但理想情况下,这将像Union[str,Optional[int]]一样处理。

我已经将上面的两个测试用例归结为下面的缩写形式,为此我能够确认正则表达式没有按预期处理:

str | Optional[int]
tuple[str|OtherType[a,b|c,d], ...] | SomeType[str]

通过正则表达式实现解析时,这些是当前结果。请注意,在其中一个结果中,还显示了|字符,但理想情况下,我们会将其删除,因为低于 3.10 的 Python 版本将无法根据内置类型评估管道|表达式。

Union[str,Optional][int]
tuple[Union[str,OtherType][a,Union[b,c],d], ...] | SomeType[str]

期望的最终结果(在我修复它以在测试时处理此类情况后,非正则表达式方法似乎按预期解决)如下:

Union[str, Optional[int]]
Union[tuple[Union[str,OtherType[a,Union[b,c],d]], ...], SomeType[str]]

最后,我还能够针对上面的正则表达式方法进行计时。我自己很好奇这个解决方案与正则表达式版本相比会如何,可以说正则表达式版本更简单、更容易理解。

我测试的代码如下:

def regex_repl_or_with_union(text):
rx = r"(w+[)(w+([(?:[^][|]++|(?3))*])?(?:s*|s*w+([(?:[^][|]++|(?4))*])?)+)]"
n = 1
res = text
while n != 0:
res, n = regex.subn(rx, lambda x: "{}Union[{}]]".format(x.group(1), regex.sub(r's*|s*', ',', x.group(2))),
res)
return regex.sub(r'w+(?:s*|s*w+)+', lambda z: "Union[{}]".format(regex.sub(r's*|s*', ',', z.group())), res)
test_cases = """
str|int|bool
Optional[int|tuple[str|int]]
dict[str | int, list[B | C | Optional[D]]]
"""

def non_regex_solution():
for line in test_cases.strip().split('n'):
_ = repl_or_with_union(line)

def regex_solution():
for line in test_cases.strip().split('n'):
_ = regex_repl_or_with_union(line)
n = 100_000
print('Non-regex: ', timeit('non_regex_solution()', globals=globals(), number=n))
print('Regex:     ', timeit('regex_solution()', globals=globals(), number=n))

结果 - 在Alienware PC上运行,AMD Ryzen 7 3700X 8核处理器/w 16GB内存:

Non-regex:  2.0510589000186883
Regex:      31.39290289999917

因此,我想出的非正则表达式实现实际上平均比正则表达式实现快15 倍,这很难相信。对我来说最好的消息是它不涉及额外的依赖项。我现在可能会继续使用非正则表达式解决方案,并注意这主要是因为如果可能的话,我想减少对项目的依赖性。再次非常感谢@Wiktor和所有帮助解决这个问题的人,并帮助引导我找到解决方案!

最新更新