1# -*- test-case-name: twisted.web.test.test_template -*-
2# Copyright (c) Twisted Matrix Laboratories.
3# See LICENSE for details.
4
5
6"""
7HTML rendering for twisted.web.
8
9@var VALID_HTML_TAG_NAMES: A list of recognized HTML tag names, used by the
10    L{tag} object.
11
12@var TEMPLATE_NAMESPACE: The XML namespace used to identify attributes and
13    elements used by the templating system, which should be removed from the
14    final output document.
15
16@var tags: A convenience object which can produce L{Tag} objects on demand via
17    attribute access.  For example: C{tags.div} is equivalent to C{Tag("div")}.
18    Tags not specified in L{VALID_HTML_TAG_NAMES} will result in an
19    L{AttributeError}.
20"""
21
22__all__ = [
23    'TEMPLATE_NAMESPACE', 'VALID_HTML_TAG_NAMES', 'Element', 'TagLoader',
24    'XMLString', 'XMLFile', 'renderer', 'flatten', 'flattenString', 'tags',
25    'Comment', 'CDATA', 'Tag', 'slot', 'CharRef', 'renderElement'
26    ]
27
28import warnings
29from zope.interface import implements
30
31from cStringIO import StringIO
32from xml.sax import make_parser, handler
33
34from twisted.web._stan import Tag, slot, Comment, CDATA, CharRef
35from twisted.python.filepath import FilePath
36
37TEMPLATE_NAMESPACE = 'http://twistedmatrix.com/ns/twisted.web.template/0.1'
38
39from twisted.web.iweb import ITemplateLoader
40from twisted.python import log
41
42# Go read the definition of NOT_DONE_YET. For lulz. This is totally
43# equivalent. And this turns out to be necessary, because trying to import
44# NOT_DONE_YET in this module causes a circular import which we cannot escape
45# from. From which we cannot escape. Etc. glyph is okay with this solution for
46# now, and so am I, as long as this comment stays to explain to future
47# maintainers what it means. ~ C.
48#
49# See http://twistedmatrix.com/trac/ticket/5557 for progress on fixing this.
50NOT_DONE_YET = 1
51
52class _NSContext(object):
53    """
54    A mapping from XML namespaces onto their prefixes in the document.
55    """
56
57    def __init__(self, parent=None):
58        """
59        Pull out the parent's namespaces, if there's no parent then default to
60        XML.
61        """
62        self.parent = parent
63        if parent is not None:
64            self.nss = dict(parent.nss)
65        else:
66            self.nss = {'http://www.w3.org/XML/1998/namespace':'xml'}
67
68
69    def get(self, k, d=None):
70        """
71        Get a prefix for a namespace.
72
73        @param d: The default prefix value.
74        """
75        return self.nss.get(k, d)
76
77
78    def __setitem__(self, k, v):
79        """
80        Proxy through to setting the prefix for the namespace.
81        """
82        self.nss.__setitem__(k, v)
83
84
85    def __getitem__(self, k):
86        """
87        Proxy through to getting the prefix for the namespace.
88        """
89        return self.nss.__getitem__(k)
90
91
92
93class _ToStan(handler.ContentHandler, handler.EntityResolver):
94    """
95    A SAX parser which converts an XML document to the Twisted STAN
96    Document Object Model.
97    """
98
99    def __init__(self, sourceFilename):
100        """
101        @param sourceFilename: the filename to load the XML out of.
102        """
103        self.sourceFilename = sourceFilename
104        self.prefixMap = _NSContext()
105        self.inCDATA = False
106
107
108    def setDocumentLocator(self, locator):
109        """
110        Set the document locator, which knows about line and character numbers.
111        """
112        self.locator = locator
113
114
115    def startDocument(self):
116        """
117        Initialise the document.
118        """
119        self.document = []
120        self.current = self.document
121        self.stack = []
122        self.xmlnsAttrs = []
123
124
125    def endDocument(self):
126        """
127        Document ended.
128        """
129
130
131    def processingInstruction(self, target, data):
132        """
133        Processing instructions are ignored.
134        """
135
136
137    def startPrefixMapping(self, prefix, uri):
138        """
139        Set up the prefix mapping, which maps fully qualified namespace URIs
140        onto namespace prefixes.
141
142        This gets called before startElementNS whenever an C{xmlns} attribute
143        is seen.
144        """
145
146        self.prefixMap = _NSContext(self.prefixMap)
147        self.prefixMap[uri] = prefix
148
149        # Ignore the template namespace; we'll replace those during parsing.
150        if uri == TEMPLATE_NAMESPACE:
151            return
152
153        # Add to a list that will be applied once we have the element.
154        if prefix is None:
155            self.xmlnsAttrs.append(('xmlns',uri))
156        else:
157            self.xmlnsAttrs.append(('xmlns:%s'%prefix,uri))
158
159
160    def endPrefixMapping(self, prefix):
161        """
162        "Pops the stack" on the prefix mapping.
163
164        Gets called after endElementNS.
165        """
166        self.prefixMap = self.prefixMap.parent
167
168
169    def startElementNS(self, namespaceAndName, qname, attrs):
170        """
171        Gets called when we encounter a new xmlns attribute.
172
173        @param namespaceAndName: a (namespace, name) tuple, where name
174            determines which type of action to take, if the namespace matches
175            L{TEMPLATE_NAMESPACE}.
176        @param qname: ignored.
177        @param attrs: attributes on the element being started.
178        """
179
180        filename = self.sourceFilename
181        lineNumber = self.locator.getLineNumber()
182        columnNumber = self.locator.getColumnNumber()
183
184        ns, name = namespaceAndName
185        if ns == TEMPLATE_NAMESPACE:
186            if name == 'transparent':
187                name = ''
188            elif name == 'slot':
189                try:
190                    # Try to get the default value for the slot
191                    default = attrs[(None, 'default')]
192                except KeyError:
193                    # If there wasn't one, then use None to indicate no
194                    # default.
195                    default = None
196                el = slot(
197                    attrs[(None, 'name')], default=default,
198                    filename=filename, lineNumber=lineNumber,
199                    columnNumber=columnNumber)
200                self.stack.append(el)
201                self.current.append(el)
202                self.current = el.children
203                return
204
205        render = None
206
207        attrs = dict(attrs)
208        for k, v in attrs.items():
209            attrNS, justTheName = k
210            if attrNS != TEMPLATE_NAMESPACE:
211                continue
212            if justTheName == 'render':
213                render = v
214                del attrs[k]
215
216        # nonTemplateAttrs is a dictionary mapping attributes that are *not* in
217        # TEMPLATE_NAMESPACE to their values.  Those in TEMPLATE_NAMESPACE were
218        # just removed from 'attrs' in the loop immediately above.  The key in
219        # nonTemplateAttrs is either simply the attribute name (if it was not
220        # specified as having a namespace in the template) or prefix:name,
221        # preserving the xml namespace prefix given in the document.
222
223        nonTemplateAttrs = {}
224        for (attrNs, attrName), v in attrs.items():
225            nsPrefix = self.prefixMap.get(attrNs)
226            if nsPrefix is None:
227                attrKey = attrName
228            else:
229                attrKey = '%s:%s' % (nsPrefix, attrName)
230            nonTemplateAttrs[attrKey] = v
231
232        if ns == TEMPLATE_NAMESPACE and name == 'attr':
233            if not self.stack:
234                # TODO: define a better exception for this?
235                raise AssertionError(
236                    '<{%s}attr> as top-level element' % (TEMPLATE_NAMESPACE,))
237            if 'name' not in nonTemplateAttrs:
238                # TODO: same here
239                raise AssertionError(
240                    '<{%s}attr> requires a name attribute' % (TEMPLATE_NAMESPACE,))
241            el = Tag('', render=render, filename=filename,
242                     lineNumber=lineNumber, columnNumber=columnNumber)
243            self.stack[-1].attributes[nonTemplateAttrs['name']] = el
244            self.stack.append(el)
245            self.current = el.children
246            return
247
248        # Apply any xmlns attributes
249        if self.xmlnsAttrs:
250            nonTemplateAttrs.update(dict(self.xmlnsAttrs))
251            self.xmlnsAttrs = []
252
253        # Add the prefix that was used in the parsed template for non-template
254        # namespaces (which will not be consumed anyway).
255        if ns != TEMPLATE_NAMESPACE and ns is not None:
256            prefix = self.prefixMap[ns]
257            if prefix is not None:
258                name = '%s:%s' % (self.prefixMap[ns],name)
259        el = Tag(
260            name, attributes=dict(nonTemplateAttrs), render=render,
261            filename=filename, lineNumber=lineNumber,
262            columnNumber=columnNumber)
263        self.stack.append(el)
264        self.current.append(el)
265        self.current = el.children
266
267
268    def characters(self, ch):
269        """
270        Called when we receive some characters.  CDATA characters get passed
271        through as is.
272
273        @type ch: C{string}
274        """
275        if self.inCDATA:
276            self.stack[-1].append(ch)
277            return
278        self.current.append(ch)
279
280
281    def endElementNS(self, name, qname):
282        """
283        A namespace tag is closed.  Pop the stack, if there's anything left in
284        it, otherwise return to the document's namespace.
285        """
286        self.stack.pop()
287        if self.stack:
288            self.current = self.stack[-1].children
289        else:
290            self.current = self.document
291
292
293    def startDTD(self, name, publicId, systemId):
294        """
295        DTDs are ignored.
296        """
297
298
299    def endDTD(self, *args):
300        """
301        DTDs are ignored.
302        """
303
304
305    def startCDATA(self):
306        """
307        We're starting to be in a CDATA element, make a note of this.
308        """
309        self.inCDATA = True
310        self.stack.append([])
311
312
313    def endCDATA(self):
314        """
315        We're no longer in a CDATA element.  Collect up the characters we've
316        parsed and put them in a new CDATA object.
317        """
318        self.inCDATA = False
319        comment = ''.join(self.stack.pop())
320        self.current.append(CDATA(comment))
321
322
323    def comment(self, content):
324        """
325        Add an XML comment which we've encountered.
326        """
327        self.current.append(Comment(content))
328
329
330
331def _flatsaxParse(fl):
332    """
333    Perform a SAX parse of an XML document with the _ToStan class.
334
335    @param fl: The XML document to be parsed.
336    @type fl: A file object or filename.
337
338    @return: a C{list} of Stan objects.
339    """
340    parser = make_parser()
341    parser.setFeature(handler.feature_validation, 0)
342    parser.setFeature(handler.feature_namespaces, 1)
343    parser.setFeature(handler.feature_external_ges, 0)
344    parser.setFeature(handler.feature_external_pes, 0)
345
346    s = _ToStan(getattr(fl, "name", None))
347    parser.setContentHandler(s)
348    parser.setEntityResolver(s)
349    parser.setProperty(handler.property_lexical_handler, s)
350
351    parser.parse(fl)
352
353    return s.document
354
355
356class TagLoader(object):
357    """
358    An L{ITemplateLoader} that loads existing L{IRenderable} providers.
359
360    @ivar tag: The object which will be loaded.
361    @type tag: An L{IRenderable} provider.
362    """
363    implements(ITemplateLoader)
364
365    def __init__(self, tag):
366        """
367        @param tag: The object which will be loaded.
368        @type tag: An L{IRenderable} provider.
369        """
370        self.tag = tag
371
372
373    def load(self):
374        return [self.tag]
375
376
377
378class XMLString(object):
379    """
380    An L{ITemplateLoader} that loads and parses XML from a string.
381
382    @ivar _loadedTemplate: The loaded document.
383    @type _loadedTemplate: a C{list} of Stan objects.
384    """
385    implements(ITemplateLoader)
386
387    def __init__(self, s):
388        """
389        Run the parser on a StringIO copy of the string.
390
391        @param s: The string from which to load the XML.
392        @type s: C{str}
393        """
394        self._loadedTemplate = _flatsaxParse(StringIO(s))
395
396
397    def load(self):
398        """
399        Return the document.
400
401        @return: the loaded document.
402        @rtype: a C{list} of Stan objects.
403        """
404        return self._loadedTemplate
405
406
407
408class XMLFile(object):
409    """
410    An L{ITemplateLoader} that loads and parses XML from a file.
411
412    @ivar _loadedTemplate: The loaded document, or C{None}, if not loaded.
413    @type _loadedTemplate: a C{list} of Stan objects, or C{None}.
414
415    @ivar _path: The L{FilePath}, file object, or filename that is being
416        loaded from.
417    """
418    implements(ITemplateLoader)
419
420    def __init__(self, path):
421        """
422        Run the parser on a file.
423
424        @param path: The file from which to load the XML.
425        @type path: L{FilePath}
426        """
427        if not isinstance(path, FilePath):
428            warnings.warn(
429                "Passing filenames or file objects to XMLFile is deprecated "
430                "since Twisted 12.1.  Pass a FilePath instead.",
431                category=DeprecationWarning, stacklevel=2)
432        self._loadedTemplate = None
433        self._path = path
434
435
436    def _loadDoc(self):
437        """
438        Read and parse the XML.
439
440        @return: the loaded document.
441        @rtype: a C{list} of Stan objects.
442        """
443        if not isinstance(self._path, FilePath):
444            return _flatsaxParse(self._path)
445        else:
446            f = self._path.open('r')
447            try:
448                return _flatsaxParse(f)
449            finally:
450                f.close()
451
452
453    def __repr__(self):
454        return '<XMLFile of %r>' % (self._path,)
455
456
457    def load(self):
458        """
459        Return the document, first loading it if necessary.
460
461        @return: the loaded document.
462        @rtype: a C{list} of Stan objects.
463        """
464        if self._loadedTemplate is None:
465            self._loadedTemplate = self._loadDoc()
466        return self._loadedTemplate
467
468
469
470# Last updated October 2011, using W3Schools as a reference. Link:
471# http://www.w3schools.com/html5/html5_reference.asp
472# Note that <xmp> is explicitly omitted; its semantics do not work with
473# t.w.template and it is officially deprecated.
474VALID_HTML_TAG_NAMES = set([
475    'a', 'abbr', 'acronym', 'address', 'applet', 'area', 'article', 'aside',
476    'audio', 'b', 'base', 'basefont', 'bdi', 'bdo', 'big', 'blockquote',
477    'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code',
478    'col', 'colgroup', 'command', 'datalist', 'dd', 'del', 'details', 'dfn',
479    'dir', 'div', 'dl', 'dt', 'em', 'embed', 'fieldset', 'figcaption',
480    'figure', 'font', 'footer', 'form', 'frame', 'frameset', 'h1', 'h2', 'h3',
481    'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'iframe',
482    'img', 'input', 'ins', 'isindex', 'keygen', 'kbd', 'label', 'legend',
483    'li', 'link', 'map', 'mark', 'menu', 'meta', 'meter', 'nav', 'noframes',
484    'noscript', 'object', 'ol', 'optgroup', 'option', 'output', 'p', 'param',
485    'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'script',
486    'section', 'select', 'small', 'source', 'span', 'strike', 'strong',
487    'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'textarea',
488    'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'tt', 'u', 'ul', 'var',
489    'video', 'wbr',
490])
491
492
493
494class _TagFactory(object):
495    """
496    A factory for L{Tag} objects; the implementation of the L{tags} object.
497
498    This allows for the syntactic convenience of C{from twisted.web.html import
499    tags; tags.a(href="linked-page.html")}, where 'a' can be basically any HTML
500    tag.
501
502    The class is not exposed publicly because you only ever need one of these,
503    and we already made it for you.
504
505    @see: L{tags}
506    """
507    def __getattr__(self, tagName):
508        if tagName == 'transparent':
509            return Tag('')
510        # allow for E.del as E.del_
511        tagName = tagName.rstrip('_')
512        if tagName not in VALID_HTML_TAG_NAMES:
513            raise AttributeError('unknown tag %r' % (tagName,))
514        return Tag(tagName)
515
516
517
518tags = _TagFactory()
519
520
521
522def renderElement(request, element,
523                  doctype='<!DOCTYPE html>', _failElement=None):
524    """
525    Render an element or other C{IRenderable}.
526
527    @param request: The C{Request} being rendered to.
528    @param element: An C{IRenderable} which will be rendered.
529    @param doctype: A C{str} which will be written as the first line of
530        the request, or C{None} to disable writing of a doctype.  The C{string}
531        should not include a trailing newline and will default to the HTML5
532        doctype C{'<!DOCTYPE html>'}.
533
534    @returns: NOT_DONE_YET
535
536    @since: 12.1
537    """
538    if doctype is not None:
539        request.write(doctype)
540        request.write('\n')
541
542    if _failElement is None:
543        _failElement = twisted.web.util.FailureElement
544
545    d = flatten(request, element, request.write)
546
547    def eb(failure):
548        log.err(failure, "An error occurred while rendering the response.")
549        if request.site.displayTracebacks:
550            return flatten(request, _failElement(failure), request.write)
551        else:
552            request.write(
553                ('<div style="font-size:800%;'
554                 'background-color:#FFF;'
555                 'color:#F00'
556                 '">An error occurred while rendering the response.</div>'))
557
558    d.addErrback(eb)
559    d.addBoth(lambda _: request.finish())
560    return NOT_DONE_YET
561
562
563
564from twisted.web._element import Element, renderer
565from twisted.web._flatten import flatten, flattenString
566import twisted.web.util
567