【问题标题】:Converting key=value pairs back into Python dicts将 key=value 对转换回 Python dicts
【发布时间】:2019-03-24 17:37:53
【问题描述】:

有一个日志文件,其中包含以空格分隔的key=value 对形式的文本,每一行最初都是从 Python 字典中的数据序列化的,类似于:

' '.join([f'{k}={v!r}' for k,v in d.items()])

键总是只是字符串。这些值可以是 ast.literal_eval 可以成功解析的任何值,不多不少。

如何处理这个日志文件并将这些行转换回 Python 字典? 示例:

>>> to_dict("key='hello world'")
{'key': 'hello world'}

>>> to_dict("k1='v1' k2='v2'")
{'k1': 'v1', 'k2': 'v2'}

>>> to_dict("s='1234' n=1234")
{'s': '1234', 'n': 1234}

>>> to_dict("""k4='k5="hello"' k5={'k6': ['potato']}""")
{'k4': 'k5="hello"', 'k5': {'k6': ['potato']}}

以下是有关数据的一些额外上下文:

  • 密钥是valid names
  • 输入行格式正确(例如,没有悬空括号)
  • 数据可信(evalexecyaml.load等不安​​全函数可以使用)
  • 顺序并不重要。性能并不重要。正确性很重要。

编辑:根据 cmets 的要求,这里是一个 MCVE 和一个无法正常工作的示例代码

>>> def to_dict(s):
...     s = s.replace(' ', ', ')
...     return eval(f"dict({s})")
... 
... 
>>> to_dict("k1='v1' k2='v2'")
{'k1': 'v1', 'k2': 'v2'}  # OK
>>> to_dict("s='1234' n=1234")
{'s': '1234', 'n': 1234}  # OK
>>> to_dict("key='hello world'")
{'key': 'hello, world'}  # Incorrect, the value was corrupted

【问题讨论】:

  • 您似乎希望我们为您编写一些代码。虽然许多用户愿意为陷入困境的编码人员编写代码,但他们通常只在发布者已经尝试单独解决问题时提供帮助。展示这种努力的一个好方法是包含Minimal, complete, verifiable example。在发帖前检查你完成的intro tour,尤其是How to Ask
  • 拥有 150k 代表,您会认为会显示一些代码...
  • 哎哟。我认为 wim 知道How to Ask。这与 gimmethecode 作业问题不同......
  • 您从 python 答案中获得了 11,720 名声望。很惊讶你不能敲出一些代码,或者你不知道你应该这样做。
  • @Prune tss tss 罐装 cmets 代表低代表,upvotes 代表高代表?

标签: python string parsing dictionary logging


【解决方案1】:

提供两个辅助函数。

  • popstr:从看起来像字符串的字符串开头拆分内容
    如果它以单引号或双引号开头,我将查找下一个并在该点拆分。

    def popstr(s):
        i = s[1:].find(s[0]) + 2
        return s[:i], s[i:]
    
  • poptrt:从被括号('[]'、'()'、'{}')包围的字符串的开头拆分内容。
    如果它以括号开头,我将开始为起始字符的每个实例递增,并为它的补码的每个实例递减。当我达到零时,我分裂了。

    def poptrt(s): d = {'{': '}', '[': ']', '(': ')'} b = s[0] c = lambda x: {b: 1, d[b]: -1}.get(x, 0) 部分= [] t, 我 = 1, 1 当 t > 0 和 s 时: 如果我 > len(s) - 1: 休息 '\'"'中的 elif s[i]: s, s, s = s[:i], *map(str.strip, popstr(s[i:])) parts.extend([s, s]) 我 = 0 别的: t += c(s[i]) 我 += 1 如果 t == 0: return ''.join(parts + [s[:i]]), s[i:] 别的: raise ValueError('你的字符串有不平衡的括号。')


咀嚼细绳,直到没有细绳可咀嚼

def to_dict(log):
    d = {}
    while log:
        k, log = map(str.strip, log.split('=', 1))
        if log.startswith(('"', "'")):
            v, log = map(str.strip, popstr(log))
        elif log.startswith((*'{[(',)):
            v, log = map(str.strip, poptrt(log))
        else:
            v, *log = map(str.strip, log.split(None, 1))
            log = ' '.join(log)
        d[k] = ast.literal_eval(v)
    return d

所有测试通过

assert to_dict("key='hello world'") == {'key': 'hello world'}
assert to_dict("k1='v1' k2='v2'") == {'k1': 'v1', 'k2': 'v2'}
assert to_dict("s='1234' n=1234") == {'s': '1234', 'n': 1234}
assert to_dict("""k4='k5="hello"' k5={'k6': ['potato']}""") == {'k4': 'k5="hello"', 'k5': {'k6': ['potato']}}

不足之处

  • 没有考虑反斜杠
  • 没有考虑嵌套的愚蠢格式

齐心协力

import ast

def popstr(s):
    i = s[1:].find(s[0]) + 2
    return s[:i], s[i:]

def poptrt(s):
    d = {'{': '}', '[': ']', '(': ')'}
    b = s[0]
    c = lambda x: {b: 1, d[b]: -1}.get(x, 0)
    parts = []
    t, i = 1, 1
    while t > 0 and s:
        if i > len(s) - 1:
            break
        elif s[i] in '\'"':
            _s, s_, s = s[:i], *map(str.strip, popstr(s[i:]))
            parts.extend([_s, s_])
            i = 0
        else:
            t += c(s[i])
            i += 1
    if t == 0:
        return ''.join(parts + [s[:i]]), s[i:]
    else:
        raise ValueError('Your string has unbalanced brackets.')

def to_dict(log):
    d = {}
    while log:
        k, log = map(str.strip, log.split('=', 1))
        if log.startswith(('"', "'")):
            v, log = map(str.strip, popstr(log))
        elif log.startswith((*'{[(',)):
            v, log = map(str.strip, poptrt(log))
        else:
            v, *log = map(str.strip, log.split(None, 1))
            log = ' '.join(log)
        d[k] = ast.literal_eval(v)
    return d

【讨论】:

    【解决方案2】:

    您的输入不能方便地被 ast.literal_eval 之类的东西解析,但它可以tokenized 作为一系列 Python 令牌。这使事情变得比原本可能要容易一些。

    = 标记可以出现在您的输入中的唯一位置是作为键值分隔符;至少现在,ast.literal_eval 不接受任何带有= 令牌的东西。我们可以使用= 标记来确定键值对的开始和结束位置,剩下的大部分工作都可以由ast.literal_eval 处理。使用tokenize 模块还可以避免= 或字符串文字中的反斜杠转义问题。

    import ast
    import io
    import tokenize
    
    def todict(logstring):
        # tokenize.tokenize wants an argument that acts like the readline method of a binary
        # file-like object, so we have to do some work to give it that.
        input_as_file = io.BytesIO(logstring.encode('utf8'))
        tokens = list(tokenize.tokenize(input_as_file.readline))
    
        eqsign_locations = [i for i, token in enumerate(tokens) if token[1] == '=']
    
        names = [tokens[i-1][1] for i in eqsign_locations]
    
        # Values are harder than keys.
        val_starts = [i+1 for i in eqsign_locations]
        val_ends = [i-1 for i in eqsign_locations[1:]] + [len(tokens)]
    
        # tokenize.untokenize likes to add extra whitespace that ast.literal_eval
        # doesn't like. Removing the row/column information from the token records
        # seems to prevent extra leading whitespace, but the documentation doesn't
        # make enough promises for me to be comfortable with that, so we call
        # strip() as well.
        val_strings = [tokenize.untokenize(tok[:2] for tok in tokens[start:end]).strip()
                       for start, end in zip(val_starts, val_ends)]
        vals = [ast.literal_eval(val_string) for val_string in val_strings]
    
        return dict(zip(names, vals))
    

    这在您的示例输入以及带有反斜杠的示例上表现正确:

    >>> todict("key='hello world'")
    {'key': 'hello world'}
    >>> todict("k1='v1' k2='v2'")
    {'k1': 'v1', 'k2': 'v2'}
    >>> todict("s='1234' n=1234")
    {'s': '1234', 'n': 1234}
    >>> todict("""k4='k5="hello"' k5={'k6': ['potato']}""")
    {'k4': 'k5="hello"', 'k5': {'k6': ['potato']}}
    >>> s=input()
    a='=' b='"\'' c=3
    >>> todict(s)
    {'a': '=', 'b': '"\'', 'c': 3}
    

    顺便说一句,我们可能会寻找令牌类型 NAME 而不是 = 令牌,但如果他们将 set() 支持添加到 literal_eval,那将会中断。寻找= 也可能在未来中断,但它似乎不像寻找NAME 令牌那样可能中断。

    【讨论】:

    • 重新使用标记器是一个非常有创意和跳出框框思考的想法。干得好。
    【解决方案3】:

    正则表达式替换函数救援

    不是为你重写类似 ast 的解析器,但一个非常有效的技巧是使用正则表达式替换带引号的字符串并用“变量”替换它们(I'我选择了__token(number)__),有点像你在混淆一些代码。

    记下您要替换的字符串(应该注意空格),用逗号替换空格(防止符号之前像: 允许通过最后一个测试)并再次用字符串替换。

    import re,itertools
    
    def to_dict(s):
        rep_dict = {}
        cnt = itertools.count()
        def rep_func(m):
            rval = "__token{}__".format(next(cnt))
            rep_dict[rval] = m.group(0)
            return rval
    
        # replaces single/double quoted strings by token variable-like idents
        # going on a limb to support escaped quotes in the string and double escapes at the end of the string
        s = re.sub(r"(['\"]).*?([^\\]|\\\\)\1",rep_func,s)
        # replaces spaces that follow a letter/digit/underscore by comma
        s = re.sub("(\w)\s+",r"\1,",s)
        #print("debug",s)   # uncomment to see temp string
        # put back the original strings
        s = re.sub("__token\d+__",lambda m : rep_dict[m.group(0)],s)
    
        return eval("dict({s})".format(s=s))
    
    print(to_dict("k1='v1' k2='v2'"))
    print(to_dict("s='1234' n=1234"))
    print(to_dict(r"key='hello world'"))
    print(to_dict('key="hello world"'))
    print(to_dict("""k4='k5="hello"' k5={'k6': ['potato']}"""))
    # extreme string test
    print(to_dict(r"key='hello \'world\\'"))
    

    打印:

    {'k2': 'v2', 'k1': 'v1'}
    {'n': 1234, 's': '1234'}
    {'key': 'hello world'}
    {'key': 'hello world'}
    {'k5': {'k6': ['potato']}, 'k4': 'k5="hello"'}
    {'key': "hello 'world\\"}
    

    关键是使用非贪婪的正则表达式提取字符串(带引号/双引号),并在表达式中用非字符串替换它们(比如那些是字符串变量而不是文字)。正则表达式已经过调整,因此它可以在字符串末尾接受转义引号和双重转义(自定义解决方案)

    替换函数是一个内部函数,因此它可以利用非本地字典和计数器来跟踪被替换的文本,因此一旦空格被处理后就可以恢复。

    当用逗号替换空格时,你必须小心不要在冒号(最后一个测试)之后或在字母数字/下划线之后考虑的所有事情(因此\w 逗号替换正则表达式中的保护)

    如果我们在原始字符串放回打印之前取消注释调试打印代码:

    debug k1=__token0__,k2=__token1__
    debug s=__token0__,n=1234
    debug key=__token0__
    debug k4=__token0__,k5={__token1__: [__token2__]}
    debug key=__token0__
    

    字符串已被 pwned,空格替换工作正常。通过更多的努力,应该可以引用密钥并将k1=替换为"k1":,因此可以使用ast.literal_eval代替eval(风险更大,此处不需要)

    我确信一些超级复杂的表达式会破坏我的代码(我什至听说很少有 json 解析器能够解析 100% 的有效 json 文件),但是对于您提交的测试,它'会起作用(当然,如果某个有趣的人试图将 __tokenxx__ 标识放在原始字符串中,那将会失败,也许它可以被一些其他无效的变量占位符替换)。我前段时间使用这种技术构建了一个 Ada 词法分析器,能够避免字符串中的空格,并且效果很好。

    【讨论】:

      【解决方案4】:

      您可以找到所有出现的= 字符,然后找到给出有效ast.literal_eval 结果的字符的最大运行次数。然后可以解析这些字符的值,与最后一次成功解析和当前=的索引之间的字符串切片找到的键相关联:

      import ast, typing
      def is_valid(_str:str) -> bool:  
        try:
           _ = ast.literal_eval(_str)
        except:
          return False
        else:
          return True
      
      def parse_line(_d:str) -> typing.Generator[typing.Tuple, None, None]:
        _eq, last = [i for i, a in enumerate(_d) if a == '='], 0
        for _loc in _eq:
           if _loc >= last:
             _key = _d[last:_loc]
             _inner, seen, _running, _worked = _loc+1, '', _loc+2, []
             while True:
               try:
                  val = ast.literal_eval(_d[_inner:_running])
               except:
                  _running += 1
               else:
                  _max = max([i for i in range(len(_d[_inner:])) if is_valid(_d[_inner:_running+i])])
                  yield (_key, ast.literal_eval(_d[_inner:_running+_max]))
                  last = _running+_max
                  break
      
      
      def to_dict(_d:str) -> dict:
        return dict(parse_line(_d))
      

      print([to_dict("key='hello world'"), 
             to_dict("k1='v1' k2='v2'"), 
             to_dict("s='1234' n=1234"), 
             to_dict("""k4='k5="hello"' k5={'k6': ['potato']}"""),
             to_dict("val=['100', 100, 300]"),
             to_dict("val=[{'t':{32:45}, 'stuff':100, 'extra':[]}, 100, 300]")
         ]
      
      )
      

      输出:

      {'key': 'hello world'}
      {'k1': 'v1', 'k2': 'v2'}
      {'s': '1234', 'n': 1234}
      {'k4': 'k5="hello"', 'k5': {'k6': ['potato']}}
      {'val': ['100', 100, 300]}
      {'val': [{'t': {32: 45}, 'stuff': 100, 'extra': []}, 100, 300]}
      

      免责声明:

      这个解决方案没有@Jean-FrançoisFabre 的优雅,我不确定它是否可以100% 解析传递给to_dict 的内容,但它可能会为您提供自己版本的灵感。

      【讨论】:

      • 此解决方案因转义引号而窒息(就像我的顺便说一句):尝试"key='hello \'world'"。在不真正解析数据的情况下编写这样的解析器几乎是不可能的
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2019-08-08
      • 1970-01-01
      • 2015-03-22
      • 1970-01-01
      • 1970-01-01
      • 2012-02-06
      • 1970-01-01
      相关资源
      最近更新 更多