diff --git a/.travis.yml b/.travis.yml index 3039725..897ae4a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,7 @@ language: python python: - 2.6 - 2.7 + - 3.3 install: - pip install . --use-mirrors script: diff --git a/CHANGES.txt b/CHANGES.txt index e04b956..9c500b9 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -5,7 +5,14 @@ CHANGES 3.0.0 (unreleased) ================== -- Nothing changed yet. +- Added support for Python 3.3. At least for ``z3c.etestbrowser.wsgi`` + and ``z3c.etestbrowser.browser``. ``z3c.etestbrowser.testing`` also + supports Python 3 by itself, but it depends on ``zope.app.testing``, which + doesn't. + +- Backwards compatibility note: ``ExtendedTestBrowser.pretty_print()`` now + considers `` `` to be a whitespace character and collapses it into a + simple space. 2.0.0 (2011-10-13) @@ -23,6 +30,7 @@ CHANGES equality with ``zope.testbrowser`` but kept ``ExtendedTestBrowser`` for backwards compatibility. + 1.5.0 (2010-08-22) ================== @@ -48,6 +56,7 @@ CHANGES - Added doctest to `long_description` to show up on pypi. + 1.3.0 (2009-07-23) ================== @@ -56,6 +65,7 @@ CHANGES - Fixed bug with `normalized_contents` which would break the `open` function of test browser if content wasn't parsable as HTML/XML. + 1.2.0 (2008-05-29) ================== diff --git a/setup.py b/setup.py index 266ed52..988ce59 100644 --- a/setup.py +++ b/setup.py @@ -14,9 +14,14 @@ """Setup for z3c.etestbrowser package.""" import os -import ConfigParser from setuptools import setup, find_packages +try: + from ConfigParser import ConfigParser +except ImportError: + # Python 3.x + from configparser import ConfigParser + def here(*rnames): return os.path.join(os.path.dirname(__file__), *rnames) @@ -25,7 +30,7 @@ def read(*rnames): return f.read() def get_test_requires(): - parser = ConfigParser.ConfigParser() + parser = ConfigParser() parser.read([here('tox.ini')]) return parser.get('testenv', 'deps') @@ -41,8 +46,6 @@ def get_test_requires(): + '\n\n' + read('src', 'z3c', 'etestbrowser', 'README.txt') + '\n\n' + - read('src', 'z3c', 'etestbrowser', 'wsgi.txt') - + '\n\n' + read('src', 'z3c', 'etestbrowser', 'over_the_wire.txt') + '\n\n' + read('CHANGES.txt') @@ -56,6 +59,7 @@ def get_test_requires(): 'Programming Language :: Python', 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.3', 'Natural Language :: English', 'Operating System :: OS Independent', 'Topic :: Internet :: WWW/HTTP', diff --git a/src/z3c/__init__.py b/src/z3c/__init__.py index 7b12e4c..a5a4b11 100644 --- a/src/z3c/__init__.py +++ b/src/z3c/__init__.py @@ -2,6 +2,6 @@ try: import pkg_resources pkg_resources.declare_namespace(__name__) -except ImportError, e: +except ImportError as e: from pkgutil import extend_path __path__ = extend_path(__path__, __name__) diff --git a/src/z3c/etestbrowser/README.txt b/src/z3c/etestbrowser/README.txt index 98508cb..4df9380 100644 --- a/src/z3c/etestbrowser/README.txt +++ b/src/z3c/etestbrowser/README.txt @@ -1,9 +1,8 @@ Extended testbrowser -------------------- -This package provides some extensions to Zope 3's testbrowser. It is intended -for extensions that have dependencies that we do not want to rely on in the -Zope 3 core e.g. lxml. +This package provides some extensions to ``zope.testbrowser``. These are not +included in the core because they have extra dependencies, such as ``lxml``. Requirements @@ -23,10 +22,11 @@ and related XML technologies. Example: - >>> from z3c.etestbrowser.testing import ExtendedTestBrowser - >>> browser = ExtendedTestBrowser() + >>> from z3c.etestbrowser.wsgi import Browser + >>> browser = Browser(wsgi_app=wsgi_app) + >>> browser.handleErrors = False >>> browser.open("http://localhost/") - >>> print browser.contents + >>> print(browser.contents) ... @@ -60,8 +60,8 @@ contains a German umlaut: >>> browser.xml_strict = False >>> browser.open('http://localhost/lxml.html') - >>> browser.etree.xpath("//span")[0].text - u'K\xfcgelblitz.' + >>> print(ascii(browser.etree.xpath("//span")[0].text)) + 'K\xfcgelblitz.' Invalid XML/HTML responses ++++++++++++++++++++++++++ @@ -88,7 +88,7 @@ Sometimes a normal `print` of the browsers contents is hard to read for debugging: >>> browser.open('http://localhost/') - >>> print browser.contents + >>> print(browser.contents) >> browser.pretty_print() @import url(http://localhost/@@/zope3_tablelayout.css); User: Fallback - unauthenticated principal [Login][1] (image)[2] Location:...[top][3] / + unauthenticated principal [Login][1] (image)[2] Location: [top][3] / Navigation - Loading... ... Name Title Created Modified ... + Loading... Name Title Created Modified HTML/XML normalization ~~~~~~~~~~~~~~~~~~~~~~ @@ -109,7 +109,7 @@ testing examples with HTML or XML a bit easier when unimportant details like whitespace are changing: >>> browser.open('http://localhost/funny.html') - >>> print browser.contents + >>> print(browser.contents) Foo @@ -124,7 +124,7 @@ whitespace are changing: versus - >>> print browser.normalized_contents + >>> print(browser.normalized_contents) Foo diff --git a/src/z3c/etestbrowser/_prettyprint.py b/src/z3c/etestbrowser/_prettyprint.py new file mode 100644 index 0000000..281fe1c --- /dev/null +++ b/src/z3c/etestbrowser/_prettyprint.py @@ -0,0 +1,440 @@ +"""HTML pretty-printer based on Python 2's htmllib.py""" + +import html.parser +import html.entities + +from formatter import AS_IS + + +class HTMLParser(html.parser.HTMLParser): + + def __init__(self, formatter): + """Creates an instance of the HTMLParser class. + + The formatter parameter is the formatter instance associated with + the parser. + + """ + super().__init__() + self.formatter = formatter + + def reset(self): + super().reset() + self.savedata = None + self.isindex = 0 + self.title = None + self.base = None + self.anchor = None + self.anchorlist = [] + self.nofill = 0 + self.list_stack = [] + + def handle_starttag(self, tag, attrs): + method = getattr(self, 'start_' + tag, + getattr(self, 'do_' + tag, None)) + if method is None: + self.unknown_starttag(tag, attrs) + else: + method(attrs) + + def handle_endtag(self, tag): + method = getattr(self, 'end_' + tag, None) + if method is None: + self.unknown_endtag(tag) + else: + method() + + def handle_entityref(self, name): + replacement = html.entities.entitydefs.get(name) + if replacement: + self.handle_data(replacement) + + def handle_charrsef(self, name): + # handle numeric character references + self.handle_data(chr(int(name))) + + # ------ Methods used internally; some may be overridden + + # --- Formatter interface, taking care of 'savedata' mode; + # shouldn't need to be overridden + + def handle_data(self, data): + if self.savedata is not None: + self.savedata = self.savedata + data + else: + if self.nofill: + self.formatter.add_literal_data(data) + else: + self.formatter.add_flowing_data(data) + + # --- Hooks to save data; shouldn't need to be overridden + + def save_bgn(self): + """Begins saving character data in a buffer instead of sending it + to the formatter object. + + Retrieve the stored data via the save_end() method. Use of the + save_bgn() / save_end() pair may not be nested. + + """ + self.savedata = '' + + def save_end(self): + """Ends buffering character data and returns all data saved since + the preceding call to the save_bgn() method. + + If the nofill flag is false, whitespace is collapsed to single + spaces. A call to this method without a preceding call to the + save_bgn() method will raise a TypeError exception. + + """ + data = self.savedata + self.savedata = None + if not self.nofill: + data = ' '.join(data.split()) + return data + + # --- Hooks for anchors; should probably be overridden + + def anchor_bgn(self, href, name, type): + """This method is called at the start of an anchor region. + + The arguments correspond to the attributes of the tag with + the same names. The default implementation maintains a list of + hyperlinks (defined by the HREF attribute for tags) within + the document. The list of hyperlinks is available as the data + attribute anchorlist. + + """ + self.anchor = href + if self.anchor: + self.anchorlist.append(href) + + def anchor_end(self): + """This method is called at the end of an anchor region. + + The default implementation adds a textual footnote marker using an + index into the list of hyperlinks created by the anchor_bgn()method. + + """ + if self.anchor: + self.handle_data("[%d]" % len(self.anchorlist)) + self.anchor = None + + # --- Hook for images; should probably be overridden + + def handle_image(self, src, alt, *args): + """This method is called to handle images. + + The default implementation simply passes the alt value to the + handle_data() method. + + """ + self.handle_data(alt) + + # --------- Top level elememts + + def start_html(self, attrs): pass + def end_html(self): pass + + def start_head(self, attrs): pass + def end_head(self): pass + + def start_body(self, attrs): pass + def end_body(self): pass + + # ------ Head elements + + def start_title(self, attrs): + self.save_bgn() + + def end_title(self): + self.title = self.save_end() + + def do_base(self, attrs): + for a, v in attrs: + if a == 'href': + self.base = v + + def do_isindex(self, attrs): + self.isindex = 1 + + def do_link(self, attrs): + pass + + def do_meta(self, attrs): + pass + + def do_nextid(self, attrs): # Deprecated + pass + + # ------ Body elements + + # --- Headings + + def start_h1(self, attrs): + self.formatter.end_paragraph(1) + self.formatter.push_font(('h1', 0, 1, 0)) + + def end_h1(self): + self.formatter.end_paragraph(1) + self.formatter.pop_font() + + def start_h2(self, attrs): + self.formatter.end_paragraph(1) + self.formatter.push_font(('h2', 0, 1, 0)) + + def end_h2(self): + self.formatter.end_paragraph(1) + self.formatter.pop_font() + + def start_h3(self, attrs): + self.formatter.end_paragraph(1) + self.formatter.push_font(('h3', 0, 1, 0)) + + def end_h3(self): + self.formatter.end_paragraph(1) + self.formatter.pop_font() + + def start_h4(self, attrs): + self.formatter.end_paragraph(1) + self.formatter.push_font(('h4', 0, 1, 0)) + + def end_h4(self): + self.formatter.end_paragraph(1) + self.formatter.pop_font() + + def start_h5(self, attrs): + self.formatter.end_paragraph(1) + self.formatter.push_font(('h5', 0, 1, 0)) + + def end_h5(self): + self.formatter.end_paragraph(1) + self.formatter.pop_font() + + def start_h6(self, attrs): + self.formatter.end_paragraph(1) + self.formatter.push_font(('h6', 0, 1, 0)) + + def end_h6(self): + self.formatter.end_paragraph(1) + self.formatter.pop_font() + + # --- Block Structuring Elements + + def do_p(self, attrs): + self.formatter.end_paragraph(1) + + def start_pre(self, attrs): + self.formatter.end_paragraph(1) + self.formatter.push_font((AS_IS, AS_IS, AS_IS, 1)) + self.nofill = self.nofill + 1 + + def end_pre(self): + self.formatter.end_paragraph(1) + self.formatter.pop_font() + self.nofill = max(0, self.nofill - 1) + + def start_xmp(self, attrs): + self.start_pre(attrs) + self.setliteral('xmp') # Tell SGML parser + + def end_xmp(self): + self.end_pre() + + def start_listing(self, attrs): + self.start_pre(attrs) + self.setliteral('listing') # Tell SGML parser + + def end_listing(self): + self.end_pre() + + def start_address(self, attrs): + self.formatter.end_paragraph(0) + self.formatter.push_font((AS_IS, 1, AS_IS, AS_IS)) + + def end_address(self): + self.formatter.end_paragraph(0) + self.formatter.pop_font() + + def start_blockquote(self, attrs): + self.formatter.end_paragraph(1) + self.formatter.push_margin('blockquote') + + def end_blockquote(self): + self.formatter.end_paragraph(1) + self.formatter.pop_margin() + + # --- List Elements + + def start_ul(self, attrs): + self.formatter.end_paragraph(not self.list_stack) + self.formatter.push_margin('ul') + self.list_stack.append(['ul', '*', 0]) + + def end_ul(self): + if self.list_stack: del self.list_stack[-1] + self.formatter.end_paragraph(not self.list_stack) + self.formatter.pop_margin() + + def do_li(self, attrs): + self.formatter.end_paragraph(0) + if self.list_stack: + [dummy, label, counter] = top = self.list_stack[-1] + top[2] = counter = counter+1 + else: + label, counter = '*', 0 + self.formatter.add_label_data(label, counter) + + def start_ol(self, attrs): + self.formatter.end_paragraph(not self.list_stack) + self.formatter.push_margin('ol') + label = '1.' + for a, v in attrs: + if a == 'type': + if len(v) == 1: v = v + '.' + label = v + self.list_stack.append(['ol', label, 0]) + + def end_ol(self): + if self.list_stack: del self.list_stack[-1] + self.formatter.end_paragraph(not self.list_stack) + self.formatter.pop_margin() + + def start_menu(self, attrs): + self.start_ul(attrs) + + def end_menu(self): + self.end_ul() + + def start_dir(self, attrs): + self.start_ul(attrs) + + def end_dir(self): + self.end_ul() + + def start_dl(self, attrs): + self.formatter.end_paragraph(1) + self.list_stack.append(['dl', '', 0]) + + def end_dl(self): + self.ddpop(1) + if self.list_stack: del self.list_stack[-1] + + def do_dt(self, attrs): + self.ddpop() + + def do_dd(self, attrs): + self.ddpop() + self.formatter.push_margin('dd') + self.list_stack.append(['dd', '', 0]) + + def ddpop(self, bl=0): + self.formatter.end_paragraph(bl) + if self.list_stack: + if self.list_stack[-1][0] == 'dd': + del self.list_stack[-1] + self.formatter.pop_margin() + + # --- Phrase Markup + + # Idiomatic Elements + + def start_cite(self, attrs): self.start_i(attrs) + def end_cite(self): self.end_i() + + def start_code(self, attrs): self.start_tt(attrs) + def end_code(self): self.end_tt() + + def start_em(self, attrs): self.start_i(attrs) + def end_em(self): self.end_i() + + def start_kbd(self, attrs): self.start_tt(attrs) + def end_kbd(self): self.end_tt() + + def start_samp(self, attrs): self.start_tt(attrs) + def end_samp(self): self.end_tt() + + def start_strong(self, attrs): self.start_b(attrs) + def end_strong(self): self.end_b() + + def start_var(self, attrs): self.start_i(attrs) + def end_var(self): self.end_i() + + # Typographic Elements + + def start_i(self, attrs): + self.formatter.push_font((AS_IS, 1, AS_IS, AS_IS)) + def end_i(self): + self.formatter.pop_font() + + def start_b(self, attrs): + self.formatter.push_font((AS_IS, AS_IS, 1, AS_IS)) + def end_b(self): + self.formatter.pop_font() + + def start_tt(self, attrs): + self.formatter.push_font((AS_IS, AS_IS, AS_IS, 1)) + def end_tt(self): + self.formatter.pop_font() + + def start_a(self, attrs): + href = '' + name = '' + type = '' + for attrname, value in attrs: + value = value.strip() + if attrname == 'href': + href = value + if attrname == 'name': + name = value + if attrname == 'type': + type = value.lower() + self.anchor_bgn(href, name, type) + + def end_a(self): + self.anchor_end() + + # --- Line Break + + def do_br(self, attrs): + self.formatter.add_line_break() + + # --- Horizontal Rule + + def do_hr(self, attrs): + self.formatter.add_hor_rule() + + # --- Image + + def do_img(self, attrs): + align = '' + alt = '(image)' + ismap = '' + src = '' + width = 0 + height = 0 + for attrname, value in attrs: + if attrname == 'align': + align = value + if attrname == 'alt': + alt = value + if attrname == 'ismap': + ismap = value + if attrname == 'src': + src = value + if attrname == 'width': + try: width = int(value) + except ValueError: pass + if attrname == 'height': + try: height = int(value) + except ValueError: pass + self.handle_image(src, alt, ismap, align, width, height) + + # --- Unhandled tags + + def unknown_starttag(self, tag, attrs): + pass + + def unknown_endtag(self, tag): + pass diff --git a/src/z3c/etestbrowser/browser.py b/src/z3c/etestbrowser/browser.py index e47c937..1e3196c 100644 --- a/src/z3c/etestbrowser/browser.py +++ b/src/z3c/etestbrowser/browser.py @@ -11,20 +11,22 @@ # FOR A PARTICULAR PURPOSE. # ############################################################################## -"""Extensions for z3c.etestbrowser +"""Extensions for z3c.etestbrowser""" -$Id$ -""" - -import re -import htmllib import formatter +import re import lxml.etree import lxml.html import zope.testbrowser.browser +try: + from htmllib import HTMLParser +except ImportError: + # Python 3 + from ._prettyprint import HTMLParser + RE_CHARSET = re.compile('.*;charset=(.*)') @@ -70,16 +72,13 @@ def etree(self): self.contents, parser=lxml.etree.XMLParser(resolve_entities=False)) else: - # This is a workaround against the broken fallback for - # encoding detection of libxml2. - # We have a chance of knowing the encoding as Zope states this in - # the content-type response header. content = self.contents - content_type = self.headers['content-type'] - match = RE_CHARSET.match(content_type) - if match is not None: - charset = match.groups()[0] - content = content.decode(charset) + if isinstance(content, bytes): + content_type = self.headers['content-type'] + match = RE_CHARSET.match(content_type) + if match is not None: + charset = match.groups()[0].strip('"' + "'") + content = content.decode(charset) self._etree = lxml.etree.HTML(content) return self._etree @@ -89,7 +88,7 @@ def normalized_contents(self): if self._normalized_contents is None: indent(self.etree) self._normalized_contents = lxml.etree.tostring( - self.etree, pretty_print=True) + self.etree, pretty_print=True).decode('UTF-8') return self._normalized_contents def _changed(self): @@ -103,9 +102,9 @@ def pretty_print(self): If the content is not text/html then it is just printed. """ if not self.headers['content-type'].lower().startswith('text/html'): - print self.contents + print(self.contents) else: - parser = htmllib.HTMLParser( + parser = HTMLParser( formatter.AbstractFormatter(formatter.DumbWriter())) parser.feed(self.contents) parser.close() diff --git a/src/z3c/etestbrowser/fake_index.pt b/src/z3c/etestbrowser/fake_index.pt new file mode 100644 index 0000000..c216b9a --- /dev/null +++ b/src/z3c/etestbrowser/fake_index.pt @@ -0,0 +1,85 @@ + + + + + Z3: + + + + + + + + + + + + + + + +
+
+
+ User: + Fallback unauthenticated principal + [Login] +
+ +
+
+ +
+
+ + +
+
+
+
+
+ + + + + + + + + + +
 NameTitleCreatedModified
+
+
+
+
+
+
+
+   +
+
+
+
+ + diff --git a/src/z3c/etestbrowser/ftesting.zcml b/src/z3c/etestbrowser/ftesting.zcml index 0c60dba..7d3278f 100644 --- a/src/z3c/etestbrowser/ftesting.zcml +++ b/src/z3c/etestbrowser/ftesting.zcml @@ -1,15 +1,28 @@ - + - - + + + + + + - - - - - + + + + + + + + + + + + + @@ -17,31 +30,42 @@ - + - + + + + + + name="lxml.html" + for="*" + template="lxml.pt" + permission="zope.View" + /> + name="funny.html" + for="*" + template="funny.pt" + permission="zope.View" + /> + name="empty.html" + for="*" + template="empty.pt" + permission="zope.View" + /> diff --git a/src/z3c/etestbrowser/tests.py b/src/z3c/etestbrowser/tests.py index 1b4e544..5e425b5 100644 --- a/src/z3c/etestbrowser/tests.py +++ b/src/z3c/etestbrowser/tests.py @@ -16,38 +16,56 @@ $Id$ """ -from zope.app.testing import functional import doctest -import os.path +import re import unittest + import z3c.etestbrowser import zope.app.wsgi.testlayer +import zope.testing.renormalizing + +try: + ascii +except NameError: + # Python 2 + ascii = repr + + +try: + unichr +except NameError: + # Python 3 + unichr = chr + +checker = zope.testing.renormalizing.OutputChecker([ + # Python 3 prints uncode reprs differently + (re.compile(r"\bu('[^']*')"), r'\1'), + # Python 3 prints fully-qualified dotted names for exceptions + (re.compile(r'lxml\.etree\.(XMLSyntaxError:)'), r'\1'), + (re.compile(r'zope\.testbrowser\.browser\.(RobotExclusionError:)'), r'\1'), + # Python 2's formatter treat NBSP as printable characters, + # Python 3's formatter treats NBSP as a space and normalizes it + (re.compile(unichr(0xA0)), r' '), +]) -layer = functional.ZCMLLayer( - os.path.join(os.path.split(__file__)[0], 'ftesting.zcml'), - __name__, 'ETestBrowserLayer', allow_teardown=True) -wsgi_layer = zope.app.wsgi.testlayer.BrowserLayer(z3c.etestbrowser, allowTearDown=True) +wsgi_layer = zope.app.wsgi.testlayer.BrowserLayer(z3c.etestbrowser, + allowTearDown=True) + def setUpWSGI(test): test.globs['wsgi_app'] = wsgi_layer.make_wsgi_app() + test.globs['ascii'] = ascii def test_suite(): suite = unittest.TestSuite() - - test = functional.FunctionalDocFileSuite( + wsgi_test = doctest.DocFileSuite( "README.txt", "over_the_wire.txt", - optionflags=doctest.REPORT_NDIFF|doctest.NORMALIZE_WHITESPACE| - doctest.ELLIPSIS) - test.layer = layer - suite.addTest(test) - - wsgi_test = doctest.DocFileSuite( - "wsgi.txt", setUp=setUpWSGI, + checker=checker, optionflags=doctest.NORMALIZE_WHITESPACE|doctest.ELLIPSIS) wsgi_test.layer = wsgi_layer suite.addTest(wsgi_test) diff --git a/src/z3c/etestbrowser/wsgi.txt b/src/z3c/etestbrowser/wsgi.txt deleted file mode 100644 index c959005..0000000 --- a/src/z3c/etestbrowser/wsgi.txt +++ /dev/null @@ -1,20 +0,0 @@ -Extended testbrowser for zope.testbrowser.wsgi ----------------------------------------------- - -There is also a variant in ``z3c.etestbrowser.wsgi`` which can be used for -the WSGI variant of ``zope.testbrowser``. - -Example: - - >>> import z3c.etestbrowser.wsgi - >>> browser = z3c.etestbrowser.wsgi.Browser(wsgi_app=wsgi_app) - >>> browser.open("http://localhost/") - >>> print browser.contents - - ... - - >>> browser.etree - - >>> browser.etree.xpath('//body') - [] - diff --git a/tox.ini b/tox.ini index ff9643f..ab93bda 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,10 @@ [tox] envlist = - py26,py27 + py26,py27,py33 [testenv] deps = zope.testrunner - zope.app.testing zope.app.wsgi >= 4.0dev zope.app.zcmlfiles zope.app.server