9.7 转储和加载CSV

    csv模块将简单的listdict实例进行编码和解码,存入CSV格式。可对于之前讨论的json模块,这并不是一个完整的持久化方案。然而,由于大量使用了CSV文件,意味经常需要在Python对象和CSV文件中的每条记录间进行转换。

    在与CSV文件交互的过程中会需要在对象与CSV结构之间完成一些映射。需要对映射过程仔细地设计,要考虑到CSV格式的限制。由于具有高度表达力的对象与表格格式的CSV文件记录之间是有很大区别的,使得映射工作更有难度。

    一个CSV文件中每列的内容只是纯文本。当从一个CSV文件中加载数据时,需要在应用程序中将这些值转换为具体的类型。对电子表格做类型转换的过程可能会很复杂,因为要考虑到异常的类型。例如,在一个电子表格中,US ZIP代码被改为了浮点数。当将这个电子表格保存为CSV时,ZIP代码就可能显示为看起来奇怪的值。

    因此,可能需要使用一种转换,例如使用('00000'+row['zip']) [-5:]来还原前面的0。另一个场景是使用"{:05.0f}".format(float(row['zip']))来还原前面的0。另外,不要忘了有时文件可能同时包含了ZIP和ZIP+4的邮编格式,进一步增加了工作的挑战性。

    在更复杂的与CSV文件的交互场景中,必须考虑到它们有时由于被人为的改动导致不再兼容。为了应对非法操作,灵活性对于一个软件是很重要的。

    当有了相对简单的类定义,可以经常将类实例转换为简单的数据行。在一个CSV源文件和Python对象之间,namedtuple是一个很好的选择。如果应用程序需要以CSV格式保存数据,另一种途径,就需要基于namedtuple设计自己的Python类。

    如果有一些类,它们本身是容器。通常,将结构化的容器表示为CSV中的行是很困难的。这是一个阻抗失谐问题,主要发生在数据模型与CSV文件或关系数据库中的数据表之间。对于阻抗失谐问题,目前没有一个很好的方案,它需要在设计上进行仔细的考虑。我们将以简单的对象为开始,演示一些CSV的映射方式。

    9.7.1 将简单的序列转储为CSV

    namedtuple实例和CSV文件的行记录之间的映射是不难的,每行表示了一个不同的namedtuple,参见如下的Python类,。

    from collections import namedtuple
    GameStat = namedtuple( "GameStat", "player,bet,rounds,final" )

    以上定义了一些对象,它们是简单的属性序列。数据库架构师称为第一范式。没有重复的记录并且每条记录都是原子数据,可使用如下代码从一个模拟器中创建这些对象。

    def gamestatiter( player, betting, limit=100 ):
      for sample in range(30):
        b = Blackjack( player(), betting() )
        b.untilbrokeorrounds(limit)
        yield GameStat( player.__name
    , betting.__name
    , b.rounds,
    b.betting.stake )

    这个迭代器将创建21点模拟器,包括了一个玩家和下注策略。它将持续运行,直到玩家破产或玩了 100 个回合。每个回合结束,将返回一个GameStat对象,其中包含了玩家策略、下注策略、回合数以及最后的底金。可以使用这个对象来对每次游戏、下注策略或组合的情况进行统计计算。以下是将统计结果写入文件的代码实现,之后可用于分析。

    import csv
    with open("blackjack.stats","w",newline="") as target:
      writer= csv.DictWriter( target, GameStat._fields )
      writer.writeheader()
      for gamestat in gamestat_iter( Player_Strategy_1, Martingale_Bet
    ):
        writer.writerow( gamestat._asdict() )

    创建一个CSV writer需要以下3个步骤。

    1.以newline选项(赋值为"")打开一个文件。这是为了支持(可能)CSV文件中非标准的行。

    2.创建一个CSV writer对象。在这个例子中,我们创建了DictWriter实例,可以用来从字典对象中简单地创建行。

    3.在文件的第1行设置标题。这样可以通过为CSV文件中的记录提供一些提示来简化数据交换的操作。

    一旦创建好了writer对象,就可以使用writer中的writerow()方法将字典写入CSV文件中。出于扩展的目的,可使用writerows()方法来简化实现。这个方法将接收一个迭代器而不是独立的行,以下是如何使用迭代器调用writerows()方法的示例。

    data = gamestat_iter( Player_Strategy_1, Martingale_Bet )
    with open("blackjack.stats","w",newline="") as target:
      writer= csv.DictWriter( target, GameStat._fields )
      writer.writeheader()
      writer.writerows( g._asdict() for g in data )

    将迭代器赋值给了一个data变量。为writerows()方法提供一个字典,它的每行记录都来自于迭代器。

    9.7.2 从CSV文件中加载简单的序列

    可以从一个CSV文件加载简单的序列对象,如下面代码所示,使用一个循环来完成加载过程。

    with open("blackjack.stats","r",newline="") as source:
      reader= csv.DictReader( source )
      for gs in
    ( GameStat(**r) for r in reader ):
        print( gs )

    我们为文件定义了一个reader对象。如我们所看到的,文件中包含了标题,可以使用DictReader。这样就会使用第1行来定义属性名称。现在可以使用CSV文件中的行来构造GameStat对象。我们使用了一个生成器表达式来创建行。

    在这种情况下,我们假设列名与GameStat类中定义的属性名是匹配的。如果必要,可以通过对比reader.fieldnamesGameStat.fields来确定文件格式是所期望的。由于顺序不必一致,因此可以将每个成员名称列表转换为一个集合。以下是我们检查列名的操作。

    assert set(reader.fieldnames) == set(GameStat._fields)

    我们忽略了从文件中所读取记录的类型。当读取CSV文件时,两个数字列的值最终将被当作字符串来对待。为了创建正确的数据,就需要引入更复杂的行到行的转换机制。以下是一个典型的工厂函数,用于完成数据转换。

    def gamestat_iter(iterator):
      for row in iterator:
        yield GameStat( row['player'], row['bet'], int(row['rounds']),
    int(row['final']) )

    我们将int函数应用于了一些数字列。一个文件中不该有正确的标题却对应了错误的数据,我们将从一个失败的int()函数中得到一个普通的ValueError错误对象。可以像下面这样使用这个生成器函数。

    with open("blackjack.stats","r",newline="") as source:
      reader= csv.DictReader( source )
      assert set(reader.fieldnames) == set(GameStat._fields)
      for gs in gamestat_iter(reader):
        print( gs )

    在这个版本的reader中,由于执行了适当的数值类型的转换,从而正确地创建了GameStat对象。

    9.7.3 处理集合与复杂的类

    回顾一下博客的例子,我们有一个Blog对象,包含了许多Post实例。在示例中,Bloglist的封装,因此Blog将包含一个集合。当与CSV记录交互时,就必须设计从复杂结构到表格格式的映射。我们有3种常见的方案。

    • 我们可以创建两个文件:博客和文章。博客文件中只有Blog实例。在我们的例子中,每个Blog有一个标题。每个Post的行包含了一个对Blog行的引用,用于表示这一行属于哪篇文章。我们需要为每个Blog都添加一个键。每个Post就可以包含一个外键,作为对Blog键的引用。
    • 我们可以在一个文件中创建两种行,包括Blog行和Post行。writer负责将不同数值类型混合在一起写入文件;reader在读取时将不同数据的类型分开。
    • 我们可以通过使用关系数据库中的连接在不同类型的行数据之间建立关系,在每个Post的子记录中重复Blog的父记录。

    在以上选择中,不存在最优方案。必须设计一种方案能够解决在CSV文件中的行与结构化的Python对象之间的阻抗不匹配问题,这些数据的用例将产生一些优点和缺点。

    创建两个文件的同时为每个Blog创建唯一标识,这样就使得一个Post可以正确地引用Blog。不能使用Python内部的ID,因为在每次Python运行后它们并不能保证一致性。

    一个常见的做法是使用Blog标题作为唯一的键值,因为它是Blog的一个属性,可看作是天然的主键。这种方案也不是有效的,我们无法在不更新所有引用自BlogPosts的同时,对一个Blog的标题进行更新。一种更好的做法是创建唯一标识并在类定义中包含它,这称为代理主键。可使用Python中的uuid模块来提供唯一标识。

    使用多个文件的代码实现与之前的例子几乎是相同的。唯一区别就是在Blog类中添加了一个适当的主键。一旦定义了键值,就可以使用之前看到的writerreader来处理与不同文件交互的BlogPost实例。

    9.7.4 在一个CSV文件中转储并从多类型的行中加载数据

    在一个文件中创建多种类型的行使得格式更复杂了。需要将所有可用列的标题合成为一个整体。由于在不同行类型之间存在命名冲突,对行进行访问时,要么通过位置——会阻止我们直接调用csv.DictReader的方式;或者创建复杂的标题,将类与属性名结合。

    如果每行使用另外一列存放类修饰符的话,操作起来就会容易些。这个附加列会告诉我们这行对象对应哪种类型。这列存放对象的类名就可以了。以下代码演示了如何将CSV文件中两种不同行格式的记录写入博客和文章。

    with open("blog.csv","w",newline="") as target:
      wtr.writerow(['class','title','date','title','rst_
    text','tags'])
      wtr= csv.writer( target )
      for b in blogs:
        wtr.writerow(['Blog',b.title,None,None,None,None])
        for p in b.entries:
          wtr.writerow(['Post',None,p.date,p.title,p.rst_text,p.
    tags])

    我们基于文件中的行创建了两个变量。一些行的第1列包含了'Blog',并只包含有Blog对象的属性。另一些行的第1列含有'Post'并仅包含Post对象的属性。

    我们并没有将标题设为是唯一的,因此不能使用dictionary reader。当像这样来分配列的位置时,由于其他类型行的存在,每行有一些列是没有被用到的。这些列将被填充为 None。随着越来越多不同行类型的引入,管理不同位置列的分配是一项很有挑战的工作。

    另外,个别的数值类型转换显得有些奇怪。特别是,我们忽略了timestamptags类型。我们可以通过验证每行的修饰符来重新整合BlogsPosts

    with open("blog.csv","r",newline="") as source:
      rdr= csv.reader( source )
      header= next(rdr)
      assert header == ['class','title','date','title','rst_
    text','tags']
      blogs = []
      for r in rdr:
        if r[0] == 'Blog':
          
    blog= Blog( *r[1:2] )
          blogs.append( blog )
        if r[0] == 'Post':
          
    post= post_builder( r )
          blogs[-1].append( post )

    这段代码将创建一个Blog对象的列表。每个'Blog'的行使用了splice(1,2)中的列来定义Blog对象。每个'Post'行使用了splice(2,6)中的列来定义一个Post对象。这需要每个Blog正确地对应了相关的Post实例,仅仅使用一个外键并不能将两个对象关联在一起。

    我们假设在CSV文件中的列和类构造器参数的类型具有相同顺序。对于Blog对象,我们使用了blog=Blog( *r[1:2] ),因为one-and-only列是文本,与类构造器是匹配的。当与外部提供的数据一起工作时,这个假设可能就不成立了。

    为了创建Post实例,我们使用了一个单独的函数来完成从列到类构造器的映射。这里是映射函数。

    import ast
    def builder( row ):
      return Post(
        date=datetime.datetime.strptime(row[2], "%Y-%m-%d %H:%M:%S"),
        title=row[3],
        rst_text=row[4],
        tags=ast.literal_eval(row[5]) )

    以上代码会基于文本行正确地创建一个Post实例。它将datetime文本和标签的文本转换为了相应的Python类型,它的优势是显式地完成映射。

    在这个例子中,我们使用ast.literal_eval()来对Python中更复杂的文本值进行解码。允许CSV数据中包含一组字符串值组成的元组:"('#RedRanger', '#Whitby42', '#ICW')"

    9.7.5 使用迭代器筛选CSV中的行

    可以对之前的加载示例代码进行重构,对Blog对象进行迭代而不是返回Blog对象组成的列表。这样一来,当浏览一个很大的CSV文件时,只需查找相关的BlogPost的行记录就可以了。这个函数是一个生成器,会分别返回每个Blog实例。

    def blogiter(source):
      rdr= csv.reader( source )
      header= next(rdr)
      assert header == ['class','title','date','title','rst

    text','tags']
      blog= None
      for r in rdr:
        if r[0] == 'Blog':
          if blog:
            
    yield blog
          blog= Blog( *r[1:2] )
        if r[0] == 'Post':
          post= post_builder( r )
          blog.append( post )
      if blog:
        
    yield blog

    这个blog_iter()函数创建了Blog对象并附加了Post对象。每当下一个Blog表头出现时,之前的Blog就算完成了并且返回。最终,Blog对象也需要被返回。如果我们需要那个很大的Blog实例列表,可以使用如下这段代码。

    with open("blog.csv","r",newline="") as source:
      blogs= list( blog_iter(source) )

    以上代码会使用迭代器来创建一个Blogs列表,在很少情况下我们会需要用到内存中整个序列。可使用如下代码来分别对每个Blog进行处理并创建RST文件。

    with open("blog.csv","r",newline="") as source:
      for b in blog_iter(source):
        with open(blog.title+'.rst','w') as rst_file:
          render( blog, rst_file )

    我们使用了blog_iter()函数来读取每篇博客。每次读完,都可以使用RST格式的文件来表示。可使用另一个进程通过运行rst2html.py来将每篇博客转换为HTML格式。

    我们可以简单地通过添加一个过滤器来做到只处理选中的Blog实例。可以添加一个if语句来决定哪些Blogs需要渲染,而不是渲染全部。

    9.7.6 从CSV文件中转储和加载连接的行

    将所有对象连接起来意味着每行是一个与所有父对象连接的子对象,这样会导致父对象的属性在每个子对象是重复的。如果有多个级别的容器,会导致大量的重复数据。

    重复带来的优势是每行是独立的,并且不依赖于上下文,这个上下文是基于在它上面的行来定义的。并不需要使用一个类修饰符来存放父对象的值,这些值在每个子对象中都存在。

    这种方式对于简单层次结构的数据是可行的,每个子对象中添加了一些父对象的属性。当数据涉及更复杂的关系时,简单的父子结构就不适用了。在这些例子中,我们使用文本中单独的一列来集中放置Post标签。如果希望将标签分散到不同的列中,它们将成为每个Post的子对象,意味着Post文本会在每个tag中重复。显然,这种方式更好一些。

    列标题必须将所有可用的列标题进行合成。由于在不同行之间的命名冲突是有可能存在的,我们会使用类名来作为列名。列标题可能会是'Blog.Title''Post.title',这样就避免了命名冲突。这种机制也允许使用DictReaderDictWriter,而不是根据位置分配列名。然而,这些列名并不会进一步完成类定义中属性名称的匹配,这导致了解析标题的过程需要更多的文本处理来完成。以下代码演示了如何将已经连接了父对象和子对象及其属性的行写入文件。

    with open("blog.csv","w",newline="") as target:
      wtr= csv.writer( target )
      wtr.writerow(['Blog.title','Post.date','Post.title', 'Post.
    tags','Post.rst_text'])
      for b in blogs:
        for p in b.entries:
          wtr.writerow([b.title,p.date,p.title,p.tags,p.rst_text])

    以上实现包含适当的列标题。在这种格式中,每行包含了Blog属性与Post属性合成后的结果。这种结构更容易构造,因为不需要将不需要的列填充为None。由于每列的名称是唯一的,因此可以很方便地使用DictWriter。以下这种方式基于CSV行的输入对原容器进行了重新构造。

    def blog_iter2( source ):
      rdr= csv.DictReader( source )
      assert set(rdr.fieldnames) == set(['Blog.title','Post.date','Post.
    title', 'Post.tags','Post.rst_text'])
      row= next(rdr)
      blog= Blog(row['Blog.title'])
      post= post_builder5( row )
      blog.append( post )
      for row in rdr:
        if row['Blog.title'] != blog.title:
          yield blog
          blog= Blog( row['Blog.title'] )
        post= post_builder5( row )
        blog.append( post )
      yield blog

    数据的第1行用于创建一个Blog实例以及Blog中的第1个Post对象。在循环中,不可变条件会假设存在一个适当的Blog对象。一个有效的Blog实例使得逻辑更简化了。Post的实例是使用以下这个函数创建的。

    import ast
    def post_builder5( row ):
      return Post(
        date=datetime.datetime.strptime(
          row['Post.date'], "%Y-%m-%d %H:%M:%S"),
        title=row['Post.title'],
        rst_text=row['Post.rst_text'],
        tags=ast.literal_eval(row['Post.tags']) )

    我们对每行中的每列都进行映射,这个映射将每一列转换为类构造器的参数。这使得所有的转换都是显式进行的。它很好地完成了从CSV文本到Python对象的类型转换。

    我们可能希望将Blog生成器重构为一个单独的函数。然而,这并没有完全遵从DRY原则,可对于这么小的功能来说似乎过于挑剔了。因为列标题匹配了参数名,所以可以使用如下代码来生成对象。

      def makeobj( row, class=Post, prefix="Post" ):
        column_split = ( (k,)+tuple(k.split('.')) for k in row )
        kw_args = dict( (attr,row[key])
          for key,classname,attr in column_split if
    classname==prefix )
        return class( **kw_args )

    这里使用了两个表达式生成器。第1个表达式生成器将列名解析为类和属性,并创建了三元组,包括全键、类名和属性名。第2个表达式生成器对目标类进行了筛选,它使用属性和键值对创建了一个二元组的序列,可用于创建字典。

    这并没有解决Posts的数据转换问题。每个列的映射操作还没有统一。当将其与post_builder5()函数对比时,添加更多处理逻辑并不会有太大作用。

    空文件的情况并不是很常见——具有一个标题行但是包含0条Blog记录——初始化表达式row=next(rdr)将导致一个StopIteration异常。由于这个异常并没有在生成器函数中被处理,它将冒泡进入blog_iter2()的循环中,这个循环最后将终止执行。