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