345e011f0d44c247ecad967aca81684d89478253
[librarian.git] / librarian / xmlutils.py
1 # -*- coding: utf-8 -*-
2 #
3 # This file is part of Librarian, licensed under GNU Affero GPLv3 or later.
4 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
5 #
6 from lxml import etree
7 from collections import defaultdict
8
9
10 class Xmill(object):
11     """Transforms XML to some text.
12     Used instead of XSLT which is difficult and cumbersome.
13
14     """
15     def __init__(self, options=None):
16         self._options = []
17         if options:
18             self._options.append(options)
19         self.text_filters = []
20         self.escaped_text_filters = []
21
22     def register_text_filter(self, fun):
23         self.text_filters.append(fun)
24
25     def filter_text(self, text):
26         for flt in self.text_filters:
27             if text is None:
28                 return None
29             else:
30                 text = flt(text)
31         # TODO: just work on the tree and let lxml handle escaping.
32         e = etree.Element("x")
33         e.text = text
34         # This whole mixing text with ML is so wrong.
35         output = etree.tostring(e, encoding=unicode)[3:-4]
36         for flt in self.escaped_text_filters:
37             output = flt(output)
38         return output
39
40
41     def generate(self, document):
42         """Generate text from node using handlers defined in class."""
43         output = self._handle_element(document)
44         return u''.join([x for x in flatten(output) if x is not None])
45
46     @property
47     def options(self):
48         """Returnes merged scoped options for current node.
49         """
50         # Here we can see how a decision not to return the modified map
51         # leads to a need for a hack.
52         return reduce(lambda a, b: a.update(b) or a, self._options, defaultdict(lambda: None))
53
54     @options.setter
55     def options(self, opts):
56         """Sets options overrides for current and child nodes
57         """
58         self._options.append(opts)
59
60
61     def _handle_for_element(self, element):
62         ns = None
63         tagname = None
64 #        from nose.tools import set_trace
65
66         if element.tag[0] == '{':
67             for nshort, nhref in element.nsmap.items():
68                 try:
69                     if element.tag.index('{%s}' % nhref) == 0:
70                         ns = nshort
71                         tagname  = element.tag[len('{%s}' % nhref):]
72                         break
73                 except ValueError:
74                     pass
75             if not ns:
76                 raise ValueError("Strange ns for tag: %s, nsmap: %s" %
77                                  (element.tag, element.nsmap))
78         else:
79             tagname = element.tag
80
81         if ns:
82             meth_name = "handle_%s__%s" % (ns, tagname)
83         else:
84             meth_name = "handle_%s" % (tagname,)
85
86         handler = getattr(self, meth_name, None)
87         return handler
88
89     def next(self, element):
90         if len(element):
91             return element[0]
92
93         while True:
94             sibling = element.getnext()
95             if sibling is not None: return sibling  # found a new branch to dig into
96             element = element.getparent()
97             if element is None: return None  # end of tree
98
99     def _handle_element(self, element):
100         if isinstance(element, etree._Comment): return None
101         
102         handler = self._handle_for_element(element)
103         # How many scopes
104         try:
105             options_scopes = len(self._options)
106
107             if handler is None:
108                 pre = [self.filter_text(element.text)]
109                 post = [self.filter_text(element.tail)]
110             else:
111                 vals = handler(element)
112                 # depending on number of returned values, vals can be None, a value, or a tuple.
113                 # how poorly designed is that? 9 lines below are needed just to unpack this.
114                 if vals is None:
115                     return [self.filter_text(element.tail)]
116                 else:
117                     if not isinstance(vals, tuple):
118                         return [vals, self.filter_text(element.tail)]
119                     else:
120                         pre = [vals[0], self.filter_text(element.text)]
121                         post = [vals[1], self.filter_text(element.tail)]
122
123             out = pre + [self._handle_element(child) for child in element] + post
124         finally:
125             # clean up option scopes if necessary
126             self._options = self._options[0:options_scopes]
127         return out
128
129
130 def tag_open_close(name_, classes_=None, **attrs):
131     u"""Creates tag beginning and end.
132     
133     >>> tag_open_close("a", "klass", x=u"ą<")
134     (u'<a x="\\u0105&lt;" class="klass">', u'</a>')
135
136     """
137     if classes_:
138         if isinstance(classes_, (tuple, list)): classes_ = ' '.join(classes_)
139         attrs['class'] = classes_
140
141     e = etree.Element(name_)
142     e.text = " "
143     for k, v in attrs.items():
144         e.attrib[k] = v
145     pre, post = etree.tostring(e, encoding=unicode).split(u"> <")
146     return pre + u">", u"<" + post
147
148 def tag(name_, classes_=None, **attrs):
149     """Returns a handler which wraps node contents in tag `name', with class attribute
150     set to `classes' and other attributes according to keyword paramters
151     """
152     def _hnd(self, element):
153         return tag_open_close(name_, classes_, **attrs)
154     return _hnd
155
156
157 def tagged(name, classes=None, **attrs):
158     """Handler decorator which wraps handler output in tag `name', with class attribute
159     set to `classes' and other attributes according to keyword paramters
160     """
161     if classes:
162         if isinstance(classes, (tuple,list)): classes = ' '.join(classes)
163         attrs['class'] = classes
164     a = ''.join([' %s="%s"' % (k,v) for (k,v) in attrs.items()])
165     def _decor(f):
166         def _wrap(self, element):
167             r = f(self, element)
168             if r is None: return
169
170             prepend = "<%s%s>" % (name, a)
171             append = "</%s>" % name
172
173             if isinstance(r, tuple):
174                 return prepend + r[0], r[1] + append
175             return prepend + r + append
176         return _wrap
177     return _decor
178
179
180 def ifoption(**options):
181     """Decorator which enables node only when options are set
182     """
183     def _decor(f):
184         def _handler(self, *args, **kw):
185             opts = self.options
186             for k, v in options.items():
187                 if opts[k] != v:
188                     return
189             return f(self, *args, **kw)
190         return _handler
191     return _decor
192
193 def flatten(l, ltypes=(list, tuple)):
194     """flatten function from BasicPropery/BasicTypes package
195     """
196     ltype = type(l)
197     l = list(l)
198     i = 0
199     while i < len(l):
200         while isinstance(l[i], ltypes):
201             if not l[i]:
202                 l.pop(i)
203                 i -= 1
204                 break
205             else:
206                 l[i:i + 1] = l[i]
207         i += 1
208     return ltype(l)