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