【问题标题】:Parsing pseudo-algebraic string into command将伪代数字符串解析为命令
【发布时间】:2020-11-11 18:20:26
【问题描述】:

我有一个包含对象列表的字典

objects = {'A1': obj_1,
    'A2': obj_2,
    }

然后我有一个字符串

cmd = '(1.3A1 + 2(A2 + 0.7A3)) or 2(A4 to A6)'

我想把它翻译成一个命令

max( 1.3*objects['A1'] + 2*(objects['A2'] + 0.73*objects['A3']), 2*max(objects['A4'], objects['A5'], objects['A6']))

我的尝试

由于没有找到更好的选择,我开始从头开始编写解析器。

个人注意:我不认为将 150 行代码附加到 SO 问题是一种好的做法,因为这意味着读者应该阅读并理解它,这是一项艰巨的任务。尽管如此,我之前的问题被否决了,因为我没有提出我的解决方案。所以你来了……

import re
from more_itertools import stagger

def comb_to_py(string, objects):

    # Split the line
    toks = split_comb_string(string)

    # Escape for empty string
    if toks[0] == 'none':
        return []

    # initialize iterator
    # I could use a deque here. Let's see what works the best
    iterator = stagger(toks, offsets=range(2), longest=True)

    return comb_it_to_py(iterator, objects)


def split_comb_string(string):

    # Add whitespaces between tokes when they could be implicit to allow string
    # splitting i.e. before/after plus (+), minus and closed bracket
    string = re.sub(r' ?([\+\-)]) ?', r' \1 ', string)

    # remove double spaces
    string = re.sub(' +', ' ', string)

    # Avoid situations as 'A1 + - 2A2' and replace them with 'A1 - 2A2'
    string = re.sub(r'\+ *\-', r'-', string)
    # Avoid situations as 'A1 - - 2A2' and replace them with 'A1 + 2A2'
    string = re.sub(r'\- *\-', r'+', string)

    # Add whitespace after "(" (we do not want to add it in front of it)
    string = re.sub(r'\( ?', r'( ', string)

    return string.strip().split(' ')


def comb_it_to_py(iterator, objects):

    for items in iterator:

        # item[0] is a case token (e.g. 1.2A3)
        # This should occur only with the first element
        if re.fullmatch(r'([\d.]*)([a-zA-Z(]+\d*)', items[0]) is not None:
            res = parse_case(items[0], objects, iterator)


        elif items[0] == ')' or items[0] is None:
            return res


        # plus (+)
        elif items[0] == '+':
            # skip one position
            skip_next(iterator)

            # add following item
            res += parse_case(items[1], objects, iterator)


        # minus (-)
        elif items[0] == '-':
            # skip one position
            skip_next(iterator)

            # add following item
            res -= parse_case(items[1], objects, iterator)

        else:
            raise(ValueError(f'Invalid or misplaced token {items[0]}'))

    return res

def parse_case(tok, objects, iterator):
    # Translate a case string into an object.
    # It handles also brackets as "cases" calling comb_it_to_py recursively
    res = re.match(r'([\d.]*)(\S*)', tok)

    if res[1] == '':
        mult = 1
    else:
        mult = float(res[1])

    if res[2] == '(':
        return mult * comb_it_to_py(iterator, objects)
    else:
        return mult * objects[res[2]]


def skip_next(iterator):
    try:
        next(iterator)
    except StopIteration:
        pass


if __name__ == '__main__':

    from numpy import isclose
    def test(string, expected_result):
        try:
            res = comb_to_py(string, objects)
        except Exception as e:
            print(f"Error during test on '{string}'")
            raise e

        assert isclose(res.value, expected_result), f"Failed test on '{string}'"


    objects = {'A1': 1, 'A2':2, 'A10':3}

    test('A2', 2)
    test('1.3A2', 2.6)

    test('1.3A2 + 3A1', 5.6)
    test('1.3A2+ 3A1', 5.6)
    test('1.3A2 +3A1', 5.6)
    test('1.3A2+3A1', 5.6)

    test('1.3A2 - 3A1', -0.4)
    test('1.3A2 -3A1', -0.4)
    test('1.3A2- 3A1', -0.4)
    test('1.3A2-3A1', -0.4)

    test('1.3A2 + -3A1', -0.4)
    test('1.3A2 +-3A1', -0.4)
    test('1.3A2 - -3A1', 5.6)

    test('A1 + 2(A2+A10)', 25)
    test('A1 - 2(A2+A10)', -23)

    test('2(A2+A10) + A1', 25)
    test('2(A2+A10) - A1', 23)
    test('2(A2+A10) - -A1', 25)
    test('2(A2+A10) - -2A1', 26)

这段代码不仅冗长,而且很容易破解。整个代码基于字符串的正确拆分,而正则表达式部分只是为了确保正确拆分字符串,这完全取决于字符串中空格的位置,即使 - 在这个特定的语法中 - 根本不应解析大多数空格

此外,此代码仍然无法处理 or 关键字(其中 A or B 应转换为 max(A,B)to 关键字(其中 A1 to A9 应转换为 max([Ai for Ai in range(A1, A9)]))。

问题

这是最好的方法还是对于此类任务有更强大的方法?

注意

我看了pyparsing。它看起来是一种可能性,但是,如果我理解得很好,它应该被用作更强大的“线分割”,而令牌仍然必须手动一个一个地转换为操作。这是正确的吗?

【问题讨论】:

  • 不要提出新问题,请编辑现有问题重新打开它。
  • 你现在喜欢我删除这个还是那个?
  • 这次真的无所谓了。 总的来说我认为删除现有问题并发布新问题是不受欢迎的(因为您可能会被视为试图规避网站的编辑过程)。
  • 我删除了旧版本。感谢您的来信
  • 我不认为将 150 行代码附加到 SO 问题是一种好习惯这就是为什么你应该创建一个 minimal reproducible example

标签: python text-parsing


【解决方案1】:

正则表达式本质上不适合涉及用于嵌套分组的括号的任务——您的伪代数语言 (PAL) 不是 regular language。应该使用实际的解析器,例如 PyParsingPEG parser)。

虽然这仍然需要将源代码转换为操作,但可以在解析期间直接执行。


我们需要一些直接转换为 Python 原语的语言元素:

  • 数字文字,例如1.3,如int/float 文字或fractions.Fraction
  • 名称引用,例如 A3,作为 objects 命名空间的键。
  • 括号,例如(...),通过括号分组:
    • 变体,例如(1.3 or A3),作为max 调用。
    • 名称范围,例如A4 to A6,如max 调用
    • + 二元运算符,如 + 二元运算符。
  • 隐式乘法,如2(...),如2 * (...)

这样一种简单的语言同样适用于转译器或解释器——没有副作用或内省,因此没有一等对象、中间表示或 AST 的幼稚翻译就可以了。


对于转译器,我们需要将 PAL 源代码转换为 Python 源代码。我们可以使用pyparsing 直接读取 PAL 并使用解析操作发出 Python。

原始表达式

最简单的情况是数字——PAL 和 Python 源代码是相同的。这是查看转译的一般结构的理想选择:

import pyparsing as pp

# PAL grammar rule: one "word" of sign, digits, dot, digits
NUMBER = pp.Regex(r"-?\d+\.?\d*")

# PAL -> Python transformation: Compute appropriate Python code
@NUMBER.setParseAction
def translate(result: pp.ParseResults) -> str:
    return result[0]

注意setParseAction 通常与lambda 一起使用,而不是装饰def。然而,较长的变体更容易注释/注释。

名称引用类似于 parse,但需要对 Python 进行一些小的翻译。我们仍然可以使用正则表达式,因为这里也没有嵌套。所有名称都将是单个全局命名空间的键,我们任意称为 objects

NAME = pp.Regex(r"\w+\d+")

@NAME.setParseAction
def translate(result: pp.ParseResults) -> str:
    return f'objects["{result[0]}"]'   # interpolate key into namespace

这两个语法部分已经独立工作以进行转译。例如NAME.parseString("A3")提供源代码objects["A3"]

复合表达式

与终端/原始语法表达式不同,复合表达式必须引用其他表达式,可能是它们自己(此时正则表达式失败)。 PyParsing 使用 Forward 表达式使这一切变得简单——这些是稍后定义的占位符。

# placeholder for any valid PAL grammar element
EXPRESSION = pp.Forward()

没有运算符优先级,只是通过(...) 进行分组,所有+orto 工作类似。我们选择or 作为演示者。

现在语法变得更复杂了:我们使用pp.Suppress 来匹配但丢弃了纯语法的(/)or。我们使用+/-来组合几个语法表达式(-表示解析时没有替代方案)。最后,我们使用前向引用EXPRESSION 来引用所有其他和这个表达式。

SOME_OR = pp.Suppress("(") + EXPRESSION + pp.OneOrMore(pp.Suppress("or") - EXPRESSION) - pp.Suppress(")")

@SOME_OR.setParseAction
def translate(result: pp.ParseResults) -> str:
    elements = ', '.join(result)
    return f"max({elements})"

名称范围和加法基本相同,只是分隔符和输出格式发生了变化。隐式乘法更简单,因为它只适用于一对表达式。


此时,我们为每个种类的语言元素都有一个转译器。可以使用相同的方法创建缺少的规则。现在,我们需要真正读取源代码并运行转译后的代码。

我们首先将我们拥有的部分放在一起:将所有语法元素插入到前向引用中。我们还提供了一个方便的函数来抽象出 PyParsing。

EXPRESSION << (NAME | NUMBER | SOME_OR)

def transpile(pal: str) -> str:
    """Transpile PAL source code to Python source code"""
    return EXPRESSION.parseString(pal, parseAll=True)[0]

为了运行一些代码,我们需要转译 PAL 代码使用一些命名空间评估 Python 代码。由于我们的语法只允许安全输入,我们可以直接使用eval

def execute(pal, **objects):
    """Execute PAL source code given some object values"""
    code = transpile(pal)
    return eval(code, {"objects": objects})

可以使用给定的 PAL 源和名称值运行此函数,以评估等效的 Python 值:

>>> execute("(A4 or A3 or 13)", A3=42, A4=7)
42

为了完全支持 PAL,定义缺少的复合规则并将它们与其他规则一起添加到 EXPRESSION

【讨论】:

  • 非常感谢您非常详细的解释。通过阅读它,我学到了很多关于我不熟悉的主题(如常规语言和 PEG 解析器)的知识。这比我希望得到的还要多。再次,非常非常感谢你
  • @NAME.setParseAction 作为装饰者 - 当然,我认为这很棒!下次我在那里时会添加到文档中。
猜你喜欢
  • 1970-01-01
  • 2019-09-04
  • 1970-01-01
  • 2023-04-06
  • 2015-09-06
  • 2016-09-25
  • 2016-07-30
  • 1970-01-01
  • 2016-12-10
相关资源
最近更新 更多