Renamed RodelbahnboxValidator to RodelbahnboxDictValidator.
[philipp/winterrodeln/wrpylib.git] / wrpylib / mwmarkup.py
1 #!/usr/bin/python2.7
2 # -*- coding: iso-8859-15 -*-
3 # $Id$
4 # $HeadURL$
5 """This module contains general functions that help parsing the mediawiki markup.
6 I looked for an already existing MediaWiki parser in Python but I didn't find anything 
7 that convinced me. However, here are the links:
8
9 * py-wikimarkup https://github.com/dcramer/py-wikimarkup
10 * mwlib http://code.pediapress.com/wiki/wiki
11 """
12 import re
13 import xml.etree.ElementTree
14 import collections
15 import formencode
16
17
18 class ParseError(RuntimeError):
19     """Exception used by some of the functions"""
20     pass
21
22
23 def find_template(wikitext, template_title):
24     """Returns the tuple (start, end) of the first occurence of the template '{{template ...}} within wikitext'.
25     (None, None) is returned if the template is not found.
26     If you are sure that the wikitext contains the template, the template could be extracted like follows:
27
28     >>> wikitext = u'This is a {{Color|red|red text}} template.'
29     >>> start, end = find_template(wikitext, u'Color')
30     >>> print wikitext[start:end]
31     {{Color|red|red text}}
32
33     or just:
34
35     >>> print wikitext.__getslice__(*find_template(wikitext, u'Color'))
36     {{Color|red|red text}}
37
38     The search is done with regular expression. It gives wrong results when parsing a template
39     containing the characters "}}"
40
41     :param wikitext: The text (preferalbe unicode) that has the template in it.
42     :param template_title: The page title of the template with or without namespace (but as in the wikitext).
43     :return: 
44         (start, end) of the first occurence with start >= 0 and end > start.
45         (None, None) if the template is not found.
46     """ 
47     match = re.search(u"\{\{" + template_title + "\s*(\|[^\}]*)?\}\}", wikitext,  re.DOTALL)
48     if match is None: return None, None
49     return match.start(), match.end()
50
51
52 class TemplateValidator(formencode.FancyValidator):
53     def __init__(self, strip=True, as_table=False, as_table_keylen=None):
54         """Validates a MediaWiki template, e.g. {{Color|red}}
55         :param stip: If strip is True, the title, and the parameter keys and values are stripped in to_python.
56         :param as_table: formats the returned template in one row for each parameter
57         :param as_table_keylen: length of the key field for from_python. None for "automatic"."""
58         self.strip = (lambda s: s.strip()) if strip else (lambda s: s)
59         self.as_table = as_table
60         self.as_table_keylen = as_table_keylen
61
62     def to_python(self, value, state=None):
63         """Takes a template, like u'{{Color|red|text=Any text}}' and translates it to a Python tuple
64         (title, anonym_params, named_params) where title is the template title,
65         anonym_params is a list of anonymous parameters and named_params is a OrderedDict
66         of named parameters. Whitespace of the parameters is stripped."""
67         if not value.startswith(u'{{'):
68             raise formencode.Invalid(u'Template does not start with "{{"', value, state)
69         if not value.endswith(u'}}'):
70             raise formencode.Invalid(u'Template does not end with "}}"', value, state)
71         parts = value[2:-2].split(u'|')
72
73         # template name
74         title = self.strip(parts[0])
75         if len(title) == 0:
76             raise formencode.Invalid(u'Empty template tilte.', value, state)
77         del parts[0]
78
79         # anonymous parameters
80         anonym_params = []
81         while len(parts) > 0:
82             equalsign_pos = parts[0].find(u'=')
83             if equalsign_pos >= 0: break # named parameter
84             anonym_params.append(self.strip(parts[0]))
85             del parts[0]
86
87         # named or numbered parameters
88         named_params = collections.OrderedDict()
89         while len(parts) > 0:
90             equalsign_pos = parts[0].find(u'=')
91             if equalsign_pos < 0:
92                 raise formencode.Invalid(u'Anonymous parameter after named parameter.', value, state)
93             key, sep, value = parts[0].partition(u'=')
94             key = self.strip(key)
95             if len(key) == 0:
96                 raise formencode.Invalid(u'Empty key.', value, state)
97             if named_params.has_key(key):
98                 raise formencode.Invalid(u'Duplicate key: "{0}"'.format(key), value, state)
99             named_params[key] = self.strip(value)
100             del parts[0]
101
102         return title, anonym_params, named_params
103
104     def from_python(self, value, state=None):
105         """Formats a MediaWiki template.
106         value is a tuple: (title, anonym_params, named_params)
107         where title is the template title, anonym_params is a list of anonymous parameters and
108         named_params is a dict or OrderedDict of named parameters."""
109         title, anonym_params, named_params = value
110         pipe_char, equal_char, end_char = (u'\n| ', u' = ', u'\n}}') if self.as_table else (u'|', u'=', u'}}')
111         parts = [u"{{" + title]
112         parts += anonym_params
113         as_table_keylen = self.as_table_keylen
114         if self.as_table and as_table_keylen is None:
115             as_table_keylen = max(map(len, named_params.iterkeys()))
116         for k, v in named_params.iteritems():
117             if self.as_table:
118                 k = k.ljust(as_table_keylen)
119             parts.append(k + equal_char + v)
120         return pipe_char.join(parts) + end_char
121
122
123 def split_template(template):
124     """Deprecated legacy function.
125
126     Takes a template, like u'{{Color|red|text=Any text}}' and translates it to a Python tuple
127     (template_title, parameters) where parameters is a Python dictionary {u'1': u'red', u'text'=u'Any text'}.
128     Anonymous parameters get integer keys (converted to unicode) starting with 1 
129     like in MediaWiki, named parameters are unicode strings.
130     Whitespace is stripped.
131     If an unexpected format is encountered, a ValueError is raised."""
132     try:
133         title, anonym_params, named_params = TemplateValidator().to_python(template)
134         parameters = dict(named_params)
135         for i in xrange(len(anonym_params)):
136             parameters[unicode(i+1)] = anonym_params[i]
137     except formencode.Invalid as e:
138         raise ValueError(e[0])
139     return title, parameters
140
141
142 def create_template(template_title, anonym_params=[], named_param_keys=[], named_param_values=[], as_table=False, as_table_keylen=None):
143     """Deprecated legacy function.
144
145     Formats a MediaWiki template.
146     :param template_title: Unicode string with the template name
147     :param anonym_params: list with parameters without keys
148     :param named_param_keys: list with keys of named parameters
149     :param named_param_values: list with values of named parameters, corresponding to named_param_keys.
150     :param as_table: formats the returned template in one row for each parameter
151     :param as_table_keylen: length of the key field. None for "automatic".
152     :return: unicode template"""
153     named_params = collections.OrderedDict(zip(named_param_keys, named_param_values))
154     return TemplateValidator(as_table=as_table, as_table_keylen=as_table_keylen).from_python((template_title, anonym_params, named_params))
155
156
157 def find_tag(wikitext, tagname, pos=0):
158     """Returns position information of the first occurence of the tag '<tag ...>...</tag>'
159     or '<tag ... />'.
160     If you are sure that the wikitext contains the tag, the tag could be extracted like follows:
161
162     >>> wikitext = u'This is a <tag>mytag</tag> tag.'
163     >>> start, content, endtag, end = find_template(wikitext, u'tag')
164     >>> print wikitext[start:end]
165     <tag>mytag</tag>
166
167     :param wikitext: The text (preferalbe unicode) that has the template in it.
168     :param tagname: Name of the tag, e.g. u'tag' for <tag>.
169     :param pos: position within wikitext to start searching the tag.
170     :return:
171         (start, content, endtag, end). start is the position of '<' of the tag,
172         content is the beginning of the content (after '>'), enttag is the
173         beginning of the end tag ('</') and end is one position after the end tag.
174         For single tags, (start, None, None, end) is returned.
175         If the tag is not found (or only the start tag is present,
176         (None, None, None, None) is returned.
177     """
178     # Find start tag
179     regexp_starttag = re.compile(u"<{0}.*?(/?)>".format(tagname), re.DOTALL)
180     match_starttag = regexp_starttag.search(wikitext, pos)
181     if match_starttag is None:
182         return None, None, None, None
183
184     # does the tag have content?
185     if len(match_starttag.group(1)) == 1: # group(1) is either '' or '/'.
186         # single tag
187         return match_starttag.start(), None, None, match_starttag.end()
188
189     # tag with content
190     regexp_endtag = re.compile(u'</{0}>'.format(tagname), re.DOTALL)
191     match_endtag = regexp_endtag.search(wikitext, match_starttag.end())
192     if match_endtag is None:
193         # No closing tag - error in wikitext
194         return None, None, None, None
195     return match_starttag.start(), match_starttag.end(), match_endtag.start(), match_endtag.end()
196
197
198 def parse_googlemap(wikitext):
199     """Parses the (unicode) u'<googlemap ...>content</googlemap>' of the googlemap extension.
200     If wikitext does not contain the <googlemap> tag or if the <googlemap> tag contains
201     invalid formatted lines, a ParseError is raised.
202     Use find_tag(wikitext, 'googlemap') to find the googlemap tag within an arbitrary
203     wikitext before using this function.
204
205     :param wikitext: wikitext containing the template. Example:
206
207     wikitext = '''
208     <googlemap version="0.9" lat="47.113291" lon="11.272337" zoom="15">
209     (Parkplatz)47.114958,11.266026
210     Parkplatz
211     
212     (Gasthaus) 47.114715, 11.266262, Alt Bärnbad (Gasthaus)
213     6#FF014E9A
214     47.114715,11.266262
215     47.114135,11.268381
216     47.113421,11.269322
217     47.11277,11.269979
218     47.112408,11.271119
219     </googlemap>
220     '''
221     :returns: The tuple (attributes, coords, paths) is returned.
222         attributes is a dict that contains the attribues that are present
223         (e.g. lon, lat, zoom, width, height) converted to float (lon, lat) or int.
224         coords is a list of (lon, lat, symbol, title) tuples.
225         paths is a list of (style, coords) tuples.
226         coords is again a list of (lon, lat, symbol, title) tuples."""
227
228     def is_coord(line):
229         """Returns True if the line contains a coordinate."""
230         match = re.search('[0-9]{1,2}\.[0-9]+, ?[0-9]{1,2}\.[0-9]+', line)
231         return not match is None
232
233     def is_path(line):
234         """Returns True if the line contains a path style definition."""
235         match = re.match('[0-9]#[0-9a-fA-F]{8}', line)
236         return not match is None
237
238     def parse_coord(line):
239         """Returns (lon, lat, symbol, title). If symbol or text is not present, None is returned."""
240         match = re.match(u'\(([^)]+)\) ?([0-9]{1,2}\.[0-9]+), ?([0-9]{1,2}\.[0-9]+), ?(.*)', line)
241         if not match is None: return (float(match.group(3)), float(match.group(2)), match.group(1), match.group(4))
242         match = re.match(u'\(([^)]+)\) ?([0-9]{1,2}\.[0-9]+), ?([0-9]{1,2}\.[0-9]+)', line)
243         if not match is None: return (float(match.group(3)), float(match.group(2)), match.group(1), None)
244         match = re.match(u'([0-9]{1,2}\.[0-9]+), ?([0-9]{1,2}\.[0-9]+), ?(.*)', line)
245         if not match is None: return (float(match.group(2)), float(match.group(1)), None, match.group(3))
246         match = re.match(u'([0-9]{1,2}\.[0-9]+), ?([0-9]{1,2}\.[0-9]+)', line)
247         if not match is None: return (float(match.group(2)), float(match.group(1)), None, None)
248         return ParseError(u'Could not parse line ' + line)
249
250     start, content, endtag, end = find_tag(wikitext, 'googlemap')
251     if start is None:
252         raise ParseError(u'<googlemap> tag not found.')
253     if content is None:
254         xml_only = wikitext[start:endtag]
255     else:
256         xml_only = wikitext[start:content]+wikitext[endtag:end]
257         
258     try:
259         gm = xml.etree.ElementTree.XML(xml_only.encode('UTF8'))
260     except xml.etree.ElementTree.ParseError as e:
261         row, column = e.position
262         raise ParseError(u"XML parse error in <googlemap ...>.")
263
264     # parse attributes
265     attributes = {}
266     try:
267         for key in ['lon', 'lat']:
268             if gm.get(key) is not None:
269                 attributes[key] = float(gm.get(key))
270         for key in ['zoom', 'width', 'height']:
271             if gm.get(key) is not None:
272                 attributes[key] = int(gm.get(key))
273     except ValueError as error:
274         raise ParseError(u'Error at parsing attribute {0} of <googlemap>: {1}'.format(key, unicode(error)))
275
276     # parse points and lines
277     coords = []
278     paths = []
279     lines = wikitext[content:endtag].split("\n")
280     i = 0
281     while i < len(lines):
282         line = lines[i].strip()
283         i += 1
284
285         # Skip whitespace
286         if len(line) == 0: continue
287
288         # Handle a path
289         if is_path(line):
290             match = re.match(u'([0-9]#[0-9a-fA-F]{8})', line)
291             style =  match.group(1)
292             local_coords = []
293             while i < len(lines):
294                 line = lines[i].strip()
295                 i += 1
296                 if is_path(line):
297                     i -= 1
298                     break
299                 if is_coord(line):
300                     lon, lat, symbol, title = parse_coord(line)
301                     local_coords.append((lon, lat, symbol, title))
302             paths.append((style, local_coords))
303             continue
304
305         # Handle a coordinate
306         if is_coord(line):
307             lon, lat, symbol, title = parse_coord(line)
308             while i < len(lines):
309                 line = lines[i].strip()
310                 i += 1
311                 if is_path(line) or is_coord(line):
312                     i -= 1
313                     break
314                 if len(line) > 0 and title is None: title = line
315             coords.append((lon, lat, symbol, title))
316             continue
317
318         raise ParseError(u'Unknown line syntax: ' + line)
319
320     return (attributes, coords, paths)
321