Package lxml :: Module doctestcompare |
[hide private]
[frames] | no frames]

# Source Code for Module lxml.doctestcompare

      1  """
      2  lxml-based doctest output comparison.
      3
      4  Note: normally, you should just import the `lxml.usedoctest` and
      5  `lxml.html.usedoctest` modules from within a doctest, instead of this
      6  one::
      7
      8      >>> import lxml.usedoctest # for XML output
      9
     10      >>> import lxml.html.usedoctest # for HTML output
     11
     12  To use this module directly, you must call ``lxmldoctest.install()``,
     13  which will cause doctest to use this in all subsequent calls.
     14
     15  This changes the way output is checked and comparisons are made for
     16  XML or HTML-like content.
     17
     18  XML or HTML content is noticed because the example starts with ``<``
     19  (it's HTML if it starts with ``<html``).  You can also use the
     20  ``PARSE_HTML`` and ``PARSE_XML`` flags to force parsing.
     21
     22  Some rough wildcard-like things are allowed.  Whitespace is generally
     23  ignored (except in attributes).  In text (attributes and text in the
     24  body) you can use ``...`` as a wildcard.  In an example it also
     25  matches any trailing tags in the element, though it does not match
     26  leading tags.  You may create a tag ``<any>`` or include an ``any``
     27  attribute in the tag.  An ``any`` tag matches any tag, while the
     28  attribute matches any and all attributes.
     29
     30  When a match fails, the reformatted example and gotten text is
     31  displayed (indented), and a rough diff-like output is given.  Anything
     32  marked with ``+`` is in the output but wasn't supposed to be, and
     33  similarly ``-`` means its in the example but wasn't in the output.
     34
     35  You can disable parsing on one line with ``# doctest:+NOPARSE_MARKUP``
     36  """
     37
     38  from lxml import etree
     39  import sys
     40  import re
     41  import doctest
     42  try:
     43      from html import escape as html_escape
     44  except ImportError:
     45      from cgi import escape as html_escape
     46
     47  __all__ = ['PARSE_HTML', 'PARSE_XML', 'NOPARSE_MARKUP', 'LXMLOutputChecker',
     48             'LHTMLOutputChecker', 'install', 'temp_install']
     49
     50  try:
     51      _basestring = basestring
     52  except NameError:
     53      _basestring = (str, bytes)
     54
     55  _IS_PYTHON_3 = sys.version_info[0] >= 3
     56
     57  PARSE_HTML = doctest.register_optionflag('PARSE_HTML')
     58  PARSE_XML = doctest.register_optionflag('PARSE_XML')
     59  NOPARSE_MARKUP = doctest.register_optionflag('NOPARSE_MARKUP')
     60
     61  OutputChecker = doctest.OutputChecker
     62
     63 -def strip(v):
     64      if v is None:
     65          return None
     66      else:
     67          return v.strip()
     68
     69 -def norm_whitespace(v):
     70      return _norm_whitespace_re.sub(' ', v)
     71
     72  _html_parser = etree.HTMLParser(recover=False, remove_blank_text=True)
     73
     74 -def html_fromstring(html):
     75      return etree.fromstring(html, _html_parser)
     76
     77  # We use this to distinguish repr()s from elements:
     78  _repr_re = re.compile(r'^<[^>]+ (at|object) ')
     79  _norm_whitespace_re = re.compile(r'[ \t\n][ \t\n]+')
     80
     81 -class LXMLOutputChecker(OutputChecker):
     82
     83      empty_tags = (
     84          'param', 'img', 'area', 'br', 'basefont', 'input',
     85          'base', 'meta', 'link', 'col')
     86
     87 -    def get_default_parser(self):
     88          return etree.XML
     89
     90 -    def check_output(self, want, got, optionflags):
     91          alt_self = getattr(self, '_temp_override_self', None)
     92          if alt_self is not None:
     93              super_method = self._temp_call_super_check_output
     94              self = alt_self
     95          else:
     96              super_method = OutputChecker.check_output
     97          parser = self.get_parser(want, got, optionflags)
     98          if not parser:
     99              return super_method(
    100                  self, want, got, optionflags)
    101          try:
    102              want_doc = parser(want)
    103          except etree.XMLSyntaxError:
    104              return False
    105          try:
    106              got_doc = parser(got)
    107          except etree.XMLSyntaxError:
    108              return False
    109          return self.compare_docs(want_doc, got_doc)
    110
    111 -    def get_parser(self, want, got, optionflags):
    112          parser = None
    113          if NOPARSE_MARKUP & optionflags:
    114              return None
    115          if PARSE_HTML & optionflags:
    116              parser = html_fromstring
    117          elif PARSE_XML & optionflags:
    118              parser = etree.XML
    119          elif (want.strip().lower().startswith('<html')
    120                and got.strip().startswith('<html')):
    121              parser = html_fromstring
    122          elif (self._looks_like_markup(want)
    123                and self._looks_like_markup(got)):
    124              parser = self.get_default_parser()
    125          return parser
    126
    127 -    def _looks_like_markup(self, s):
    128          s = s.strip()
    129          return (s.startswith('<')
    130                  and not _repr_re.search(s))
    131
    132 -    def compare_docs(self, want, got):
    133          if not self.tag_compare(want.tag, got.tag):
    134              return False
    135          if not self.text_compare(want.text, got.text, True):
    136              return False
    137          if not self.text_compare(want.tail, got.tail, True):
    138              return False
    139          if 'any' not in want.attrib:
    140              want_keys = sorted(want.attrib.keys())
    141              got_keys = sorted(got.attrib.keys())
    142              if want_keys != got_keys:
    143                  return False
    144              for key in want_keys:
    145                  if not self.text_compare(want.attrib[key], got.attrib[key], False):
    146                      return False
    147          if want.text != '...' or len(want):
    148              want_children = list(want)
    149              got_children = list(got)
    150              while want_children or got_children:
    151                  if not want_children or not got_children:
    152                      return False
    153                  want_first = want_children.pop(0)
    154                  got_first = got_children.pop(0)
    155                  if not self.compare_docs(want_first, got_first):
    156                      return False
    157                  if not got_children and want_first.tail == '...':
    158                      break
    159          return True
    160
    161 -    def text_compare(self, want, got, strip):
    162          want = want or ''
    163          got = got or ''
    164          if strip:
    165              want = norm_whitespace(want).strip()
    166              got = norm_whitespace(got).strip()
    167          want = '^%s$' % re.escape(want)
    168          want = want.replace(r'\.\.\.', '.*')
    169          if re.search(want, got):
    170              return True
    171          else:
    172              return False
    173
    174 -    def tag_compare(self, want, got):
    175          if want == 'any':
    176              return True
    177          if (not isinstance(want, _basestring)
    178              or not isinstance(got, _basestring)):
    179              return want == got
    180          want = want or ''
    181          got = got or ''
    182          if want.startswith('{...}'):
    183              # Ellipsis on the namespace
    184              return want.split('}')[-1] == got.split('}')[-1]
    185          else:
    186              return want == got
    187
    188 -    def output_difference(self, example, got, optionflags):
    189          want = example.want
    190          parser = self.get_parser(want, got, optionflags)
    191          errors = []
    192          if parser is not None:
    193              try:
    194                  want_doc = parser(want)
    195              except etree.XMLSyntaxError:
    196                  e = sys.exc_info()[1]
    197                  errors.append('In example: %s' % e)
    198              try:
    199                  got_doc = parser(got)
    200              except etree.XMLSyntaxError:
    201                  e = sys.exc_info()[1]
    202                  errors.append('In actual output: %s' % e)
    203          if parser is None or errors:
    204              value = OutputChecker.output_difference(
    205                  self, example, got, optionflags)
    206              if errors:
    207                  errors.append(value)
    208                  return '\n'.join(errors)
    209              else:
    210                  return value
    211          html = parser is html_fromstring
    212          diff_parts = ['Expected:',
    213                        self.format_doc(want_doc, html, 2),
    214                        'Got:',
    215                        self.format_doc(got_doc, html, 2),
    216                        'Diff:',
    217                        self.collect_diff(want_doc, got_doc, html, 2)]
    218          return '\n'.join(diff_parts)
    219
    220 -    def html_empty_tag(self, el, html=True):
    221          if not html:
    222              return False
    223          if el.tag not in self.empty_tags:
    224              return False
    225          if el.text or len(el):
    226              # This shouldn't happen (contents in an empty tag)
    227              return False
    228          return True
    229
    230 -    def format_doc(self, doc, html, indent, prefix=''):
    231          parts = []
    232          if not len(doc):
    233              # No children...
    234              parts.append(' '*indent)
    235              parts.append(prefix)
    236              parts.append(self.format_tag(doc))
    237              if not self.html_empty_tag(doc, html):
    238                  if strip(doc.text):
    239                      parts.append(self.format_text(doc.text))
    240                  parts.append(self.format_end_tag(doc))
    241              if strip(doc.tail):
    242                  parts.append(self.format_text(doc.tail))
    243              parts.append('\n')
    244              return ''.join(parts)
    245          parts.append(' '*indent)
    246          parts.append(prefix)
    247          parts.append(self.format_tag(doc))
    248          if not self.html_empty_tag(doc, html):
    249              parts.append('\n')
    250              if strip(doc.text):
    251                  parts.append(' '*indent)
    252                  parts.append(self.format_text(doc.text))
    253                  parts.append('\n')
    254              for el in doc:
    255                  parts.append(self.format_doc(el, html, indent+2))
    256              parts.append(' '*indent)
    257              parts.append(self.format_end_tag(doc))
    258              parts.append('\n')
    259          if strip(doc.tail):
    260              parts.append(' '*indent)
    261              parts.append(self.format_text(doc.tail))
    262              parts.append('\n')
    263          return ''.join(parts)
    264
    265 -    def format_text(self, text, strip=True):
    266          if text is None:
    267              return ''
    268          if strip:
    269              text = text.strip()
    270          return html_escape(text, 1)
    271
    272 -    def format_tag(self, el):
    273          attrs = []
    274          if isinstance(el, etree.CommentBase):
    275              # FIXME: probably PIs should be handled specially too?
    276              return '<!--'
    277          for name, value in sorted(el.attrib.items()):
    278              attrs.append('%s="%s"' % (name, self.format_text(value, False)))
    279          if not attrs:
    280              return '<%s>' % el.tag
    281          return '<%s %s>' % (el.tag, ' '.join(attrs))
    282
    283 -    def format_end_tag(self, el):
    284          if isinstance(el, etree.CommentBase):
    285              # FIXME: probably PIs should be handled specially too?
    286              return '-->'
    287          return '</%s>' % el.tag
    288
    289 -    def collect_diff(self, want, got, html, indent):
    290          parts = []
    291          if not len(want) and not len(got):
    292              parts.append(' '*indent)
    293              parts.append(self.collect_diff_tag(want, got))
    294              if not self.html_empty_tag(got, html):
    295                  parts.append(self.collect_diff_text(want.text, got.text))
    296                  parts.append(self.collect_diff_end_tag(want, got))
    297              parts.append(self.collect_diff_text(want.tail, got.tail))
    298              parts.append('\n')
    299              return ''.join(parts)
    300          parts.append(' '*indent)
    301          parts.append(self.collect_diff_tag(want, got))
    302          parts.append('\n')
    303          if strip(want.text) or strip(got.text):
    304              parts.append(' '*indent)
    305              parts.append(self.collect_diff_text(want.text, got.text))
    306              parts.append('\n')
    307          want_children = list(want)
    308          got_children = list(got)
    309          while want_children or got_children:
    310              if not want_children:
    311                  parts.append(self.format_doc(got_children.pop(0), html, indent+2, '+'))
    312                  continue
    313              if not got_children:
    314                  parts.append(self.format_doc(want_children.pop(0), html, indent+2, '-'))
    315                  continue
    316              parts.append(self.collect_diff(
    317                  want_children.pop(0), got_children.pop(0), html, indent+2))
    318          parts.append(' '*indent)
    319          parts.append(self.collect_diff_end_tag(want, got))
    320          parts.append('\n')
    321          if strip(want.tail) or strip(got.tail):
    322              parts.append(' '*indent)
    323              parts.append(self.collect_diff_text(want.tail, got.tail))
    324              parts.append('\n')
    325          return ''.join(parts)
    326
    327 -    def collect_diff_tag(self, want, got):
    328          if not self.tag_compare(want.tag, got.tag):
    329              tag = '%s (got: %s)' % (want.tag, got.tag)
    330          else:
    331              tag = got.tag
    332          attrs = []
    333          any = want.tag == 'any' or 'any' in want.attrib
    334          for name, value in sorted(got.attrib.items()):
    335              if name not in want.attrib and not any:
    336                  attrs.append('+%s="%s"' % (name, self.format_text(value, False)))
    337              else:
    338                  if name in want.attrib:
    339                      text = self.collect_diff_text(want.attrib[name], value, False)
    340                  else:
    341                      text = self.format_text(value, False)
    342                  attrs.append('%s="%s"' % (name, text))
    343          if not any:
    344              for name, value in sorted(want.attrib.items()):
    345                  if name in got.attrib:
    346                      continue
    347                  attrs.append('-%s="%s"' % (name, self.format_text(value, False)))
    348          if attrs:
    349              tag = '<%s %s>' % (tag, ' '.join(attrs))
    350          else:
    351              tag = '<%s>' % tag
    352          return tag
    353
    354 -    def collect_diff_end_tag(self, want, got):
    355          if want.tag != got.tag:
    356              tag = '%s (got: %s)' % (want.tag, got.tag)
    357          else:
    358              tag = got.tag
    359          return '</%s>' % tag
    360
    361 -    def collect_diff_text(self, want, got, strip=True):
    362          if self.text_compare(want, got, strip):
    363              if not got:
    364                  return ''
    365              return self.format_text(got, strip)
    366          text = '%s (got: %s)' % (want, got)
    367          return self.format_text(text, strip)
    368
    369 -class LHTMLOutputChecker(LXMLOutputChecker):
    370 -    def get_default_parser(self):
    371          return html_fromstring
    372
    373 -def install(html=False):
    374      """
    375      Install doctestcompare for all future doctests.
    376
    377      If html is true, then by default the HTML parser will be used;
    378      otherwise the XML parser is used.
    379      """
    380      if html:
    381          doctest.OutputChecker = LHTMLOutputChecker
    382      else:
    383          doctest.OutputChecker = LXMLOutputChecker
    384
    385 -def temp_install(html=False, del_module=None):
    386      """
    387      Use this *inside* a doctest to enable this checker for this
    388      doctest only.
    389
    390      If html is true, then by default the HTML parser will be used;
    391      otherwise the XML parser is used.
    392      """
    393      if html:
    394          Checker = LHTMLOutputChecker
    395      else:
    396          Checker = LXMLOutputChecker
    397      frame = _find_doctest_frame()
    398      dt_self = frame.f_locals['self']
    399      checker = Checker()
    400      old_checker = dt_self._checker
    401      dt_self._checker = checker
    402      # The unfortunate thing is that there is a local variable 'check'
    403      # in the function that runs the doctests, that is a bound method
    404      # into the output checker.  We have to update that.  We can't
    405      # modify the frame, so we have to modify the object in place.  The
    406      # only way to do this is to actually change the func_code
    407      # attribute of the method.  We change it, and then wait for
    408      # __record_outcome to be run, which signals the end of the __run
    409      # method, at which point we restore the previous check_output
    410      # implementation.
    411      if _IS_PYTHON_3:
    412          check_func = frame.f_locals['check'].__func__
    413          checker_check_func = checker.check_output.__func__
    414      else:
    415          check_func = frame.f_locals['check'].im_func
    416          checker_check_func = checker.check_output.im_func
    417      # Because we can't patch up func_globals, this is the only global
    418      # in check_output that we care about:
    419      doctest.etree = etree
    420      _RestoreChecker(dt_self, old_checker, checker,
    421                      check_func, checker_check_func,
    422                      del_module)
    423
    424 -class _RestoreChecker(object):
    425 -    def __init__(self, dt_self, old_checker, new_checker, check_func, clone_func,
    426                   del_module):
    427          self.dt_self = dt_self
    428          self.checker = old_checker
    429          self.checker._temp_call_super_check_output = self.call_super
    430          self.checker._temp_override_self = new_checker
    431          self.check_func = check_func
    432          self.clone_func = clone_func
    433          self.del_module = del_module
    434          self.install_clone()
    435          self.install_dt_self()
    436 -    def install_clone(self):
    437          if _IS_PYTHON_3:
    438              self.func_code = self.check_func.__code__
    439              self.func_globals = self.check_func.__globals__
    440              self.check_func.__code__ = self.clone_func.__code__
    441          else:
    442              self.func_code = self.check_func.func_code
    443              self.func_globals = self.check_func.func_globals
    444              self.check_func.func_code = self.clone_func.func_code
    445 -    def uninstall_clone(self):
    446          if _IS_PYTHON_3:
    447              self.check_func.__code__ = self.func_code
    448          else:
    449              self.check_func.func_code = self.func_code
    450 -    def install_dt_self(self):
    451          self.prev_func = self.dt_self._DocTestRunner__record_outcome
    452          self.dt_self._DocTestRunner__record_outcome = self
    453 -    def uninstall_dt_self(self):
    454          self.dt_self._DocTestRunner__record_outcome = self.prev_func
    455 -    def uninstall_module(self):
    456          if self.del_module:
    457              import sys
    458              del sys.modules[self.del_module]
    459              if '.' in self.del_module:
    460                  package, module = self.del_module.rsplit('.', 1)
    461                  package_mod = sys.modules[package]
    462                  delattr(package_mod, module)
    463 -    def __call__(self, *args, **kw):
    464          self.uninstall_clone()
    465          self.uninstall_dt_self()
    466          del self.checker._temp_override_self
    467          del self.checker._temp_call_super_check_output
    468          result = self.prev_func(*args, **kw)
    469          self.uninstall_module()
    470          return result
    471 -    def call_super(self, *args, **kw):
    472          self.uninstall_clone()
    473          try:
    474              return self.check_func(*args, **kw)
    475          finally:
    476              self.install_clone()
    477
    478 -def _find_doctest_frame():
    479      import sys
    480      frame = sys._getframe(1)
    481      while frame:
    482          l = frame.f_locals
    483          if 'BOOM' in l:
    484              # Sign of doctest
    485              return frame
    486          frame = frame.f_back
    487      raise LookupError(
    488          "Could not find doctest (only use this function *inside* a doctest)")
    489
    490  __test__ = {
    491      'basic': '''
    492      >>> temp_install()
    493      >>> print """<xml a="1" b="2">stuff</xml>"""
    494      <xml b="2" a="1">...</xml>
    495      >>> print """<xml xmlns="http://example.com"><tag   attr="bar"   /></xml>"""
    496      <xml xmlns="...">
    497        <tag attr="..." />
    498      </xml>
    499      >>> print """<xml>blahblahblah<foo /></xml>""" # doctest: +NOPARSE_MARKUP, +ELLIPSIS
    500      <xml>...foo /></xml>
    501      '''}
    502
    503  if __name__ == '__main__':
    504      import doctest
    505      doctest.testmod()
    506