_lxml.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. # Use of this source code is governed by the MIT license.
  2. __license__ = "MIT"
  3. __all__ = [
  4. 'LXMLTreeBuilderForXML',
  5. 'LXMLTreeBuilder',
  6. ]
  7. try:
  8. from collections.abc import Callable # Python 3.6
  9. except ImportError as e:
  10. from collections import Callable
  11. from io import BytesIO
  12. from io import StringIO
  13. from lxml import etree
  14. from bs4.element import (
  15. Comment,
  16. Doctype,
  17. NamespacedAttribute,
  18. ProcessingInstruction,
  19. XMLProcessingInstruction,
  20. )
  21. from bs4.builder import (
  22. DetectsXMLParsedAsHTML,
  23. FAST,
  24. HTML,
  25. HTMLTreeBuilder,
  26. PERMISSIVE,
  27. ParserRejectedMarkup,
  28. TreeBuilder,
  29. XML)
  30. from bs4.dammit import EncodingDetector
  31. LXML = 'lxml'
  32. def _invert(d):
  33. "Invert a dictionary."
  34. return dict((v,k) for k, v in list(d.items()))
  35. class LXMLTreeBuilderForXML(TreeBuilder):
  36. DEFAULT_PARSER_CLASS = etree.XMLParser
  37. is_xml = True
  38. processing_instruction_class = XMLProcessingInstruction
  39. NAME = "lxml-xml"
  40. ALTERNATE_NAMES = ["xml"]
  41. # Well, it's permissive by XML parser standards.
  42. features = [NAME, LXML, XML, FAST, PERMISSIVE]
  43. CHUNK_SIZE = 512
  44. # This namespace mapping is specified in the XML Namespace
  45. # standard.
  46. DEFAULT_NSMAPS = dict(xml='http://www.w3.org/XML/1998/namespace')
  47. DEFAULT_NSMAPS_INVERTED = _invert(DEFAULT_NSMAPS)
  48. # NOTE: If we parsed Element objects and looked at .sourceline,
  49. # we'd be able to see the line numbers from the original document.
  50. # But instead we build an XMLParser or HTMLParser object to serve
  51. # as the target of parse messages, and those messages don't include
  52. # line numbers.
  53. # See: https://bugs.launchpad.net/lxml/+bug/1846906
  54. def initialize_soup(self, soup):
  55. """Let the BeautifulSoup object know about the standard namespace
  56. mapping.
  57. :param soup: A `BeautifulSoup`.
  58. """
  59. super(LXMLTreeBuilderForXML, self).initialize_soup(soup)
  60. self._register_namespaces(self.DEFAULT_NSMAPS)
  61. def _register_namespaces(self, mapping):
  62. """Let the BeautifulSoup object know about namespaces encountered
  63. while parsing the document.
  64. This might be useful later on when creating CSS selectors.
  65. This will track (almost) all namespaces, even ones that were
  66. only in scope for part of the document. If two namespaces have
  67. the same prefix, only the first one encountered will be
  68. tracked. Un-prefixed namespaces are not tracked.
  69. :param mapping: A dictionary mapping namespace prefixes to URIs.
  70. """
  71. for key, value in list(mapping.items()):
  72. # This is 'if key' and not 'if key is not None' because we
  73. # don't track un-prefixed namespaces. Soupselect will
  74. # treat an un-prefixed namespace as the default, which
  75. # causes confusion in some cases.
  76. if key and key not in self.soup._namespaces:
  77. # Let the BeautifulSoup object know about a new namespace.
  78. # If there are multiple namespaces defined with the same
  79. # prefix, the first one in the document takes precedence.
  80. self.soup._namespaces[key] = value
  81. def default_parser(self, encoding):
  82. """Find the default parser for the given encoding.
  83. :param encoding: A string.
  84. :return: Either a parser object or a class, which
  85. will be instantiated with default arguments.
  86. """
  87. if self._default_parser is not None:
  88. return self._default_parser
  89. return etree.XMLParser(
  90. target=self, strip_cdata=False, recover=True, encoding=encoding)
  91. def parser_for(self, encoding):
  92. """Instantiate an appropriate parser for the given encoding.
  93. :param encoding: A string.
  94. :return: A parser object such as an `etree.XMLParser`.
  95. """
  96. # Use the default parser.
  97. parser = self.default_parser(encoding)
  98. if isinstance(parser, Callable):
  99. # Instantiate the parser with default arguments
  100. parser = parser(
  101. target=self, strip_cdata=False, recover=True, encoding=encoding
  102. )
  103. return parser
  104. def __init__(self, parser=None, empty_element_tags=None, **kwargs):
  105. # TODO: Issue a warning if parser is present but not a
  106. # callable, since that means there's no way to create new
  107. # parsers for different encodings.
  108. self._default_parser = parser
  109. if empty_element_tags is not None:
  110. self.empty_element_tags = set(empty_element_tags)
  111. self.soup = None
  112. self.nsmaps = [self.DEFAULT_NSMAPS_INVERTED]
  113. self.active_namespace_prefixes = [dict(self.DEFAULT_NSMAPS)]
  114. super(LXMLTreeBuilderForXML, self).__init__(**kwargs)
  115. def _getNsTag(self, tag):
  116. # Split the namespace URL out of a fully-qualified lxml tag
  117. # name. Copied from lxml's src/lxml/sax.py.
  118. if tag[0] == '{':
  119. return tuple(tag[1:].split('}', 1))
  120. else:
  121. return (None, tag)
  122. def prepare_markup(self, markup, user_specified_encoding=None,
  123. exclude_encodings=None,
  124. document_declared_encoding=None):
  125. """Run any preliminary steps necessary to make incoming markup
  126. acceptable to the parser.
  127. lxml really wants to get a bytestring and convert it to
  128. Unicode itself. So instead of using UnicodeDammit to convert
  129. the bytestring to Unicode using different encodings, this
  130. implementation uses EncodingDetector to iterate over the
  131. encodings, and tell lxml to try to parse the document as each
  132. one in turn.
  133. :param markup: Some markup -- hopefully a bytestring.
  134. :param user_specified_encoding: The user asked to try this encoding.
  135. :param document_declared_encoding: The markup itself claims to be
  136. in this encoding.
  137. :param exclude_encodings: The user asked _not_ to try any of
  138. these encodings.
  139. :yield: A series of 4-tuples:
  140. (markup, encoding, declared encoding,
  141. has undergone character replacement)
  142. Each 4-tuple represents a strategy for converting the
  143. document to Unicode and parsing it. Each strategy will be tried
  144. in turn.
  145. """
  146. is_html = not self.is_xml
  147. if is_html:
  148. self.processing_instruction_class = ProcessingInstruction
  149. # We're in HTML mode, so if we're given XML, that's worth
  150. # noting.
  151. DetectsXMLParsedAsHTML.warn_if_markup_looks_like_xml(markup)
  152. else:
  153. self.processing_instruction_class = XMLProcessingInstruction
  154. if isinstance(markup, str):
  155. # We were given Unicode. Maybe lxml can parse Unicode on
  156. # this system?
  157. # TODO: This is a workaround for
  158. # https://bugs.launchpad.net/lxml/+bug/1948551.
  159. # We can remove it once the upstream issue is fixed.
  160. if len(markup) > 0 and markup[0] == u'\N{BYTE ORDER MARK}':
  161. markup = markup[1:]
  162. yield markup, None, document_declared_encoding, False
  163. if isinstance(markup, str):
  164. # No, apparently not. Convert the Unicode to UTF-8 and
  165. # tell lxml to parse it as UTF-8.
  166. yield (markup.encode("utf8"), "utf8",
  167. document_declared_encoding, False)
  168. # This was provided by the end-user; treat it as a known
  169. # definite encoding per the algorithm laid out in the HTML5
  170. # spec. (See the EncodingDetector class for details.)
  171. known_definite_encodings = [user_specified_encoding]
  172. # This was found in the document; treat it as a slightly lower-priority
  173. # user encoding.
  174. user_encodings = [document_declared_encoding]
  175. detector = EncodingDetector(
  176. markup, known_definite_encodings=known_definite_encodings,
  177. user_encodings=user_encodings, is_html=is_html,
  178. exclude_encodings=exclude_encodings
  179. )
  180. for encoding in detector.encodings:
  181. yield (detector.markup, encoding, document_declared_encoding, False)
  182. def feed(self, markup):
  183. if isinstance(markup, bytes):
  184. markup = BytesIO(markup)
  185. elif isinstance(markup, str):
  186. markup = StringIO(markup)
  187. # Call feed() at least once, even if the markup is empty,
  188. # or the parser won't be initialized.
  189. data = markup.read(self.CHUNK_SIZE)
  190. try:
  191. self.parser = self.parser_for(self.soup.original_encoding)
  192. self.parser.feed(data)
  193. while len(data) != 0:
  194. # Now call feed() on the rest of the data, chunk by chunk.
  195. data = markup.read(self.CHUNK_SIZE)
  196. if len(data) != 0:
  197. self.parser.feed(data)
  198. self.parser.close()
  199. except (UnicodeDecodeError, LookupError, etree.ParserError) as e:
  200. raise ParserRejectedMarkup(e)
  201. def close(self):
  202. self.nsmaps = [self.DEFAULT_NSMAPS_INVERTED]
  203. def start(self, name, attrs, nsmap={}):
  204. # Make sure attrs is a mutable dict--lxml may send an immutable dictproxy.
  205. attrs = dict(attrs)
  206. nsprefix = None
  207. # Invert each namespace map as it comes in.
  208. if len(nsmap) == 0 and len(self.nsmaps) > 1:
  209. # There are no new namespaces for this tag, but
  210. # non-default namespaces are in play, so we need a
  211. # separate tag stack to know when they end.
  212. self.nsmaps.append(None)
  213. elif len(nsmap) > 0:
  214. # A new namespace mapping has come into play.
  215. # First, Let the BeautifulSoup object know about it.
  216. self._register_namespaces(nsmap)
  217. # Then, add it to our running list of inverted namespace
  218. # mappings.
  219. self.nsmaps.append(_invert(nsmap))
  220. # The currently active namespace prefixes have
  221. # changed. Calculate the new mapping so it can be stored
  222. # with all Tag objects created while these prefixes are in
  223. # scope.
  224. current_mapping = dict(self.active_namespace_prefixes[-1])
  225. current_mapping.update(nsmap)
  226. # We should not track un-prefixed namespaces as we can only hold one
  227. # and it will be recognized as the default namespace by soupsieve,
  228. # which may be confusing in some situations.
  229. if '' in current_mapping:
  230. del current_mapping['']
  231. self.active_namespace_prefixes.append(current_mapping)
  232. # Also treat the namespace mapping as a set of attributes on the
  233. # tag, so we can recreate it later.
  234. attrs = attrs.copy()
  235. for prefix, namespace in list(nsmap.items()):
  236. attribute = NamespacedAttribute(
  237. "xmlns", prefix, "http://www.w3.org/2000/xmlns/")
  238. attrs[attribute] = namespace
  239. # Namespaces are in play. Find any attributes that came in
  240. # from lxml with namespaces attached to their names, and
  241. # turn then into NamespacedAttribute objects.
  242. new_attrs = {}
  243. for attr, value in list(attrs.items()):
  244. namespace, attr = self._getNsTag(attr)
  245. if namespace is None:
  246. new_attrs[attr] = value
  247. else:
  248. nsprefix = self._prefix_for_namespace(namespace)
  249. attr = NamespacedAttribute(nsprefix, attr, namespace)
  250. new_attrs[attr] = value
  251. attrs = new_attrs
  252. namespace, name = self._getNsTag(name)
  253. nsprefix = self._prefix_for_namespace(namespace)
  254. self.soup.handle_starttag(
  255. name, namespace, nsprefix, attrs,
  256. namespaces=self.active_namespace_prefixes[-1]
  257. )
  258. def _prefix_for_namespace(self, namespace):
  259. """Find the currently active prefix for the given namespace."""
  260. if namespace is None:
  261. return None
  262. for inverted_nsmap in reversed(self.nsmaps):
  263. if inverted_nsmap is not None and namespace in inverted_nsmap:
  264. return inverted_nsmap[namespace]
  265. return None
  266. def end(self, name):
  267. self.soup.endData()
  268. completed_tag = self.soup.tagStack[-1]
  269. namespace, name = self._getNsTag(name)
  270. nsprefix = None
  271. if namespace is not None:
  272. for inverted_nsmap in reversed(self.nsmaps):
  273. if inverted_nsmap is not None and namespace in inverted_nsmap:
  274. nsprefix = inverted_nsmap[namespace]
  275. break
  276. self.soup.handle_endtag(name, nsprefix)
  277. if len(self.nsmaps) > 1:
  278. # This tag, or one of its parents, introduced a namespace
  279. # mapping, so pop it off the stack.
  280. out_of_scope_nsmap = self.nsmaps.pop()
  281. if out_of_scope_nsmap is not None:
  282. # This tag introduced a namespace mapping which is no
  283. # longer in scope. Recalculate the currently active
  284. # namespace prefixes.
  285. self.active_namespace_prefixes.pop()
  286. def pi(self, target, data):
  287. self.soup.endData()
  288. data = target + ' ' + data
  289. self.soup.handle_data(data)
  290. self.soup.endData(self.processing_instruction_class)
  291. def data(self, content):
  292. self.soup.handle_data(content)
  293. def doctype(self, name, pubid, system):
  294. self.soup.endData()
  295. doctype = Doctype.for_name_and_ids(name, pubid, system)
  296. self.soup.object_was_parsed(doctype)
  297. def comment(self, content):
  298. "Handle comments as Comment objects."
  299. self.soup.endData()
  300. self.soup.handle_data(content)
  301. self.soup.endData(Comment)
  302. def test_fragment_to_document(self, fragment):
  303. """See `TreeBuilder`."""
  304. return '<?xml version="1.0" encoding="utf-8"?>\n%s' % fragment
  305. class LXMLTreeBuilder(HTMLTreeBuilder, LXMLTreeBuilderForXML):
  306. NAME = LXML
  307. ALTERNATE_NAMES = ["lxml-html"]
  308. features = ALTERNATE_NAMES + [NAME, HTML, FAST, PERMISSIVE]
  309. is_xml = False
  310. processing_instruction_class = ProcessingInstruction
  311. def default_parser(self, encoding):
  312. return etree.HTMLParser
  313. def feed(self, markup):
  314. encoding = self.soup.original_encoding
  315. try:
  316. self.parser = self.parser_for(encoding)
  317. self.parser.feed(markup)
  318. self.parser.close()
  319. except (UnicodeDecodeError, LookupError, etree.ParserError) as e:
  320. raise ParserRejectedMarkup(e)
  321. def test_fragment_to_document(self, fragment):
  322. """See `TreeBuilder`."""
  323. return '<html><body>%s</body></html>' % fragment