Edgewall Software

source: branches/stable/0.6.x/genshi/input.py

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

Merge r1219 from trunk (fix for PIs without data, fixes #368).

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