Edgewall Software

source: trunk/genshi/input.py @ 1212

Last change on this file since 1212 was 1212, checked in by hodgestar, 11 years ago

Remove unused isinstance checks.

  • Property svn:eol-style set to native
File size: 16.1 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2006-2009 Edgewall Software
4# All rights reserved.
5#
6# This software is licensed as described in the file COPYING, which
7# you should have received as part of this distribution. The terms
8# are also available at http://genshi.edgewall.org/wiki/License.
9#
10# This software consists of voluntary contributions made by many
11# individuals. For the exact contribution history, see the revision
12# history and logs, available at http://genshi.edgewall.org/log/.
13
14"""Support for constructing markup streams from files, strings, or other
15sources.
16"""
17
18from itertools import chain
19import codecs
20import htmlentitydefs as entities
21import HTMLParser as html
22from xml.parsers import expat
23
24from genshi.core import Attrs, QName, Stream, stripentities
25from genshi.core import START, END, XML_DECL, DOCTYPE, TEXT, START_NS, \
26                        END_NS, START_CDATA, END_CDATA, PI, COMMENT
27from genshi.compat import StringIO, BytesIO
28
29
30__all__ = ['ET', 'ParseError', 'XMLParser', 'XML', 'HTMLParser', 'HTML']
31__docformat__ = 'restructuredtext en'
32
33
34def ET(element):
35    """Convert a given ElementTree element to a markup stream.
36   
37    :param element: an ElementTree element
38    :return: a markup stream
39    """
40    tag_name = QName(element.tag.lstrip('{'))
41    attrs = Attrs([(QName(attr.lstrip('{')), value)
42                   for attr, value in element.items()])
43
44    yield START, (tag_name, attrs), (None, -1, -1)
45    if element.text:
46        yield TEXT, element.text, (None, -1, -1)
47    for child in element.getchildren():
48        for item in ET(child):
49            yield item
50    yield END, tag_name, (None, -1, -1)
51    if element.tail:
52        yield TEXT, element.tail, (None, -1, -1)
53
54
55class ParseError(Exception):
56    """Exception raised when fatal syntax errors are found in the input being
57    parsed.
58    """
59
60    def __init__(self, message, filename=None, lineno=-1, offset=-1):
61        """Exception initializer.
62       
63        :param message: the error message from the parser
64        :param filename: the path to the file that was parsed
65        :param lineno: the number of the line on which the error was encountered
66        :param offset: the column number where the error was encountered
67        """
68        self.msg = message
69        if filename:
70            message += ', in ' + filename
71        Exception.__init__(self, message)
72        self.filename = filename or '<string>'
73        self.lineno = lineno
74        self.offset = offset
75
76
77class XMLParser(object):
78    """Generator-based XML parser based on roughly equivalent code in
79    Kid/ElementTree.
80   
81    The parsing is initiated by iterating over the parser object:
82   
83    >>> parser = XMLParser(StringIO('<root id="2"><child>Foo</child></root>'))
84    >>> for kind, data, pos in parser:
85    ...     print('%s %s' % (kind, data))
86    START (QName('root'), Attrs([(QName('id'), u'2')]))
87    START (QName('child'), Attrs())
88    TEXT Foo
89    END child
90    END root
91    """
92
93    _entitydefs = ['<!ENTITY %s "&#%d;">' % (name, value) for name, value in
94                   entities.name2codepoint.items()]
95    _external_dtd = u'\n'.join(_entitydefs).encode('utf-8')
96
97    def __init__(self, source, filename=None, encoding=None):
98        """Initialize the parser for the given XML input.
99       
100        :param source: the XML text as a file-like object
101        :param filename: the name of the file, if appropriate
102        :param encoding: the encoding of the file; if not specified, the
103                         encoding is assumed to be ASCII, UTF-8, or UTF-16, or
104                         whatever the encoding specified in the XML declaration
105                         (if any)
106        """
107        self.source = source
108        self.filename = filename
109
110        # Setup the Expat parser
111        parser = expat.ParserCreate(encoding, '}')
112        parser.buffer_text = True
113        # Python 3 does not have returns_unicode
114        if hasattr(parser, 'returns_unicode'):
115            parser.returns_unicode = True
116        parser.ordered_attributes = True
117
118        parser.StartElementHandler = self._handle_start
119        parser.EndElementHandler = self._handle_end
120        parser.CharacterDataHandler = self._handle_data
121        parser.StartDoctypeDeclHandler = self._handle_doctype
122        parser.StartNamespaceDeclHandler = self._handle_start_ns
123        parser.EndNamespaceDeclHandler = self._handle_end_ns
124        parser.StartCdataSectionHandler = self._handle_start_cdata
125        parser.EndCdataSectionHandler = self._handle_end_cdata
126        parser.ProcessingInstructionHandler = self._handle_pi
127        parser.XmlDeclHandler = self._handle_xml_decl
128        parser.CommentHandler = self._handle_comment
129
130        # Tell Expat that we'll handle non-XML entities ourselves
131        # (in _handle_other)
132        parser.DefaultHandler = self._handle_other
133        parser.SetParamEntityParsing(expat.XML_PARAM_ENTITY_PARSING_ALWAYS)
134        parser.UseForeignDTD()
135        parser.ExternalEntityRefHandler = self._build_foreign
136
137        self.expat = parser
138        self._queue = []
139
140    def parse(self):
141        """Generator that parses the XML source, yielding markup events.
142       
143        :return: a markup event stream
144        :raises ParseError: if the XML text is not well formed
145        """
146        def _generate():
147            try:
148                bufsize = 4 * 1024 # 4K
149                done = False
150                while 1:
151                    while not done and len(self._queue) == 0:
152                        data = self.source.read(bufsize)
153                        if not data: # end of data
154                            if hasattr(self, 'expat'):
155                                self.expat.Parse('', True)
156                                del self.expat # get rid of circular references
157                            done = True
158                        else:
159                            if isinstance(data, unicode):
160                                data = data.encode('utf-8')
161                            self.expat.Parse(data, False)
162                    for event in self._queue:
163                        yield event
164                    self._queue = []
165                    if done:
166                        break
167            except expat.ExpatError, e:
168                msg = str(e)
169                raise ParseError(msg, self.filename, e.lineno, e.offset)
170        return Stream(_generate()).filter(_coalesce)
171
172    def __iter__(self):
173        return iter(self.parse())
174
175    def _build_foreign(self, context, base, sysid, pubid):
176        parser = self.expat.ExternalEntityParserCreate(context)
177        parser.ParseFile(BytesIO(self._external_dtd))
178        return 1
179
180    def _enqueue(self, kind, data=None, pos=None):
181        if pos is None:
182            pos = self._getpos()
183        if kind is TEXT:
184            # Expat reports the *end* of the text event as current position. We
185            # try to fix that up here as much as possible. Unfortunately, the
186            # offset is only valid for single-line text. For multi-line text,
187            # it is apparently not possible to determine at what offset it
188            # started
189            if '\n' in data:
190                lines = data.splitlines()
191                lineno = pos[1] - len(lines) + 1
192                offset = -1
193            else:
194                lineno = pos[1]
195                offset = pos[2] - len(data)
196            pos = (pos[0], lineno, offset)
197        self._queue.append((kind, data, pos))
198
199    def _getpos_unknown(self):
200        return (self.filename, -1, -1)
201
202    def _getpos(self):
203        return (self.filename, self.expat.CurrentLineNumber,
204                self.expat.CurrentColumnNumber)
205
206    def _handle_start(self, tag, attrib):
207        attrs = Attrs([(QName(name), value) for name, value in
208                       zip(*[iter(attrib)] * 2)])
209        self._enqueue(START, (QName(tag), attrs))
210
211    def _handle_end(self, tag):
212        self._enqueue(END, QName(tag))
213
214    def _handle_data(self, text):
215        self._enqueue(TEXT, text)
216
217    def _handle_xml_decl(self, version, encoding, standalone):
218        self._enqueue(XML_DECL, (version, encoding, standalone))
219
220    def _handle_doctype(self, name, sysid, pubid, has_internal_subset):
221        self._enqueue(DOCTYPE, (name, pubid, sysid))
222
223    def _handle_start_ns(self, prefix, uri):
224        self._enqueue(START_NS, (prefix or '', uri))
225
226    def _handle_end_ns(self, prefix):
227        self._enqueue(END_NS, prefix or '')
228
229    def _handle_start_cdata(self):
230        self._enqueue(START_CDATA)
231
232    def _handle_end_cdata(self):
233        self._enqueue(END_CDATA)
234
235    def _handle_pi(self, target, data):
236        self._enqueue(PI, (target, data))
237
238    def _handle_comment(self, text):
239        self._enqueue(COMMENT, text)
240
241    def _handle_other(self, text):
242        if text.startswith('&'):
243            # deal with undefined entities
244            try:
245                text = unichr(entities.name2codepoint[text[1:-1]])
246                self._enqueue(TEXT, text)
247            except KeyError:
248                filename, lineno, offset = self._getpos()
249                error = expat.error('undefined entity "%s": line %d, column %d'
250                                    % (text, lineno, offset))
251                error.code = expat.errors.XML_ERROR_UNDEFINED_ENTITY
252                error.lineno = lineno
253                error.offset = offset
254                raise error
255
256
257def XML(text):
258    """Parse the given XML source and return a markup stream.
259   
260    Unlike with `XMLParser`, the returned stream is reusable, meaning it can be
261    iterated over multiple times:
262   
263    >>> xml = XML('<doc><elem>Foo</elem><elem>Bar</elem></doc>')
264    >>> print(xml)
265    <doc><elem>Foo</elem><elem>Bar</elem></doc>
266    >>> print(xml.select('elem'))
267    <elem>Foo</elem><elem>Bar</elem>
268    >>> print(xml.select('elem/text()'))
269    FooBar
270   
271    :param text: the XML source
272    :return: the parsed XML event stream
273    :raises ParseError: if the XML text is not well-formed
274    """
275    return Stream(list(XMLParser(StringIO(text))))
276
277
278class HTMLParser(html.HTMLParser, object):
279    """Parser for HTML input based on the Python `HTMLParser` module.
280   
281    This class provides the same interface for generating stream events as
282    `XMLParser`, and attempts to automatically balance tags.
283   
284    The parsing is initiated by iterating over the parser object:
285   
286    >>> parser = HTMLParser(BytesIO(u'<UL compact><LI>Foo</UL>'.encode('utf-8')), encoding='utf-8')
287    >>> for kind, data, pos in parser:
288    ...     print('%s %s' % (kind, data))
289    START (QName('ul'), Attrs([(QName('compact'), u'compact')]))
290    START (QName('li'), Attrs())
291    TEXT Foo
292    END li
293    END ul
294    """
295
296    _EMPTY_ELEMS = frozenset(['area', 'base', 'basefont', 'br', 'col', 'frame',
297                              'hr', 'img', 'input', 'isindex', 'link', 'meta',
298                              'param'])
299
300    def __init__(self, source, filename=None, encoding=None):
301        """Initialize the parser for the given HTML input.
302       
303        :param source: the HTML text as a file-like object
304        :param filename: the name of the file, if known
305        :param filename: encoding of the file; ignored if the input is unicode
306        """
307        html.HTMLParser.__init__(self)
308        self.source = source
309        self.filename = filename
310        self.encoding = encoding
311        self._queue = []
312        self._open_tags = []
313
314    def parse(self):
315        """Generator that parses the HTML source, yielding markup events.
316       
317        :return: a markup event stream
318        :raises ParseError: if the HTML text is not well formed
319        """
320        def _generate():
321            if self.encoding:
322                reader = codecs.getreader(self.encoding)
323                source = reader(self.source)
324            else:
325                source = self.source
326            try:
327                bufsize = 4 * 1024 # 4K
328                done = False
329                while 1:
330                    while not done and len(self._queue) == 0:
331                        data = source.read(bufsize)
332                        if not data: # end of data
333                            self.close()
334                            done = True
335                        else:
336                            if not isinstance(data, unicode):
337                                raise UnicodeError("source returned bytes, but no encoding specified")
338                            self.feed(data)
339                    for kind, data, pos in self._queue:
340                        yield kind, data, pos
341                    self._queue = []
342                    if done:
343                        open_tags = self._open_tags
344                        open_tags.reverse()
345                        for tag in open_tags:
346                            yield END, QName(tag), pos
347                        break
348            except html.HTMLParseError, e:
349                msg = '%s: line %d, column %d' % (e.msg, e.lineno, e.offset)
350                raise ParseError(msg, self.filename, e.lineno, e.offset)
351        return Stream(_generate()).filter(_coalesce)
352
353    def __iter__(self):
354        return iter(self.parse())
355
356    def _enqueue(self, kind, data, pos=None):
357        if pos is None:
358            pos = self._getpos()
359        self._queue.append((kind, data, pos))
360
361    def _getpos(self):
362        lineno, column = self.getpos()
363        return (self.filename, lineno, column)
364
365    def handle_starttag(self, tag, attrib):
366        fixed_attrib = []
367        for name, value in attrib: # Fixup minimized attributes
368            if value is None:
369                value = name
370            fixed_attrib.append((QName(name), stripentities(value)))
371
372        self._enqueue(START, (QName(tag), Attrs(fixed_attrib)))
373        if tag in self._EMPTY_ELEMS:
374            self._enqueue(END, QName(tag))
375        else:
376            self._open_tags.append(tag)
377
378    def handle_endtag(self, tag):
379        if tag not in self._EMPTY_ELEMS:
380            while self._open_tags:
381                open_tag = self._open_tags.pop()
382                self._enqueue(END, QName(open_tag))
383                if open_tag.lower() == tag.lower():
384                    break
385
386    def handle_data(self, text):
387        self._enqueue(TEXT, text)
388
389    def handle_charref(self, name):
390        if name.lower().startswith('x'):
391            text = unichr(int(name[1:], 16))
392        else:
393            text = unichr(int(name))
394        self._enqueue(TEXT, text)
395
396    def handle_entityref(self, name):
397        try:
398            text = unichr(entities.name2codepoint[name])
399        except KeyError:
400            text = '&%s;' % name
401        self._enqueue(TEXT, text)
402
403    def handle_pi(self, data):
404        target, data = data.split(None, 1)
405        if data.endswith('?'):
406            data = data[:-1]
407        self._enqueue(PI, (target.strip(), data.strip()))
408
409    def handle_comment(self, text):
410        self._enqueue(COMMENT, text)
411
412
413def HTML(text, encoding=None):
414    """Parse the given HTML source and return a markup stream.
415   
416    Unlike with `HTMLParser`, the returned stream is reusable, meaning it can be
417    iterated over multiple times:
418   
419    >>> html = HTML('<body><h1>Foo</h1></body>', encoding='utf-8')
420    >>> print(html)
421    <body><h1>Foo</h1></body>
422    >>> print(html.select('h1'))
423    <h1>Foo</h1>
424    >>> print(html.select('h1/text()'))
425    Foo
426   
427    :param text: the HTML source
428    :return: the parsed XML event stream
429    :raises ParseError: if the HTML text is not well-formed, and error recovery
430                        fails
431    """
432    if isinstance(text, unicode):
433        # If it's unicode text the encoding should be set to None.
434        # The option to pass in an incorrect encoding is for ease
435        # of writing doctests that work in both Python 2.x and 3.x.
436        return Stream(list(HTMLParser(StringIO(text), encoding=None)))
437    return Stream(list(HTMLParser(BytesIO(text), encoding=encoding)))
438
439
440def _coalesce(stream):
441    """Coalesces adjacent TEXT events into a single event."""
442    textbuf = []
443    textpos = None
444    for kind, data, pos in chain(stream, [(None, None, None)]):
445        if kind is TEXT:
446            textbuf.append(data)
447            if textpos is None:
448                textpos = pos
449        else:
450            if textbuf:
451                yield TEXT, ''.join(textbuf), textpos
452                del textbuf[:]
453                textpos = None
454            if kind:
455                yield kind, data, pos
Note: See TracBrowser for help on using the repository browser.