Edgewall Software

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

Last change on this file was 392, checked in by cmlenz, 17 years ago

Ported [389:391] to 0.3.x branch.

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