将伪代数字符串解析为命令

Luc*_*rio 2 python text-parsing

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

objects = {'A1': obj_1,
    'A2': obj_2,
    }
Run Code Online (Sandbox Code Playgroud)

然后我有一个字符串

cmd = '(1.3A1 + 2(A2 + 0.7A3)) or 2(A4 to A6)'
Run Code Online (Sandbox Code Playgroud)

我想把它翻译成一个命令

max( 1.3*objects['A1'] + 2*(objects['A2'] + 0.73*objects['A3']), 2*max(objects['A4'], objects['A5'], objects['A6']))
Run Code Online (Sandbox Code Playgroud)

我的尝试

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

个人注意:我不认为将 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)
Run Code Online (Sandbox Code Playgroud)

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

此外,这段代码仍然没有处理or关键字( where A or Bshould translate intomax(A,B)to关键字( where A1 to A9should translate in max([Ai for Ai in range(A1, A9)]))。

这是最好的方法还是有更强大的方法来处理此类任务?

笔记

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

Mis*_*agi 6

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

虽然这仍然需要从源代码转换为操作,但这可以在解析过程中直接执行。


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

  • 数字字面量,例如1.3, as int/ floatliterals 或fractions.Fraction.
  • 名称引用,例如A3,作为objects命名空间的键。
  • 括号,例如(...),通过括号分组:
    • 变体,例如(1.3 or A3),作为max调用。
    • 名称范围,例如A4 to A6,作为max调用
    • +二进制运算符,如+二元运算符。
  • 隐式乘法,例如2(...), as 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]
Run Code Online (Sandbox Code Playgroud)

请注意,setParseAction通常与 a 一起使用lambda,而不是装饰 a 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
Run Code Online (Sandbox Code Playgroud)

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

复合表达式

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

# placeholder for any valid PAL grammar element
EXPRESSION = pp.Forward()
Run Code Online (Sandbox Code Playgroud)

没有运算符优先级,只是通过分组(...),所有的+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})"
Run Code Online (Sandbox Code Playgroud)

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


在这一点上,我们对每一个transpiler语言元素。可以使用相同的方法创建缺失的规则。现在,我们需要实际阅读源代码并运行转译后的代码。

我们首先将我们拥有的部分放在一起:将所有语法元素插入前向引用中。我们还提供了一个方便的函数来抽象出 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]
Run Code Online (Sandbox Code Playgroud)

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

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

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

>>> execute("(A4 or A3 or 13)", A3=42, A4=7)
42
Run Code Online (Sandbox Code Playgroud)

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

  • `@NAME.setParseAction` 作为装饰器 - 当然,我认为这很棒!下次我在那里时将添加到文档中。 (2认同)