将key = value对转换回Python字典

时间:2018-10-19 19:41:28

标签: python string parsing dictionary logging

有一个日志文件,其中文本以空格分隔的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
  • 顺序并不重要。性能并不重要。正确性很重要。

编辑: :根据注释的要求,这是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

4 个答案:

答案 0 :(得分:5)

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令牌那样容易。

答案 1 :(得分:3)

正则表达式替换功能进行救援

不是为您重写一个类似ast的解析器,但是效果很好的一个技巧是使用正则表达式替换加引号的字符串,并将其替换为“变量”(我我们选择了__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__

已对字符串进行了修剪,并且空格替换已正常进行。付出更多的努力,应该可能会引用引号并将k1=替换为"k1":,以便可以使用ast.literal_eval代替eval(风险更大,并且不是必需的)这里)

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

答案 2 :(得分:2)

您可以找到所有出现的=个字符,然后找到给出有效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的解决方案那么优雅,并且我不确定它是否可以解析传递给to_dict的内容的100%,但它可能会为您提供自己的版本灵感。

答案 3 :(得分: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,i = 1,1     当t> 0和s时:         如果i> len-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:         返回''.join(parts + [s [:i]]),s [i:]     其他:         引发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