且构网

分享程序员开发的那些事...
且构网 - 分享程序员编程开发的那些事

《Python 3程序开发指南(第2版•修订版)》—— 7.2 文本文件的写入与分析

更新时间:2022-10-12 12:14:07

本节书摘来自异步社区《Python 3程序开发指南(第2版•修订版)》一书中的第7章,第7.2节,作者[英]Mark Summerfield,王弘博,孙传庆 译,更多章节内容可以访问云栖社区“异步社区”公众号查看。

7.2 文本文件的写入与分析

写入文本是容易的,读回时则可能存在不少问题,因此,我们需要认真选择合适的结构,以便对其进行分析不至于太难。图7-4以我们将要使用的文本格式展示了一个航空器事故实例。将航空器事故记录写入文件时,我们将在每个记录后附加一个空白行,但对文件进行分析时,我们可以接受在事故记录之间存在0个或多个空白行。


《Python 3程序开发指南(第2版•修订版)》—— 7.2 文本文件的写入与分析

7.2.1 写入文本

每条事故记录都以包含在方括号中的报告ID开始,其后跟随的是所有占据一行的数据项,其格式为key=value,对占据多行的叙述性文本,在文本前以开始标记(NARRATIVE_START)引导,在文本末尾则以结束标记(NARRATIVE_END)结尾,我们对两个标记之间的所有文本进行了缩排,以防止文本行与开始或结束标记混淆。

下面给出的是export_text()函数的代码,但没有给出except语句块与finally语句块,因为这两个语句块与前面相应实例中是相同的,不同之处仅在于待处理的异常:

def export_text(self, filename):
    wrapper = textwrap.TextWrapper(initial_indent="     ",
                                    subsequent_indent="     ")
    fh = None
    try:
        fh = open(filename, "w", encoding="utf8")
        for incident in self.values():
            narrative = "\n".join(wrapper.wrap(
                             incident.narrative.strip()))
            fh.write("[{0.report_id}]\n"
                    "date={0.date!s}\n"
                    "aircraft_id={0.aircraft_id}\n"
                    "aircraft_type={0.aircraft_type}\n"
                    "airport={airport}\n"
                    "pilot_percent_hours_on_type="
                    "{0.pilot_percent_hours_on_type}\n"
                    "pilot_total_hours={0.pilot_total_hours}\n"
                    "midair={0.midair:d}\n"
                    ".NARRATIVE_START.\n{narrative}\n"
                    ".NARRATIVE_END.\n\n".format(incident,
                airport=incident.airport.strip(),
                narrative=narrative))
        return True

叙述性文本中存在断行并不会有很大影响,因此我们可以按我们的需求对文本进行包裹。通常,我们可以使用textwrap模块的textwrap.wrap()函数,但这里我们同时需要缩排与包裹,因此我们首先创建了一个textwrap.TextWrap对象,并用我们将要使用的缩排(第一行与后续行都是4个空格)进行初始化。默认情况下,该对象可以包裹宽度为70个字符的行,当然,通过使用关键字参数,也可以对其进行改变。

我们可以使用三引号包含的字符串,但我们更愿意手动加入换行。textwrap.TextWrap对象提供了一个wrap()方法,该方法以一个字符串作为输入,在这里就是叙述性的文本,并返回一个字符串列表,带有适当的缩排,并且其中每个字符串的长度不超过wrap宽度。之后,我们将这些行添加到一个单独的字符串中,并使用换行作为分隔符。事故日期以datetime.date对象的形式存放,在写入日期数据时,我们强制str.format()使用字符串表示形式——这样可以很便利地产生符合ISO 8601的日期数据格式,也即YYYY-MM-DD格式。此外,我们告知str.format()将布尔型变量midair写为整数——如果为True,就变为1,如果为false,就变为0。通常,str.format()可以使写入文本变得很容易,因为该方法可以自动处理所有Python数据类型。(也包括自定义数据类型,如果实现了__str__()或__format__()特殊方法。)

7.2.2 分析文本

与进行文本写入的方法相比,用于读取与分析文本格式航空器事故记录的方法要更长一些,也更棘手。读取文件时,我们可能会处于几种状态之间的一种:读取叙述性文本行的过程中;读取key=value行的过程中;读取报告ID行的过程中(在新事故记录的起始处)。我们将分5个部分来查看import_text_manual()方法。

def import_text_manual(self, filename):
    fh = None
    try:
        fh = open(filename, encoding="utf8")
        self.clear()
        data = {}
        narrative = None

该方法首先以“文本读”模式打开文件,之后清空事故字典,创建data字典,以便存放单个事故记录的数据(与二进制读事故记录时的做法相同)。narrative变量用于两个目的:作为一个状态指示器;存储当前事故记录的叙述性文本。如果narrative为None,就意味着我们当前读取的不是叙述性文本;如果是一个字符串(即便是空字符串),也意味着我们正在读取叙述性文本行。

for lino, line in enumerate(fh, start=1):
    line = line.rstrip()
    if not line and narrative is None:
        continue
    if narrative is not None:
        if line == ".NARRATIVE_END.":
           data["narrative"] = textwrap.dedent(
                                      narrative).strip()
           if len(data) != 9:
              raise IncidentError("missing data on "
                             "line {0}".format(lino))
           incident = Incident(**data)
           self[incident.report_id] = incident
           data = {}
           narrative = None
        else:
           narrative += line + "\n"

在采用文本读模式时,数据的读入是逐行进行的,因此我们可以保持对当前行号的追踪,并提供包含信息更多的错误消息(与读取二进制文件相比)。我们首先剥离每行的结尾处的空白字符,如果处理之后剩下的是空行(假定没有处在读取叙述性文本的过程中),就简单地跳到下一行。这意味着,事故记录中的空白行并不会有什么影响,我们保留叙述性文本中任意的空白行。

如果narrative不为None,就说明我们处于读取叙述性文本的过程中。如果当前行表示的是叙述性文本的结尾标记,就说明我们不仅完成了叙述性文本的读取,也完成了整个事故记录的读取。在这种情况下,我们将叙述性文本存放到data字典(之前使用textwrap.dedent()函数移除缩排),假定我们获取了事故记录的9个要素,则创建一个新事故,并将其存放在字典中。之后,清空data字典,并重置narrative变量,以备读取下一条记录。另一方面,如果当前行不包含叙述性文本标记,我们就将其附加到narrative中——并剥离开始处的换行。

elif (not data and line[0] == "["
             and line[-1] == "]"):
   data["report_id"] = line[1:-1]

narrative为None,说明我们或者在读取新报告ID,或者在读取其他数据。只有在data字典为空(因为该字典最初为空,并且在读取每个事故记录结束后也将其清空)并且该行以[开始并以]结束时,才可以判断当前在读取新报告ID。如果是这种情况,就将报告ID放置到data字典中。这也意味着,直至data字典下一次被清空,elif条件才会变为True。

elif "=" in line:
    key, value = line.split("=", 1)
    if key == "date":
        data[key] = datetime.datetime.strptime(value,
                                     "%Y-%m-%d").date()
    elif key == "pilot_percent_hours_on_type":
        data[key] = float(value)
    elif key == "pilot_total_hours":
        data[key] = int(value)
    elif key == "midair":
        data[key] = bool(int(value))
    else:
        data[key] = value
elif line == ".NARRATIVE_START.":
    narrative = ""
else:
    raise KeyError("parsing error on line {0}".format(
                  lino))

如果当前没有处于读取叙述性文本的状态或读取新报告ID的状态,就只有3种可能:当前正在读取key=value行;当前正在读取叙述性文本开始标记;出错。

如果当前正在读取key=value行,就可以使用第一个=字符分割该行,并指定一次分割的最大值——这意味着value中可以安全地包含字符=。读入的所有数据都以Unicode字符串形式存在,因此,对于日期、数值型、布尔型等数据类型,我们必须相应地对值字符串进行转换。

对日期数据,我们使用datetime.datetime.strptime()函数(“字符串分析时间”),该函数以一个格式化字符串为参数,并返回一个datetime.datetime对象。我们使用了匹配ISO 8601日期格式的格式化字符串,并使用datetime.datetime.date()从产生的datetime.datetime对象中取回一个datetime.date对象,因为我们需要的只是日期,而不是日期/时间。对数值型值的转换,我们依赖于Python内置的类型函数float()与int()。尽管如此,要注意的是,int("4.0") 这种语句会产生ValueError异常,如果接受整数时希望更具字面上的意义,可以使用int(float("4.0"));如果需要四舍五入,而不是截取,则可以使用round(float("4.0"))。获取bool更加微妙——比如,bool("0")返回True(非空字符串都为True),因此,我们必须首先将字符串转换为int。

无效的、丢失的、超出范围的值总是会产生异常。任意其他转换操作失败,都将产生ValueError异常。在数据用于创建相应的Incident对象时,任何值超出了范围,都会产生IncidentError异常。

某行不包含字符=,我们可以检查是否已经读取了叙述性文本开始标记,如果已经读取,就将narrative变量设置为空字符串,这意味着对后继行而言,第一个if条件为True——直至读取到描述性文本结束标记。

if条件或elif条件都不为True,说明有错误产生,因此,在最后的else语句中,产生一个KeyError异常来表示这一情况。

    return True
except (EnvironmentError, ValueError, KeyError,
        IncidentError) as err:
    print("{0}: import error: {1}".format(
         os.path.basename(sys.argv[0]), err))
    return False
finally:
    if fh is not None:
       fh.close()

读取所有行后,为调用者返回True——除非发生异常,在这种情况下,except语句块将捕获该异常,为用户打印出错误消息,并返回False。最后,不论哪种情况,打开的文件都要关闭。

7.2.3 使用正则表达式分析文本

对不熟悉正则表达式(“regexes”)的读者,建议在阅读本小节之前先阅读第13章——或先跳到下一节,并在需要的时候再回到本小节。

与手动完成一切分析工作(如前面小节中所做的)相比,使用正则表达式分析文本文件通常需要更少的代码量,但这种方式下产生较好的错误报告会更加困难。我们将分两个部分来查看import_text_regex()方法,首先查看正则表达式,之后查看其分析过程——但忽略了except语句块与finally语句块,因为没有什么新东西可以学习。

def import_text_regex(self, filename):
    incident_re = re.compile(
                    r"\[(?P<id>[^]]+)\](?P<keyvalues>.+?)"
                    r"^\.NARRATIVE_START\.$(?P<narrative>.*?)"
                    r"^\.NARRATIVE_END\.$",
                    re.DOTALL|re.MULTILINE)
    key_value_re = re.compile(r"^\s*(?P<key>[^=]+)\s*=\s*"
                              r"(?P<value>.+)\s*$", re.MULTILINE)

正则表达式写成原始字符串的形式,这使得我们不再需要双写每个反斜杠(将每个写成\)——比如,没有合适的原始字符串,则第二个正则表达式必须写为"^\s(?P1+)\s=\s(?P.+)\s$"。本书中,对于正则表达式,我们总是使用原始字符串形式表示。

第一个正则表达式incident_re用于匹配完整的事故记录,该表达式的一个效果是事故记录之间任何伪造的文本将不会被注意。该表达式实际上包含两个组成部分,第一部分是[(?P[^]]+)](?P.+?),用于匹配[,之后寻找尽可能多的非]字符,并将其与id匹配组匹配,再之后匹配字符](上面操作的整体效果是匹配一个报告ID),之后再寻找尽可能少(但至少一个)的任意字符(包括换行,因为re.DOTALL标记的存在),并将其与keyvalues匹配组进行匹配。与keyvalues匹配组进行匹配的字符只要求是能过渡到正则表达式第二部分的必要的最小值。

第一个正则表达式的第二部分是^.NARRATIVE_START.$(?P.*?) ^\.NARRATIVE_END\.$,用于与文本NARRATIVE_START进行匹配,之后将尽可能少的字符与narrative匹配组进行匹配,再之后就是文本NARRATIVE_END,实际上也就到了每条记录的结尾。re.MULTILINE标记意味着,在这一正则表达式中,^匹配每一行的起始处(而不仅仅是在字符串的起始处),$匹配每一行的结尾处(而不仅仅是在字符串的结尾处),因此,叙述性文本的开始标记与结束标记只在行起始处进行匹配。

第二个正则表达式key_value_re用于捕获key=value行,该表达式在给定文本的每一行的起始处进行匹配,匹配行以任意数量(也可以没有)的空白字符开始,其后跟随非-=字符(被捕获到key匹配组),再其后跟随一个=字符,最后是该行所有余下的字符(不包括开始的与结尾的空白字符),并将这些内容捕获到value匹配组。

使用正则表达式对文件进行分析的基本逻辑与前面讲述的人工分析的逻辑是相同的,区别只在于这里是使用正则表达式来提取事故记录以及记录内的数据,而不再逐行读取。

fh = None
try:
    fh = open(filename, encoding="utf8")
    self.clear()
    for incident_match in incident_re.finditer(fh.read()):
        data = {}
        data["report_id"] = incident_match.group("id")
        data["narrative"] = textwrap.dedent(
                    incident_match.group("narrative")).strip()
        keyvalues = incident_match.group("keyvalues")
        for match in key_value_re.finditer(keyvalues):
            data[match.group("key")] = match.group("value")
        data["date"] = datetime.datetime.strptime(
                                    data["date"], "%Y-%m-%d").date()
        data["pilot_percent_hours_on_type"] = (
                float(data["pilot_percent_hours_on_type"]))
        data["pilot_total_hours"] = int(
                data["pilot_total_hours"])
        data["midair"] = bool(int(data["midair"]))
        if len(data) != 9:
            raise IncidentError("missing data")
        incident = Incident(**data)
        self[incident.report_id] = incident
    return True

re.finditer()方法返回一个迭代子,该迭代子依次产生每个非交叠的匹配。与前面所做的一样,我们创建一个data字典来存放每个事故的数据,但这一次,我们是从正则表达式incident_re的每个匹配中获取报告ID与叙述性文本,之后使用keyvalues匹配组一次提取所有key=value字符串,并使用正则表达式key_value_re的re.finditer()方法对每个单独的key=value行进行迭代。对发现的每个(key, value)对,我们将其放置在data字典中——因此所有的值都以字符串的形式存在。之后,对那些应该不是字符串的值,我们使用适当类型的值对其进行替代(并像我们在手动分析文本时一样对其进行字符串转换)。

我们添加了相应的检测机制,以确保data字典包含9个项,因为如果某个事故记录损坏,迭代子key_value.finditer()就可能匹配过多或过少的key=value行。结尾部分与前面一样——我们创建一个新的Incident对象,并将其放置在事故字典中,之后返回True。如果有任何一处出错,那么except suite将产生一个适当的错误消息,并返回False,并且由finally suite关闭文件。

我们看到,无论是手动进行的文本分析器,还是使用正则表达式进行的文本分析器都很短小,之所以会这样,原因之一就是Python的异常处理机制。文本分析器不需要检测字符串到日期、数字或布尔型的任何转换,也不需要进行区间检测(Incident类做这一工作),如果这些转换或区间失败,就会产生一个异常,而所有这些异常都将在结尾处统一处理。使用异常处理机制而不使用显式检测的另一个好处是,代码具有良好的可扩展性——即便记录格式发生变化、包含更多的数据项,异常处理代码也不需要进行改变。


  1. =