【问题标题】:How to output XML from BeautifulSoup without extraneous newlines?如何在没有多余换行符的情况下从 BeautifulSoup 输出 XML?
【发布时间】:2020-02-09 04:41:31
【问题描述】:

我正在使用 Python 和 BeautifulSoup 来解析和访问 XML 文档中的元素。我修改了几个元素的值,然后将 XML 写回到文件中。问题是更新后的 XML 文件在每个 XML 元素的文本值的开头和结尾都包含换行符,导致文件看起来像这样:

<annotation>
 <folder>
  Definitiva
 </folder>
 <filename>
  armas_229.jpg
 </filename>
 <path>
  /tmp/tmpygedczp5/handgun/images/armas_229.jpg
 </path>
 <size>
  <width>
   1800
  </width>
  <height>
   1426
  </height>
  <depth>
   3
  </depth>
 </size>
 <segmented>
  0
 </segmented>
 <object>
  <name>
   handgun
  </name>
  <pose>
   Unspecified
  </pose>
  <truncated>
   0
  </truncated>
  <difficult>
   0
  </difficult>
  <bndbox>
   <xmin>
    1001
   </xmin>
   <ymin>
    549
   </ymin>
   <xmax>
    1453
   </xmax>
   <ymax>
    1147
   </ymax>
  </bndbox>
 </object>
</annotation>

我宁愿让输出文件看起来像这样:

<annotation>
 <folder>Definitiva</folder>
 <filename>armas_229.jpg</filename>
 <path>/tmp/tmpygedczp5/handgun/images/armas_229.jpg</path>
 <size>
  <width>1800</width>
  <height>1426</height>
  <depth>3</depth>
 </size>
 <segmented>0</segmented>
 <object>
  <name>handgun</name>
  <pose>Unspecified</pose>
  <truncated>0</truncated>
  <difficult>0</difficult>
  <bndbox>
   <xmin>1001</xmin>
   <ymin>549</ymin>
   <xmax>1453</xmax>
   <ymax>1147</ymax>
  </bndbox>
 </object>
</annotation>

我打开文件并像这样得到“汤”:

    with open(pascal_xml_file_path) as pascal_file:
        pascal_contents = pascal_file.read()
    soup = BeautifulSoup(pascal_contents, "xml")

完成对文档的几个值的修改后,我使用BeautifulSoup.prettify 将文档重写回文件,如下所示:

    with open(pascal_xml_file_path, "w") as pascal_file:
        pascal_file.write(soup.prettify())

我的假设是BeautifulSoup.prettify 默认情况下会添加这些多余/无用的换行符,并且似乎没有修改这种行为的好方法。我是否错过了 BeautifulSoup 文档中的某些内容,或者我真的无法修改此行为并且需要使用另一种方法将 XML 输出到文件?也许我最好改用xml.etree.ElementTree 重写它?

【问题讨论】:

    标签: python xml beautifulsoup elementtree


    【解决方案1】:

    我的假设是 BeautifulSoup.prettify 正在添加这些 默认情况下多余/无偿的换行符,并且没有出现 是修改这种行为的好方法。

    它在bs4.Tagdecodedecode_contents 的两个方法中这样做。

    参考:Source file on github

    如果你只是需要一个临时修复,你可以猴子补丁这两种方法

    这是我的实现

    from bs4 import Tag, NavigableString, BeautifulSoup
    from bs4.element import AttributeValueWithCharsetSubstitution, EntitySubstitution
    
    
    def decode(
        self, indent_level=None,
        eventual_encoding="utf-8", formatter="minimal"
    ):
        if not callable(formatter):
            formatter = self._formatter_for_name(formatter)
    
        attrs = []
        if self.attrs:
            for key, val in sorted(self.attrs.items()):
                if val is None:
                    decoded = key
                else:
                    if isinstance(val, list) or isinstance(val, tuple):
                        val = ' '.join(val)
                    elif not isinstance(val, str):
                        val = str(val)
                    elif (
                        isinstance(val, AttributeValueWithCharsetSubstitution)
                        and eventual_encoding is not None
                    ):
                        val = val.encode(eventual_encoding)
    
                    text = self.format_string(val, formatter)
                    decoded = (
                        str(key) + '='
                        + EntitySubstitution.quoted_attribute_value(text))
                attrs.append(decoded)
        close = ''
        closeTag = ''
        prefix = ''
        if self.prefix:
            prefix = self.prefix + ":"
    
        if self.is_empty_element:
            close = '/'
        else:
            closeTag = '</%s%s>' % (prefix, self.name)
    
        pretty_print = self._should_pretty_print(indent_level)
        space = ''
        indent_space = ''
        if indent_level is not None:
            indent_space = (' ' * (indent_level - 1))
        if pretty_print:
            space = indent_space
            indent_contents = indent_level + 1
        else:
            indent_contents = None
        contents = self.decode_contents(
            indent_contents, eventual_encoding, formatter)
    
        if self.hidden:
            # This is the 'document root' object.
            s = contents
        else:
            s = []
            attribute_string = ''
            if attrs:
                attribute_string = ' ' + ' '.join(attrs)
            if indent_level is not None:
                # Even if this particular tag is not pretty-printed,
                # we should indent up to the start of the tag.
                s.append(indent_space)
            s.append('<%s%s%s%s>' % (
                    prefix, self.name, attribute_string, close))
            has_tag_child = False
            if pretty_print:
                for item in self.children:
                    if isinstance(item, Tag):
                        has_tag_child = True
                        break
                if has_tag_child:
                    s.append("\n")
            s.append(contents)
            if not has_tag_child:
                s[-1] = s[-1].strip()
            if pretty_print and contents and contents[-1] != "\n":
                s.append("")
            if pretty_print and closeTag:
                if has_tag_child:
                    s.append(space)
            s.append(closeTag)
            if indent_level is not None and closeTag and self.next_sibling:
                # Even if this particular tag is not pretty-printed,
                # we're now done with the tag, and we should add a
                # newline if appropriate.
                s.append("\n")
            s = ''.join(s)
        return s
    
    
    def decode_contents(
        self,
        indent_level=None,
        eventual_encoding="utf-8",
        formatter="minimal"
    ):
        # First off, turn a string formatter into a function. This
        # will stop the lookup from happening over and over again.
        if not callable(formatter):
            formatter = self._formatter_for_name(formatter)
    
        pretty_print = (indent_level is not None)
        s = []
        for c in self:
            text = None
            if isinstance(c, NavigableString):
                text = c.output_ready(formatter)
            elif isinstance(c, Tag):
                s.append(
                    c.decode(indent_level, eventual_encoding, formatter)
                )
            if text and indent_level and not self.name == 'pre':
                text = text.strip()
            if text:
                if pretty_print and not self.name == 'pre':
                    s.append(" " * (indent_level - 1))
                s.append(text)
                if pretty_print and not self.name == 'pre':
                    s.append("")
        return ''.join(s)
    
    
    Tag.decode = decode
    Tag.decode_contents= decode_contents
    

    在这之后,当我做print(soup.prettify)时,输出是

    <annotation>
     <folder>Definitiva</folder>
     <filename>armas_229.jpg</filename>
     <path>/tmp/tmpygedczp5/handgun/images/armas_229.jpg</path>
     <size>
      <width>1800</width>
      <height>1426</height>
      <depth>3</depth>
     </size>
     <segmented>0</segmented>
     <object>
      <name>handgun</name>
      <pose>Unspecified</pose>
      <truncated>0</truncated>
      <difficult>0</difficult>
      <bndbox>
       <xmin>1001</xmin>
       <ymin>549</ymin>
       <xmax>1453</xmax>
       <ymax>1147</ymax>
      </bndbox>
     </object>
    </annotation>
    

    我在做这个的时候做了很多假设。只是想证明这是可能的。

    【讨论】:

    • 非常好的工作,感谢@Bitto Bennichan。我可能会使用 ElementTree 重写我的代码,因为我希望不会像这样“脱离预订”,而且我认为 BeautifulSoup 并没有真正给我带来 ElementTree 不提供的东西(我使用 BS,因为它被用于我为这个任务而关注的教程)。不错的技巧,赞!
    【解决方案2】:

    考虑XSLT 与Python 的第三方模块lxml(您可能已经拥有BeautifulSoup 集成)。具体来说,调用identity transform照原样复制XML,然后在所有文本节点上运行normalize-space()模板。

    XSLT (另存为 .xsl、特殊的 .xml 文件或嵌入字符串)

    <xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
        <xsl:output indent="yes"/>
        <xsl:strip-space elements="*"/>
    
        <!-- IDENTITY TRANSFORM -->
        <xsl:template match="@*|node()">
          <xsl:copy>
            <xsl:apply-templates select="@*|node()"/>
          </xsl:copy>
        </xsl:template>
    
        <!-- RUN normalize-space() ON ALL TEXT NODES -->
        <xsl:template match="text()">
            <xsl:copy-of select="normalize-space()"/>
        </xsl:template>            
    </xsl:stylesheet>
    

    Python

    import lxml.etree as et
    
    # LOAD FROM STRING OR PARSE FROM FILE
    str_xml = '''...'''    
    str_xsl = '''...'''
    
    doc = et.fromstring(str_xml)
    style = et.fromstring(str_xsl)
    
    # INITIALIZE TRANSFORMER AND RUN 
    transformer = et.XSLT(style)
    result = transformer(doc)
    
    # PRINT TO SCREEN
    print(result)
    
    # SAVE TO DISK
    with open('Output.xml', 'wb') as f:
         f.write(result)
    

    Rextester demo

    【讨论】:

    • 谢谢@Parfait,这很有帮助。我希望有一些开箱即用的东西,而不是采用这样的方法,因为这似乎与重写我的代码以使用 ElementTree 而不是 BeautifulSoup 一样多。这假设 ElementTree 的输出将被合理简洁地格式化,不同于默认的 BeautifulSoup 输出,我想只有一种方法可以找出答案(即停止懒惰并使用 ElementTree 进行重写)。无论如何,再次感谢您的帮助。
    • 嗯...在 .xml 旁边保存一个单独的 .xsl(或作为演示显示的字符串嵌入)工作量太大?在 Python 中没有任何循环或重写树?不过明白了。我不知道您的整个过程只是为了回答标题问题。我只使用lxml 来满足我所有的 XML 需求(甚至是 HTML),它是一个完全兼容的 XPath 1.0 和 XSLT 1.0 库。祝你好运!也许这可以帮助未来的读者。
    • 事实证明,使用 ElementTree 重写我的代码很简单,并且当我使用 ElementTree.write(file_path) 时,XML 格式看起来符合预期/期望。无论如何,这对我来说似乎是最简单/最简单的......
    • 很高兴听到!很高兴它成功了。随意回答自己的问题。希望它不涉及循环。
    • 经过多一点测试,结果发现我错了,事情并没有这么简单。在我重写 XML 之前,我需要使用 strip() 使用文本值更新所有元素,以删除无关的空格和换行符。尽管如此 xml.etree.ElementTree 似乎更容易使用,所以我开始使用它而不是 BeautifulSoup 来做这类事情。
    【解决方案3】:

    如果我改用 xml.etree.ElementTree 而不是 BeautifulSoup,那么得到我想要的缩进是很直接的。例如,下面是一些读取 XML 文件的代码,清除文本元素中的任何换行符/空格,然后将树写入 XML 文件。

    import argparse
    from xml.etree import ElementTree
    
    
    # ------------------------------------------------------------------------------
    def reformat(
            input_xml: str,
            output_xml: str,
    ):
        tree = ElementTree.parse(input_xml)
    
        # remove extraneous newlines and whitespace from text elements
        for element in tree.getiterator():
            if element.text:
                element.text = element.text.strip()
    
        # write the updated XML into the annotations output directory
        tree.write(output_xml)
    
    
    # ------------------------------------------------------------------------------
    if __name__ == "__main__":
    
        # parse the command line arguments
        args_parser = argparse.ArgumentParser()
        args_parser.add_argument(
            "--in",
            required=True,
            type=str,
            help="file path of original XML",
        )
        args_parser.add_argument(
            "--out",
            required=True,
            type=str,
            help="file path of reformatted XML",
        )
        args = vars(args_parser.parse_args())
    
        reformat(
            args["in"],
            args["out"],
        )
    

    【讨论】:

      【解决方案4】:

      我写了一个代码来做美化,没有任何额外的库。

      美化逻辑

      # Recursive function (do not call this method)
      def _get_prettified(tag, curr_indent, indent):
          out =  ''
          for x in tag.find_all(recursive=False):
              if len(x.find_all()) == 0:
                  content = x.string.strip(' \n')
              else:
                  content = '\n' + _get_prettified(x, curr_indent + ' ' * indent, indent) + curr_indent
          
              attrs = ' '.join([f'{k}="{v}"' for k,v in x.attrs.items()])
              out += curr_indent + ('<%s %s>' % (x.name, attrs) if len(attrs) > 0 else '<%s>' % x.name) + content + '</%s>\n' % x.name
          
          return out 
          
      # Call this method
      def get_prettified(tag, indent):
          return _get_prettified(tag, '', indent);
      

      您的意见

      source = """<annotation>
       <folder>
        Definitiva
       </folder>
       <filename>
        armas_229.jpg
       </filename>
       <path>
        /tmp/tmpygedczp5/handgun/images/armas_229.jpg
       </path>
       <size>
        <width>
         1800
        </width>
        <height>
         1426
        </height>
        <depth>
         3
        </depth>
       </size>
       <segmented>
        0
       </segmented>
       <object>
        <name>
         handgun
        </name>
        <pose>
         Unspecified
        </pose>
        <truncated>
         0
        </truncated>
        <difficult>
         0
        </difficult>
        <bndbox>
         <xmin>
          1001
         </xmin>
         <ymin>
          549
         </ymin>
         <xmax>
          1453
         </xmax>
         <ymax>
          1147
         </ymax>
        </bndbox>
       </object>
      </annotation>"""
      

      输出

      bs = BeautifulSoup(source, 'html.parser')
      output = get_prettified(bs, indent=2)
      print(output)
      
      # Prints following
      <annotation>
        <folder>Definitiva</folder>
        <filename>armas_229.jpg</filename>
        <path>/tmp/tmpygedczp5/handgun/images/armas_229.jpg</path>
        <size>
          <width>1800</width>
          <height>1426</height>
          <depth>3</depth>
        </size>
        <segmented>0</segmented>
        <object>
          <name>handgun</name>
          <pose>Unspecified</pose>
          <truncated>0</truncated>
          <difficult>0</difficult>
          <bndbox>
            <xmin>1001</xmin>
            <ymin>549</ymin>
            <xmax>1453</xmax>
            <ymax>1147</ymax>
          </bndbox>
        </object>
      </annotation>
      

      在此处运行您的代码:https://replit.com/@bikcrum/BeautifulSoup-Prettifier

      【讨论】:

      • 我尝试了您的代码,它在 html 解析器 bs = BeautifulSoup(source, 'html.parser') 上运行良好,但将所有标签都转换为小写。当我使用bs = BeautifulSoup(source, 'xml') 时,它给出了正确的大小写标签,但在行之间产生了间隙。
      猜你喜欢
      • 2013-10-04
      • 2018-12-16
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多