使用 Pyparsing 读取自定义文本文件

日期2010-04-10(最后修改),2010-03-23(创建)

简介

在本食谱中,我们将重点介绍如何使用 pyparsing 和 numpy 读取类似于此的结构化文本文件,data.txt

在 [ ]
# This is is an example file structured in section
# with comments begining with '#'

[ INFOS ]
Debug          = False
Shape  (mm^-1) = 2.3                                                            # here is a unit
Length (mm)    = 25361.15
Path 1         = C:\\This\is\a\long\path\with some space in it\data.txt
description    = raw values can have multiple lines, but additional lines must start
                 with a whitespace which is automatically skipped
Parent         = None

[ EMPTY SECTION ]
# empty section should not be taken into account

[ TABLE IN ROWS ]
Temp    (C)             100             200        300       450.0        600
E XX    (GPa)           159.4       16.9E+0       51.8      .15E02        4     # Here is a space in the row name
Words               'hundred'   'two hundreds'  'a lot'     'four'      'five'  # Here are QuotedStrings with space

[ TABLE IN COLUMNS ]
STATION         PRECIPITATION   T_MAX_ABS  T_MIN_ABS
(/)                     (mm)    (C)        (C)       # Columns must have a unit
Ajaccio                 64.8    18.8E+0    -2.6
Auxerre                 49.6    16.9E+0    Nan       # Here is a Nan
Bastia                  114.2   20.8E+0    -0.9

[ MATRIX ]
True    2       3
4.      5.      6.
7.      nan     8

中,我们将创建一个可重用的解析器类,以自动

* 检测 节 块, 在 四种 可能的 类型 中:\ * 一组 变量 声明:name(unit) =valueunit是 可选的\ * 按 行 定义的 表格, 其中 第一列 定义 行的 名称。 如果 此 名称 后面 跟着 一个 单位, 则 它 可以 包含 空格, 否则 它 不能。\ * 按 列 定义的 表格。 列名 不能 包含 空格, 并且 在这种 情况下, 第二行 应 包含 单位\ * 仅 包含 数值、 True、 False 或 NaN 的 矩阵\ * 将 值 转换为 适当的 Python 或 Numpy 类型 (True、 False、 None、 NaN、 float、 str 或 array)\ * 如果 存在, 则 检测 关联的 单位\ * 返回 一个 数据 结构, 其 组织 与 输入 文件 中的 节 相同, 并 清理 变量 名称 以 获得 与 命名 属性 访问 兼容的 名称

以下是一个使用此解析器的会话示例,ConfigNumParser: )#

在 [ ]
>>> from ConfigNumParser import *
>>> data = parseConfigFile('data.txt')
>>> pprint(data.asList())
[['infos',
  ['debug', False],
  ['shape', 2.2999999999999998],
  ['length', 25361.150000000001],
  ['path_1', 'C:\\\\This\\is\\a\\long\\path\\with some space in it\\data.txt'],
  ['description',
   'raw values can have multiple lines, but additional lines must start\nwith a whitespace which is automatically skipped'],
  ['parent', None],
  ['names_', ['debug', 'shape', 'length', 'path_1', 'description', 'parent']],
  ['unit_', {'length': 'mm', 'shape': 'mm^-1'}]],
 ['table_in_rows',
  ['temp', array([ 100.,  200.,  300.,  450.,  600.])],
  ['e_xx', array([ 159.4,   16.9,   51.8,   15. ,    4. ])],
  ['words', array(['hundred', 'two hundreds', 'a lot', 'four', 'five'], dtype='|S12')],
  ['names_', ['temp', 'e_xx', 'words']],
  ['unit_', {'e_xx': 'GPa', 'temp': 'C'}]],
 ['table_in_columns',
  ['station', array(['Ajaccio', 'Auxerre', 'Bastia'], dtype='|S7')],
  ['precipitation', array([  64.8,   49.6,  114.2])],
  ['t_max_abs', array([ 18.8,  16.9,  20.8])],
  ['t_min_abs', array([-2.6,  NaN, -0.9])],
  ['names_', ['station', 'precipitation', 't_max_abs', 't_min_abs']],
  ['unit_',  {'precipitation': 'mm', 't_max_abs': 'C', 't_min_abs': 'C'}]],
 ['matrix',
  array([[  1.,   2.,   3.],
       [  4.,   5.,   6.],
       [  7.,  NaN,   8.]])]]

>>> data.matrix
array([[  1.,   2.,   3.],
       [  4.,   5.,   6.],
       [  7.,  NaN,   8.]])

>>> data.table_in_columns.t_max_abs
array([ 18.8,  16.9,  20.8])

>>> data.infos.length, data.infos.unit_['length']
(25361.15, 'mm')

此解析器在所有节(矩阵节除外)中添加两个特殊字段

*names_: 包含 在此 节 中 找到 的所有 变量 的 名称 的 列表\ *unit_: 包含 与 每个 变量 名称 对应的 单位 的 字典, 如果有 的话

定义参数声明的解析器

pyparsing 是一种处理格式化文本的有效工具,它允许您分两步进行处理。

1. 定义规则来识别表示部分、变量名称等的字符串。使用 pyparsing,这些规则可以轻松地与标准运算符 | 和 + 组合,并且创建可重用组件也变得容易。

2. 定义要在这些字段上执行的操作,以将它们转换为 Python 对象。

在上面的示例文件中,有四种数据:参数定义、行表、列表和矩阵。

因此,我们将为每种数据定义一个解析器,并将它们组合起来定义最终的解析器。

pyparsing 的第一步

本节将逐步描述如何构建 ConfigNumParser 中定义的函数 `paramParser`,用于解析上面的示例中的 [ INFOS ] 块。

参数声明具有以下形式

`*`key* (*unit*) = *value*\     \ with:`

*key: 一组字母数字字符或 _\ *unit: 一组可选的字母数字字符或 ^ * / - . _\ *value: 行尾的任何内容或字符 #,它开始一个注释

这可以用 pyparsing 语法几乎逐字翻译(有关更多信息,请参阅 如何使用 pyparsing)。

在 [ ]
from    pyparsing   import *
# parameter definition
keyName       = Word(alphanums + '_')
unitDef       = '(' + Word(alphanums + '^*/-._') + ')'
paramValueDef = SkipTo('#'|lineEnd)

paramDef = keyName + Optional(unitDef) + "=" + empty + paramValueDef

很容易测试在数据文件中使用此模式将找到什么。

在 [ ]
# print all params found
>>> for param in paramDef.searchString(file('data.txt').read()):
...     print param.dump()
...     print '...'
['Context', '=', 'full']
...
['Temp_ref', '(', 'K', ')', '=', '298.15']
...
...

我们可以通过几种方式改进它。

* 使用 `Suppress` 元素,从输出中抑制无意义的字段 '(', '=', ')',\ * 使用 `setResultsName` 方法为不同的字段命名,或者简单地通过在参数中调用元素名称来命名。

在 [ ]
# parameter definition
keyName       = Word(alphanums + '_')
unitDef       = Suppress('(') + Word(alphanums + '^*/-._') + Suppress(')')
paramValueDef = SkipTo('#'|lineEnd)

paramDef = keyName('name') + Optional(unitDef)('unit') + Suppress("="+empty) + paramValueDef('value')

测试现在将为结果命名并提供更友好的输出。

在 [ ]
['Context', 'full']
- name: Context
- value: full
...
['Temp_ref', 'K', '298.15']
- name: Temp_ref
- unit: ['K']
- value: 298.15
...
...

将数据转换为 Python 对象

我们将进一步详细说明期望哪种类型的值,以使 pyparsing 处理转换。

它们可以分为两部分。

* Python 对象,如数字、True、False、None、NaN 或任何带引号的字符串。\ * 不应转换的原始字符串。

让我们从数字开始。我们可以使用 `Regex` 元素快速检测表示数字的字符串。

在 [ ]
from re        import VERBOSE
number = Regex(r"""
        [+-]?                           # optional sign
         (
            (?:\d+(?P<float1>\.\d*)?)   # match 2 or 2.02
          |                             # or
            (?P<float2>\.\d+)           # match .02
         )
         (?P<float3>[Ee][+-]?\d+)?      # optional exponent
        """, flags=VERBOSE
        )

有关正则表达式的更多信息,请参阅 正则表达式操作。我们可以使用标准 pyparsing 元素(`Combine`、`Optional`、`oneOf` 等)构建解析器,但据说像浮点数这样的低级表达式使用 `Regex` 类效果更好。我知道这感觉像是作弊,但实际上,pyparsing 在幕后使用了一些 re。

现在,我们将定义一个函数将此字符串转换为 Python 浮点数或整数,并设置一个 `parseAction` 来告诉 pyparsing 在找到数字时自动转换它。

在 [ ]
def convertNumber(t):
    """Convert a string matching a number to a python number"""
    if t.float1 or t.float2 or t.float3 : return [float(t[0])]
    else                                : return [int(t[0])  ]

number.setParseAction(convertNumber)

函数 `convertNumber` 是 `parseAction` 的一个简单示例。

* 它应该接受一个 `parseResults` 对象作为输入值(某些函数可以接受 3 个参数,请参阅 `setParseAction` 文档)。`parseResults` 对象可以用作列表、字典或直接使用命名属性(如果您已为结果命名)。这里我们设置了三个命名组 float1、float2 和 float3,我们可以使用它们来决定是否使用 int() 或 float()。

* 它应该返回一个 `parseResults` 对象或一个结果列表,该列表将自动转换为 `parseResults` 对象。

Pyparsing 带有一个非常方便的函数,用于将字段转换为常量对象,即 `replaceWith`。这可以用来创建一个元素列表,将字符串转换为 Python 对象。

在 [ ]
from numpy     import NAN

pyValue_list = [ number                                                        ,
                 Keyword('True').setParseAction(replaceWith(True))             ,
                 Keyword('False').setParseAction(replaceWith(False))           ,
                 Keyword('NAN', caseless=True).setParseAction(replaceWith(NAN)),
                 Keyword('None').setParseAction(replaceWith(None))             ,
                 QuotedString('"""', multiline=True)                           ,
                 QuotedString("'''", multiline=True)                           ,
                 QuotedString('"')                                             ,
                 QuotedString("'")                                             ,
               ]

pyValue     = MatchFirst( e.setWhitespaceChars(' \t\r') for e in pyValue_list)

这里我们使用了

* `Keyword` 用于检测标准 Python 关键字并在运行时替换它们\ * `QuotedString` 用于检测引号字符串并自动取消引号\ * `MatchFirst` 用于构建一个超级元素,`pyValue` 用于转换所有类型的 Python 值。

让我们看看我们得到了什么。

在 [ ]
>>> test2 = '''
>>>     1   2   3.0  0.3 .3  2e2  -.2e+2 +2.2256E-2
>>>     True False nan NAN None
>>>     "word" "two words"
>>>     """'more words', he said"""
>>> '''
>>> print pyValue.searchString(test2)
[[1], [2], [3.0], [0.29999999999999999], [0.29999999999999999], [200.0], [-20.0], [0.022256000000000001],
[True], [False], [nan], [nan], [None], ['word'], ['two words'], ["'more words', he said"]]

关于空白字符的一些说明

默认情况下,pyparsing 将 ' \t\r\n' 中的任何字符视为空白字符且无意义。如果您需要检测行尾,则需要使用 `setWhitespaceChars` 或 `setDefaultWhitespaceChars` 来更改此行为。

由于我们将逐行处理表格,因此我们需要配置这一点,并且应该在最低级别设置这一点。

在 [ ]
>>> pyValue2     = MatchFirst(pyValue_list)          # default behavior
>>> print OneOrMore(pyValue2).searchString(test2)
[[1, 2, 3.0, 0.29999999999999999, 0.29999999999999999, 200.0, -20.0, 0.022256000000000001, True, False, nan, nan, None, 'word', 'two words', "'more words', he said"]]

>>> # to compare to

>>> for r, s, t in OneOrMore(pyValue).searchString(test2)
[[1, 2, 3.0, 0.29999999999999999, 0.29999999999999999, 200.0, -20.0, 0.022256000000000001],
[True, False, nan, nan, None],
['word', 'two words'],
["'more words', he said"]]

转换变量名

我们还必须详细说明什么是可接受的参数名称。

由于参数名称的结尾由 = 字符分隔,因此我们可以接受其中包含空格。但由于我们希望能够通过命名属性访问其值,因此我们需要将其转换为标准形式,与 Python 的命名约定兼容。这里我们选择将参数名称格式化为小写,并将 ' -/.' 中的任何字符集替换为下划线。

稍后,我们将不得不处理不允许空格的参数名称。因此,我们将不得不定义两种名称。

在 [ ]
def variableParser(escapedChars, baseChars=alphanums):
    """ Return pattern matching any characters in baseChars separated by
    characters defined in escapedChars. Thoses characters are replaced with '_'

    The '_' character is therefore automatically in escapedChars.
    """
    escapeDef = Word(escapedChars + '_').setParseAction(replaceWith('_'))
    whitespaceChars = ''.join( x for x in ' \t\r' if not x in escapedChars )
    escapeDef = escapeDef.setWhitespaceChars(whitespaceChars)
    return Combine(Word(baseChars) + Optional(OneOrMore(escapeDef + Word(baseChars))))

keyName             = variableParser(' _-./').setParseAction(downcaseTokens)
keyNameWithoutSpace = variableParser('_-./').setParseAction(downcaseTokens)

`downcaseTokens` 是一个特殊的 pyparsing 函数,它返回所有匹配的令牌的小写形式。

处理原始文本

为了完成这个解析器,我们现在需要添加一个规则来匹配符合以下条件的原始文本。

* # 字符之后的任何内容都被视为注释并被跳过\ * 原始值可以跨越多行,但附加行必须以空格开头,而不是以 [ 开头

在 [ ]
# rawValue can be multiline but theses lines should start with a Whitespace
rawLine  = CharsNotIn('#\n') + (lineEnd | Suppress('#'+restOfLine))
rawValue = Combine( rawLine + ZeroOrMore(White(' \t').suppress()+ NotAny('[') + rawLine))
rawValue.setParseAction(lambda t: [x.strip() for x in t])

我们还将改进对单位的定义,以处理特殊情况,例如 (-)、(/) 或 (),对应于空白单位。

这导致了

在 [ ]
unitDef  = Suppress('(') + (Suppress(oneOf('- /')) | Optional(Word(alphanums + '^*/-._'))) + Suppress(')')
valueDef = pyValue | rawValue
paramDef = keyName('name') + Optional(unitDef)('unit') + Suppress("="+empty) + valueDef('value')

结构化数据

我们将尝试将结果组织成易于使用的數據结构。

为此,我们将使用 `Dict` 元素,它允许像普通字典一样通过键访问,也可以通过命名属性访问。此元素为找到的每个标记,将第一个字段作为键名,将后续字段作为值。当您可以使用 `Group` 元素将数据分组以仅包含两个字段时,这非常方便。

由于我们可能有三个(带单位),我们将把这些单位放在一边。

在 [ ]
def formatBloc(t):
    """ Format the result to have a list of (key, values) easily usable with Dict

    Add two fields :
        names_ : the list of column names found
        units_ : a dict in the form {key : unit}
    """
    rows = []

    # store units and names
    units = {}
    names = []

    for row in t :
        rows.append(ParseResults([ row.name, row.value ]))
        names.append(row.name)
        if row.unit : units[row.name] = row.unit[0]

    rows.append( ParseResults([ 'names_', names ]))
    rows.append( ParseResults([ 'unit_',  units]))

    return rows

paramParser = Dict( OneOrMore( Group(paramDef)).setParseAction(formatBloc))

此 `paramParser` 元素正是由 ConfigNumParser 中定义的 `paramParser` 函数创建的解析器。

让我们看看我们得到了什么。

在 [ ]
>>> paramParser.ignore('#' + restOfLine)
>>> data = paramParser.searchString(file('data.txt').read())[0]
>>> print data.dump()
[...]
- debug: False
- description: raw values can have multiple lines, but additional lines must start
with a whitespace which is automatically skipped
- length: 25361.15
- names_: ['debug', 'shape', 'length', 'path_1', 'description', 'parent']
- parent: None
- path_1: 'C:\\This\is\a\long\path\with some space in it\data.txt'
- shape: 2.3
- unit_: {'shape': 'mm^-1', 'length': 'mm'}
>>> data.length, data.unit_['length']
Out[12]: (25361.150000000001, 'mm')

定义表格解析器

对于解析参数声明,我们已经看到了大多数常见的技术,但其中一种技术除外:使用 `Forward` 元素来动态定义解析规则。

让我们看看如何使用它来解析按列定义的表格,根据以下模式

在 [ ]
Name_1       Name_2     ...      Name_n
            (unit_1)    (unit_2)    ...     (unit_n)
            value_11    value_21    ...     value_n1
              ...         ...       ...       ...

以及以下规则

* 名称 不能 包含 任何 空格。\ * 单位 是 必需的。\ * 值 可以 是 任何 标准 python 值 (int, number, None, False, True, NaN 或 带引号的字符串) 或 一个 原始字符串 它 不能 包含 空格 或 '['。

可以使用 ConfigNumParser 中定义的 `tableColParser` 函数生成这样的解析器。

问题的核心是告诉 pyparsing 每行应该具有相同数量的列,而这个数量是先验未知的。

使用 Forward 元素

我们将通过在读取标题行后立即定义与单位行及其后续行相对应的模式来解决这个问题。

实际上,这些行可以使用 `Forward` 元素定义,我们可以将 `parseAction` 附加到标题行,以便在知道标题中有多少列后重新定义这些元素。

重新定义 `Forward` 元素是通过 `<<` 运算符完成的。

在 [ ]
# We define ends-of-line and what kind of values we expect in tables
EOL          = LineEnd().suppress()
tabValueDef  = pyValue | CharsNotIn('[ \t\r\n').setWhitespaceChars(" \t")

# We define how to detect the first line, which is a header line
# following lines will be defined later
firstLine    = Group(OneOrMore(keyNameWithoutSpace)+EOL)
unitLine     = Forward()
tabValueLine = Forward()

def defineColNumber(t):
    """ Define unitLine and tabValueLine to match the same number of columns than
    the header line"""
    nbcols = len(t.header)
    unitLine      << Group( unitDef*nbcols + EOL)
    tabValueLine  << Group( tabValueDef*nbcols + EOL)

tableColDef = (   firstLine('header').setParseAction(defineColNumber)
                + unitLine('unit')
                + Group(OneOrMore(tabValueLine))('data')
              )

构建我们的数据

现在我们将以与处理参数相同的方式组织我们的数据,但这次我们将使用列名作为键,并将我们的数据转换为 numpy 数组。

在 [ ]
def formatBloc(t):
    """ Format the result to have a list of (key, values) easily usable
    with Dict and transform data into array

    Add two fields :
        names_ : the list of column names found
        units_ : a dict in the form {key : unit}
    """
    columns = []

    # store names and units names
    names = t.header
    units   = {}

    transposedData = zip(*t.data)
    for header, unit, data in zip(t.header, t.unit, transposedData):
        units[header] = unit
        columns.append(ParseResults([header, array(data)]))

    columns.append(ParseResults(['names_', names]))
    columns.append(ParseResults(['unit_'   , units  ]))

    return columns

tableColParser = Dict(tableColDef.setParseAction(formatBloc))

让我们看看我们得到了什么。

在 [ ]
>>> tableColParser.ignore('#' + restOfLine)
>>> data = tableColParser.searchString(file('data3.txt').read())[0]
>>> print data.dump()
[...]
- names_: ['station', 'precipitation', 't_max_abs', 't_min_abs']
- precipitation: [  64.8   49.6  114.2]
- station: ['Ajaccio' 'Auxerre' 'Bastia']
- t_max_abs: [ 18.8  16.9  20.8]
- t_min_abs: [-2.6  NaN -0.9]
- unit_: {'station': '/', 'precipitation': 'mm', 't_min_abs': 'C', 't_max_abs': 'C'}

构建最终解析器

我们现在有三种解析器

* `variableParser  :` 处理 变量 名称\ * `paramParser     :` 处理 一组 变量 定义\ * `tableColParser  :` 处理 按列定义的 表格

ConfigNumParser 中还有两个。

* `tableRowParser  :` 处理按行定义的表格\ * `matrixParser    :` 处理仅包含 Python 值或 NaN 的矩阵

我们不会在这里详细介绍它们,因为它们使用了我们已经见过的完全相同的技术。

我们将看到如何将它们组合成一个复杂的解析器,就像在 `parseConfigFile` 函数中所做的那样。

在 [ ]
# Section header
sectionName = Suppress('[') + keyName + Suppress(']')

# Group section name and content 
section = Group (sectionName +
                  ( paramParser()
                  | tableColParser()
                  | tableRowParser()
                  | matrixParser()
            )     )

# Build the final parser and suppress empty sections
parser = Dict( OneOrMore( section | Suppress(sectionName) ))

# Defines comments
parser.ignore('#' + restOfLine)

就这样。

解析器现在可以通过其方法 `parseString` 或 `parseFile` 使用。有关更多信息,请参见 [附件:ConfigNumParser_v0.1.1.py ConfigNumParser]。

我希望这能为您提供一个很好的起点来阅读复杂的格式化文本。

章节作者:Elby

附件