diagnose.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. """Diagnostic functions, mainly for use when doing tech support."""
  2. # Use of this source code is governed by the MIT license.
  3. __license__ = "MIT"
  4. import cProfile
  5. from io import BytesIO
  6. from html.parser import HTMLParser
  7. import bs4
  8. from bs4 import BeautifulSoup, __version__
  9. from bs4.builder import builder_registry
  10. from typing import (
  11. Any,
  12. IO,
  13. List,
  14. Optional,
  15. Tuple,
  16. TYPE_CHECKING,
  17. )
  18. if TYPE_CHECKING:
  19. from bs4._typing import _IncomingMarkup
  20. import pstats
  21. import random
  22. import tempfile
  23. import time
  24. import traceback
  25. import sys
  26. def diagnose(data: "_IncomingMarkup") -> None:
  27. """Diagnostic suite for isolating common problems.
  28. :param data: Some markup that needs to be explained.
  29. :return: None; diagnostics are printed to standard output.
  30. """
  31. print(("Diagnostic running on Beautiful Soup %s" % __version__))
  32. print(("Python version %s" % sys.version))
  33. basic_parsers = ["html.parser", "html5lib", "lxml"]
  34. for name in basic_parsers:
  35. for builder in builder_registry.builders:
  36. if name in builder.features:
  37. break
  38. else:
  39. basic_parsers.remove(name)
  40. print(
  41. ("I noticed that %s is not installed. Installing it may help." % name)
  42. )
  43. if "lxml" in basic_parsers:
  44. basic_parsers.append("lxml-xml")
  45. try:
  46. from lxml import etree # type:ignore
  47. print(("Found lxml version %s" % ".".join(map(str, etree.LXML_VERSION))))
  48. except ImportError:
  49. print("lxml is not installed or couldn't be imported.")
  50. if "html5lib" in basic_parsers:
  51. try:
  52. import html5lib
  53. print(("Found html5lib version %s" % html5lib.__version__))
  54. except ImportError:
  55. print("html5lib is not installed or couldn't be imported.")
  56. if hasattr(data, "read"):
  57. data = data.read()
  58. for parser in basic_parsers:
  59. print(("Trying to parse your markup with %s" % parser))
  60. success = False
  61. try:
  62. soup = BeautifulSoup(data, features=parser)
  63. success = True
  64. except Exception:
  65. print(("%s could not parse the markup." % parser))
  66. traceback.print_exc()
  67. if success:
  68. print(("Here's what %s did with the markup:" % parser))
  69. print((soup.prettify()))
  70. print(("-" * 80))
  71. def lxml_trace(data: "_IncomingMarkup", html: bool = True, **kwargs: Any) -> None:
  72. """Print out the lxml events that occur during parsing.
  73. This lets you see how lxml parses a document when no Beautiful
  74. Soup code is running. You can use this to determine whether
  75. an lxml-specific problem is in Beautiful Soup's lxml tree builders
  76. or in lxml itself.
  77. :param data: Some markup.
  78. :param html: If True, markup will be parsed with lxml's HTML parser.
  79. if False, lxml's XML parser will be used.
  80. """
  81. from lxml import etree
  82. recover = kwargs.pop("recover", True)
  83. if isinstance(data, str):
  84. data = data.encode("utf8")
  85. if not isinstance(data, IO):
  86. reader = BytesIO(data)
  87. for event, element in etree.iterparse(reader, html=html, recover=recover, **kwargs):
  88. print(("%s, %4s, %s" % (event, element.tag, element.text)))
  89. class AnnouncingParser(HTMLParser):
  90. """Subclass of HTMLParser that announces parse events, without doing
  91. anything else.
  92. You can use this to get a picture of how html.parser sees a given
  93. document. The easiest way to do this is to call `htmlparser_trace`.
  94. """
  95. def _p(self, s: str) -> None:
  96. print(s)
  97. def handle_starttag(
  98. self,
  99. name: str,
  100. attrs: List[Tuple[str, Optional[str]]],
  101. handle_empty_element: bool = True,
  102. ) -> None:
  103. self._p(f"{name} {attrs} START")
  104. def handle_endtag(self, name: str, check_already_closed: bool = True) -> None:
  105. self._p("%s END" % name)
  106. def handle_data(self, data: str) -> None:
  107. self._p("%s DATA" % data)
  108. def handle_charref(self, name: str) -> None:
  109. self._p("%s CHARREF" % name)
  110. def handle_entityref(self, name: str) -> None:
  111. self._p("%s ENTITYREF" % name)
  112. def handle_comment(self, data: str) -> None:
  113. self._p("%s COMMENT" % data)
  114. def handle_decl(self, data: str) -> None:
  115. self._p("%s DECL" % data)
  116. def unknown_decl(self, data: str) -> None:
  117. self._p("%s UNKNOWN-DECL" % data)
  118. def handle_pi(self, data: str) -> None:
  119. self._p("%s PI" % data)
  120. def htmlparser_trace(data: str) -> None:
  121. """Print out the HTMLParser events that occur during parsing.
  122. This lets you see how HTMLParser parses a document when no
  123. Beautiful Soup code is running.
  124. :param data: Some markup.
  125. """
  126. parser = AnnouncingParser()
  127. parser.feed(data)
  128. _vowels: str = "aeiou"
  129. _consonants: str = "bcdfghjklmnpqrstvwxyz"
  130. def rword(length: int = 5) -> str:
  131. """Generate a random word-like string.
  132. :meta private:
  133. """
  134. s = ""
  135. for i in range(length):
  136. if i % 2 == 0:
  137. t = _consonants
  138. else:
  139. t = _vowels
  140. s += random.choice(t)
  141. return s
  142. def rsentence(length: int = 4) -> str:
  143. """Generate a random sentence-like string.
  144. :meta private:
  145. """
  146. return " ".join(rword(random.randint(4, 9)) for i in range(length))
  147. def rdoc(num_elements: int = 1000) -> str:
  148. """Randomly generate an invalid HTML document.
  149. :meta private:
  150. """
  151. tag_names = ["p", "div", "span", "i", "b", "script", "table"]
  152. elements = []
  153. for i in range(num_elements):
  154. choice = random.randint(0, 3)
  155. if choice == 0:
  156. # New tag.
  157. tag_name = random.choice(tag_names)
  158. elements.append("<%s>" % tag_name)
  159. elif choice == 1:
  160. elements.append(rsentence(random.randint(1, 4)))
  161. elif choice == 2:
  162. # Close a tag.
  163. tag_name = random.choice(tag_names)
  164. elements.append("</%s>" % tag_name)
  165. return "<html>" + "\n".join(elements) + "</html>"
  166. def benchmark_parsers(num_elements: int = 100000) -> None:
  167. """Very basic head-to-head performance benchmark."""
  168. print(("Comparative parser benchmark on Beautiful Soup %s" % __version__))
  169. data = rdoc(num_elements)
  170. print(("Generated a large invalid HTML document (%d bytes)." % len(data)))
  171. for parser_name in ["lxml", ["lxml", "html"], "html5lib", "html.parser"]:
  172. success = False
  173. try:
  174. a = time.time()
  175. BeautifulSoup(data, parser_name)
  176. b = time.time()
  177. success = True
  178. except Exception:
  179. print(("%s could not parse the markup." % parser_name))
  180. traceback.print_exc()
  181. if success:
  182. print(("BS4+%s parsed the markup in %.2fs." % (parser_name, b - a)))
  183. from lxml import etree
  184. a = time.time()
  185. etree.HTML(data)
  186. b = time.time()
  187. print(("Raw lxml parsed the markup in %.2fs." % (b - a)))
  188. import html5lib
  189. parser = html5lib.HTMLParser()
  190. a = time.time()
  191. parser.parse(data)
  192. b = time.time()
  193. print(("Raw html5lib parsed the markup in %.2fs." % (b - a)))
  194. def profile(num_elements: int = 100000, parser: str = "lxml") -> None:
  195. """Use Python's profiler on a randomly generated document."""
  196. filehandle = tempfile.NamedTemporaryFile()
  197. filename = filehandle.name
  198. data = rdoc(num_elements)
  199. vars = dict(bs4=bs4, data=data, parser=parser)
  200. cProfile.runctx("bs4.BeautifulSoup(data, parser)", vars, vars, filename)
  201. stats = pstats.Stats(filename)
  202. # stats.strip_dirs()
  203. stats.sort_stats("cumulative")
  204. stats.print_stats("_html5lib|bs4", 50)
  205. # If this file is run as a script, standard input is diagnosed.
  206. if __name__ == "__main__":
  207. diagnose(sys.stdin.read())