Add type annotations and improve coding style.
[philipp/winterrodeln/wrpylib.git] / wrpylib / wrvalidators.py
1 """
2 This module contains functions that convert winterrodeln specific strings (like geographic coordinates) from string
3 to appropriate python types and the other way round.
4 Functions that take strings to convert it to python types are called *_from_str(value, [...]) and are supposed to
5 validate the string. In case of errors, a ValueError (or a subclass thereof) is returned.
6 Functions that take python types and convert it to Winterrodeln strings are called *_to_str(value, [...]) and can
7 assume that the value they get is valid. If it is not, the behavior is undefined.
8 The namedtuple FromToConverter groups corresponding *_from_str and *_to_str converters.
9 """
10 import email.headerregistry
11 import urllib.parse
12 import re
13 from collections import OrderedDict, namedtuple
14 from email.errors import HeaderParseError
15 from typing import Tuple, Optional, List, Any, Callable, Union, TypeVar, Dict
16
17 import mwparserfromhell  # https://github.com/earwig/mwparserfromhell
18
19 from wrpylib.mwmarkup import format_template_table
20
21
22 T = TypeVar("T")  # use for generic type annotations
23 E = TypeVar("E")  # use for generic type annotations
24 N = TypeVar("N")  # use for generic type annotations
25
26
27 # FromToConverter type
28 # --------------------
29
30 # namedtuple that groups corresponding *_from_str and *_to_str functions.
31 FromToConverter = namedtuple('FromToConverter', ['from_str', 'to_str'])
32
33
34 # optional converter
35 # ------------------
36
37 def opt_from_str(value: str, from_str: Callable[[str], T], empty: E = None) -> Union[T, E]:
38     """Makes the converter `from_str` "optional"
39     by replacing the empty string with a predefined value (default: None)."""
40     return empty if value == '' else from_str(value)
41
42
43 def opt_to_str(value: Union[T, E], to_str: Callable[[T], str], empty: E = None) -> str:
44     return '' if value == empty else to_str(value)
45
46
47 # "no" converter
48 # --------------
49
50 def no_german_from_str(value: str, from_str: Callable[[str], T], use_tuple: bool = True, no_value: N = None) \
51         -> Union[Tuple[bool, Union[T, N]], T, N]:
52     """Makes it possible to have "Nein" as special value. If use_tuple is True, a tuple is returned. The first
53     entry of the tuple is False in case the value is "Nein", otherwise the first value is True. The second value is
54     no_value in case of the value being "Nein", otherwise it is the result of from_str(value).
55     If use_tuple is False, no_value is returned in case the value is "Nein", otherwise the result of from_str(value)."""
56     if value == 'Nein':
57         return (False, no_value) if use_tuple else no_value
58     return (True, from_str(value)) if use_tuple else from_str(value)
59
60
61 def no_german_to_str(value: Union[Tuple[bool, Union[T, N]], T, N], to_str: Callable[[T], str], use_tuple: bool = True,
62                      no_value: N = None) -> str:
63     if use_tuple:
64         if not value[0]:
65             return 'Nein'
66         return to_str(value[1])
67     else:
68         if value == no_value:
69             return 'Nein'
70         return to_str(value)
71
72
73 # "optional"/"no" converter
74 # -------------------------
75
76 def opt_no_german_from_str(value: str, from_str: Callable[[str], T], use_tuple: bool = True, no_value: N = None,
77                            empty: E = (None, None)) -> Union[Tuple[bool, Union[T, N]], T, N, E]:
78     """
79     'abc' -> (True, from_str('abc')) or from_str('abc')
80     'Nein' -> (False, no_value) or no_value
81     '' -> empty
82     """
83     return opt_from_str(value, lambda v: no_german_from_str(v, from_str, use_tuple, no_value), empty)
84
85
86 def opt_no_german_to_str(value: Union[Tuple[bool, Union[T, N]], T, N, E], to_str: Callable[[T], str], use_tuple=True,
87                          no_value: N = None, empty: E = (None, None)) -> str:
88     """
89     (True, 'abc') -> to_value('abc')
90     (False, no_value) -> 'Nein'
91     empty -> ''
92     """
93     return opt_to_str(value, lambda v: no_german_to_str(v, to_str, use_tuple, no_value), empty)
94
95
96 # choice converter
97 # ----------------
98
99 def choice_from_str(value, choices):
100     """Returns the value if it is a member of the choices iterable."""
101     if value not in choices:
102         raise ValueError(f"'{value}' is an invalid value")
103     return value
104
105
106 # dictkey converter
107 # -----------------
108
109 def dictkey_from_str(value: str, key_str_dict: Dict[T, str]) -> T:
110     """Returns the key of an entry in the key_str_dict if the value of the entry corresponds to the given value."""
111     try:
112         return dict(list(zip(key_str_dict.values(), key_str_dict.keys())))[value]
113     except KeyError:
114         raise ValueError(f"Invalid value '{value}'")
115
116
117 def dictkey_to_str(value: T, key_str_dict: Dict[T, str]) -> str:
118     try:
119         return key_str_dict[value]
120     except KeyError:
121         raise ValueError(f"Invalid value '{value}'")
122
123
124 # enum/"list" converter
125 # ---------------------
126
127 def enum_from_str(value: str, from_str: Callable[[str], T], separator: str = ';', min_len: int = 0) -> List[T]:
128     """Semicolon separated list of entries with the same "type"."""
129     values = value.split(separator)
130     if len(values) == 1 and values[0] == '':
131         values = []
132     if len(values) < min_len:
133         raise ValueError(f'at least {min_len} entry/entries have to be in the enumeration')
134     return list(map(from_str, map(str.strip, values)))
135
136
137 def enum_to_str(value: List[T], to_str: Callable[[T], str], separator='; ') -> str:
138     return separator.join(map(to_str, value))
139
140
141 # value/comment converter
142 # -----------------------
143
144 def value_comment_from_str(value: str, value_from_str: Callable[[str], T], comment_from_str: Callable[[str], E],
145                            comment_optional: bool = False) -> Tuple[T, E]:
146     """Makes it possible to have a mandatory comment in parenthesis at the end of the string."""
147     comment = ''
148     if value.endswith(')'):
149         open_brackets = 0
150         for i, char in enumerate(value[::-1]):
151             if char == ')':
152                 open_brackets += 1
153             elif char == '(':
154                 open_brackets -= 1
155                 if open_brackets == 0:
156                     comment = value[-i:-1]
157                     value = value[:-i-1]
158                     if len(value) > 1 and value[-1] != ' ':
159                         raise ValueError('there has to be a space before the opening bracket of the comment')
160                     value = value[:-1]
161                     break
162         else:
163             if open_brackets > 0:
164                 raise ValueError('bracket mismatch')
165             if not comment_optional:
166                 raise ValueError('mandatory comment not found')
167     else:
168         if not comment_optional:
169             raise ValueError(f'mandatory comment not found in "{value}"')
170     return value_from_str(value), comment_from_str(comment)
171
172
173 def value_comment_to_str(value: Tuple[T, E], value_to_str: Callable[[T], str], comment_to_str: Callable[[E], str],
174                          comment_optional: bool = False) -> str:
175     left = value_to_str(value[0])
176     comment = comment_to_str(value[1])
177     if len(comment) > 0 or not comment_optional:
178         comment = f'({comment})'
179     if len(left) == 0:
180         return comment
181     if len(comment) == 0:
182         return left
183     return f'{left} {comment}'
184
185
186 # string converter
187 # ----------------
188
189 def str_from_str(value: str) -> str:
190     """Converter that takes any string and returns it as string without validation.
191     In other words, this function does nothing and just returns its argument."""
192     return value
193
194
195 def str_to_str(value: str) -> str:
196     return value
197
198
199 def req_str_from_str(value: str) -> str:
200     if value == '':
201         raise ValueError('missing required value')
202     return str_from_str(value)
203
204
205 def opt_str_from_str(value: str) -> Optional[str]:
206     return opt_from_str(value, str_from_str)
207
208
209 def opt_str_to_str(value: Optional[str]) -> str:
210     return opt_to_str(value, str_to_str)
211
212
213 opt_str_converter = FromToConverter(opt_str_from_str, opt_str_to_str)
214
215
216 # optional no or string converter
217 # -------------------------------
218
219 def opt_no_or_str_from_str(value: str) -> Tuple[Optional[bool], Optional[str]]:
220     """
221     'Nein' => (False, None); 'Nur Wochenende' => (True, 'Nur Wochenende'); 'Ja' => (True, 'Ja'); '' => (None, None)"""
222     return opt_no_german_from_str(value, req_str_from_str)
223
224
225 def opt_no_or_str_to_str(value: Tuple[Optional[bool], Optional[str]]) -> str:
226     return opt_no_german_to_str(value, str_to_str)
227
228
229 opt_no_or_str_converter = FromToConverter(opt_no_or_str_from_str, opt_no_or_str_to_str)
230
231
232 # integer converter
233 # -----------------
234
235 def int_from_str(value: str, minimum: Optional[int] = None, maximum: Optional[int] = None) -> int:
236     """Converter that takes a string representation of an integer and returns the integer.
237     :param value: string representation of an integer
238     :param minimum: If not None, the integer has to be at least min
239     :param maximum: If not None, the integer has to be no more than max
240     """
241     value = int(value)
242     if minimum is not None and value < minimum:
243         raise ValueError(f'{value} must be >= than {minimum}')
244     if maximum is not None and value > maximum:
245         raise ValueError(f'{value} must be <= than {maximum}')
246     return value
247
248
249 def int_to_str(value: int) -> str:
250     return str(value)
251
252
253 def opt_int_from_str(value: str, minimum: Optional[int] = None, maximum: Optional[int] = None) -> Optional[int]:
254     return opt_from_str(value, lambda val: int_from_str(val, minimum, maximum))
255
256
257 def opt_int_to_str(value: Optional[int]) -> str:
258     return opt_to_str(value, int_to_str)
259
260
261 def opt_uint_from_str(value: str, minimum: int = 0, maximum: Optional[int] = None) -> Optional[int]:
262     """Optional positive integer."""
263     return opt_int_from_str(value, minimum, maximum)
264
265
266 def opt_uint_to_str(value: Optional[int]) -> str:
267     return opt_int_to_str(value)
268
269
270 opt_uint_converter = FromToConverter(opt_uint_from_str, opt_uint_to_str)
271
272
273 # bool converter
274 # --------------
275
276 BOOL_GERMAN = OrderedDict([(False, 'Nein'), (True, 'Ja')])
277
278
279 def bool_german_from_str(value: str) -> bool:
280     return dictkey_from_str(value, BOOL_GERMAN)
281
282
283 def bool_german_to_str(value: bool) -> str:
284     return dictkey_to_str(value, BOOL_GERMAN)
285
286
287 def opt_bool_german_from_str(value: str) -> Optional[bool]:
288     return opt_from_str(value, bool_german_from_str)
289
290
291 def opt_bool_german_to_str(value: Optional[bool]) -> str:
292     return opt_to_str(value, bool_german_to_str)
293
294
295 opt_bool_german_converter = FromToConverter(opt_bool_german_from_str, opt_bool_german_to_str)
296
297
298 # tristate converter
299 # ------------------
300
301 TRISTATE_GERMAN = OrderedDict([(0.0, 'Nein'), (0.5, 'Teilweise'), (1.0, 'Ja')])
302
303
304 def tristate_german_from_str(value: str) -> float:
305     return dictkey_from_str(value, TRISTATE_GERMAN)
306
307
308 def tristate_german_to_str(value: float) -> str:
309     return dictkey_to_str(value, TRISTATE_GERMAN)
310
311
312 def opt_tristate_german_from_str(value: str) -> Optional[float]:
313     return opt_from_str(value, tristate_german_from_str)
314
315
316 def opt_tristate_german_to_str(value: Optional[float]) -> str:
317     return opt_to_str(value, tristate_german_to_str)
318
319
320 opt_tristate_german_converter = FromToConverter(opt_tristate_german_from_str, opt_tristate_german_to_str)
321
322
323 # tristate with comment converter
324 # -------------------------------
325
326 def opt_tristate_german_comment_from_str(value: str) -> Tuple[Optional[float], Optional[str]]:
327     """Ja, Nein or Teilweise, optionally with comment in parenthesis."""
328     return value_comment_from_str(value, opt_tristate_german_from_str, opt_str_from_str, True)
329
330
331 def opt_tristate_german_comment_to_str(value: Tuple[Optional[float], Optional[str]]) -> str:
332     return value_comment_to_str(value, opt_tristate_german_to_str, opt_str_to_str, True)
333
334
335 opt_tristate_german_comment_converter = FromToConverter(opt_tristate_german_comment_from_str,
336                                                         opt_tristate_german_comment_to_str)
337
338
339 # url converter
340 # -------------
341
342 def url_from_str(value: str) -> str:
343     result = urllib.parse.urlparse(value)
344     if result.scheme not in ['http', 'https']:
345         raise ValueError('scheme has to be http or https')
346     if not result.netloc:
347         raise ValueError('url does not contain netloc')
348     return value
349
350
351 def url_to_str(value: str) -> str:
352     return value
353
354
355 # webauskunft converter
356 # ---------------------
357
358 def webauskunft_from_str(value: str) -> Tuple[Optional[bool], Optional[str]]:
359     """Converts a URL or 'Nein' to a tuple
360     'http://www.example.com/' -> (True, 'http://www.example.com/')
361     'Nein' -> (False, None)
362     '' -> (None, None)
363
364     :param value: URL or 'Nein'
365     :return: tuple
366     """
367     return opt_no_german_from_str(value, url_from_str)
368
369
370 def webauskunft_to_str(value: Tuple[Optional[bool], Optional[str]]) -> str:
371     return opt_no_german_to_str(value, url_to_str)
372
373
374 webauskunft_converter = FromToConverter(webauskunft_from_str, webauskunft_to_str)
375
376
377 # wikipage converter
378 # ------------------
379
380 def wikipage_from_str(value: str) -> str:
381     """Validates wiki page name like '[[Birgitzer Alm]]'.
382     The page is not checked for existence.
383     An empty string is an error.
384     '[[Birgitzer Alm]]' => '[[Birgitzer Alm]]'
385     """
386     if re.match(r'\[\[[^\[\]]+]]$', value) is None:
387         raise ValueError('No valid wiki page name "{}"'.format(value))
388     return value
389
390
391 def wikipage_to_str(value: str) -> str:
392     return value
393
394
395 def opt_wikipage_enum_from_str(value: str) -> Optional[List[str]]:
396     """Validates a list of wiki pages like '[[Birgitzer Alm]]; [[Kemater Alm]]'.
397     '[[Birgitzer Alm]]; [[Kemater Alm]]' => ['[[Birgitzer Alm]]', '[[Kemater Alm]]']
398     '[[Birgitzer Alm]]'                  => ['[[Birgitzer Alm]]']
399     'Nein'                               => []
400     ''                                   => None
401     """
402     return opt_no_german_from_str(value, lambda val: enum_from_str(val, wikipage_from_str), False, [], None)
403
404
405 def opt_wikipage_enum_to_str(value: Optional[List[str]]) -> str:
406     return opt_no_german_to_str(value, lambda val: enum_to_str(val, wikipage_to_str), False, [], None)
407
408
409 opt_wikipage_enum_converter = FromToConverter(opt_wikipage_enum_from_str, opt_wikipage_enum_to_str)
410
411
412 # email converter
413 # ---------------
414
415 def email_from_str(value: str) -> str:
416     """Takes an email address like 'office@example.com', checks it for correctness and returns it again as string."""
417     try:
418         email.headerregistry.Address(addr_spec=value)
419     except HeaderParseError as e:
420         raise ValueError('Invalid email address: {}'.format(value), e)
421     return value
422
423
424 def email_to_str(value: str) -> str:
425     return str(value)
426
427
428 def masked_email_from_str(value: str, mask='(at)', masked_only=False) -> Tuple[str, bool]:
429     """Converts an email address that is possibly masked. Returns a tuple. The first parameter is the un-masked
430     email address as string, the second is a boolean telling whether the address was masked."""
431     unmasked = value.replace(mask, '@')
432     was_masked = unmasked != value
433     if masked_only and not was_masked:
434         raise ValueError('E-Mail address not masked')
435     return email_from_str(unmasked), was_masked
436
437
438 def masked_email_to_str(value: Tuple[str, bool], mask='(at)') -> str:
439     """Value is a tuple. The first entry is the email address, the second one is a boolean telling whether the
440     email address should be masked."""
441     email, do_masking = value
442     email = email_to_str(email)
443     if do_masking:
444         email = email.replace('@', mask)
445     return email
446
447
448 def emails_from_str(value: str) -> Optional[List[Tuple[str, str]]]:
449     return opt_no_german_from_str(
450         value,
451         lambda val:
452             enum_from_str(val, lambda v: value_comment_from_str(v, masked_email_from_str, opt_str_from_str, True)),
453         False, [], None)
454
455
456 def emails_to_str(value: Optional[List[Tuple[str, str]]]) -> str:
457     return opt_no_german_to_str(
458         value,
459         lambda val: enum_to_str(val, lambda v: value_comment_to_str(v, masked_email_to_str, opt_str_to_str, True)),
460         False, [], None)
461
462
463 emails_converter = FromToConverter(emails_from_str, emails_to_str)
464
465
466 # phone converter
467 # ---------------
468
469 def phone_number_from_str(value: str) -> str:
470     match = re.match(r'\+\d+(-\d+)*$', value)
471     if match is None:
472         raise ValueError('invalid format of phone number - use something like +43-699-1234567')
473     return value
474
475
476 def phone_number_to_str(value: str) -> str:
477     return value
478
479
480 def opt_phone_comment_enum_from_str(value: str, comment_optional: bool = False) -> Optional[List[Tuple[str, str]]]:
481     return opt_no_german_from_str(
482         value,
483         lambda val: enum_from_str(
484             val,
485             lambda v:
486                 value_comment_from_str(
487                     v,
488                     phone_number_from_str,
489                     opt_str_from_str if comment_optional else req_str_from_str,
490                     comment_optional
491                 )
492         ), False, [], None)
493
494
495 def opt_phone_comment_enum_to_str(value: Optional[List[Tuple[str, str]]], comment_optional: bool = False) -> str:
496     return opt_no_german_to_str(
497         value,
498         lambda val: enum_to_str(
499             val, lambda v: value_comment_to_str(
500                 v, phone_number_to_str, opt_str_to_str if comment_optional else str_to_str, comment_optional)),
501         False, [], None)
502
503
504 opt_phone_comment_enum_converter = FromToConverter(opt_phone_comment_enum_from_str, opt_phone_comment_enum_to_str)
505
506
507 opt_phone_comment_opt_enum_converter = FromToConverter(lambda value: opt_phone_comment_enum_from_str(value, True),
508                                                        lambda value: opt_phone_comment_enum_to_str(value, True))
509
510
511 # longitude/latitude converter
512 # ----------------------------
513
514 LonLat = namedtuple('LonLat', ['lon', 'lat'])
515
516
517 lonlat_none = LonLat(None, None)
518
519
520 def lonlat_from_str(value: str) -> LonLat:
521     """Converts a Winterrodeln geo string like '47.076207 N 11.453553 E' (being '<latitude> N <longitude> E'
522     to the LonLat(lon, lat) named  tuple."""
523     r = re.match(r'(\d+\.\d+) N (\d+\.\d+) E', value)
524     if r is None: raise ValueError("Coordinates '{}' have not a format like '47.076207 N 11.453553 E'".format(value))
525     return LonLat(float(r.groups()[1]), float(r.groups()[0]))
526
527
528 def lonlat_to_str(value: LonLat) -> str:
529     return '{:.6f} N {:.6f} E'.format(value.lat, value.lon)
530
531
532 def opt_lonlat_from_str(value: str) -> LonLat:
533     return opt_from_str(value, lonlat_from_str, lonlat_none)
534
535
536 def opt_lonlat_to_str(value: LonLat) -> str:
537     return opt_to_str(value, lonlat_to_str, lonlat_none)
538
539
540 opt_lonlat_converter = FromToConverter(opt_lonlat_from_str, opt_lonlat_to_str)
541
542
543 # difficulty converter
544 # --------------------
545
546 DIFFICULTY_GERMAN = OrderedDict([(1, 'leicht'), (2, 'mittel'), (3, 'schwer')])
547
548
549 def difficulty_german_from_str(value: str) -> int:
550     return dictkey_from_str(value, DIFFICULTY_GERMAN)
551
552
553 def difficulty_german_to_str(value: int) -> str:
554     return dictkey_to_str(value, DIFFICULTY_GERMAN)
555
556
557 def opt_difficulty_german_from_str(value: str) -> Optional[int]:
558     return opt_from_str(value, difficulty_german_from_str)
559
560
561 def opt_difficulty_german_to_str(value: Optional[int]) -> str:
562     return opt_to_str(value, difficulty_german_to_str)
563
564
565 opt_difficulty_german_converter = FromToConverter(opt_difficulty_german_from_str, opt_difficulty_german_to_str)
566
567
568 # avalanches converter
569 # --------------------
570
571 AVALANCHES_GERMAN = OrderedDict([(1, 'kaum'), (2, 'selten'), (3, 'gelegentlich'), (4, 'häufig')])
572
573
574 def avalanches_german_from_str(value: str) -> int:
575     return dictkey_from_str(value, AVALANCHES_GERMAN)
576
577
578 def avalanches_german_to_str(value: int) -> str:
579     return dictkey_to_str(value, AVALANCHES_GERMAN)
580
581
582 def opt_avalanches_german_from_str(value: str) -> Optional[int]:
583     return opt_from_str(value, avalanches_german_from_str)
584
585
586 def opt_avalanches_german_to_str(value: Optional[int]) -> str:
587     return opt_to_str(value, avalanches_german_to_str)
588
589
590 opt_avalanches_german_converter = FromToConverter(opt_avalanches_german_from_str, opt_avalanches_german_to_str)
591
592
593 # lift converter
594 # --------------
595
596 LIFT_GERMAN = ['Sessellift', 'Gondel', 'Linienbus', 'Taxi', 'Sonstige']
597
598
599 def lift_german_from_str(value) -> Optional[List[Tuple[str, Optional[str]]]]:
600     """Checks a lift_details property. It is a value comment property with the following
601     values allowed:
602     'Sessellift'
603     'Gondel'
604     'Linienbus'
605     'Taxi'
606     'Sonstige'
607     Alternatively, the value u'Nein' is allowed.
608     An empty string maps to (None, None).
609
610     Examples:
611     ''                                       <=> None
612     'Nein'                                   <=> []
613     'Sessellift                              <=> [('Sessellift', None)]
614     'Gondel (nur bis zur Hälfte)'            <=> [('Gondel', 'nur bis zur Hälfte')]
615     'Sessellift; Taxi'                       <=> [('Sessellift', None), ('Taxi', None)]
616     'Sessellift (Wochenende); Taxi (6 Euro)' <=> [('Sessellift', 'Wochenende'), ('Taxi', '6 Euro')]
617     """
618     return opt_no_german_from_str(
619         value,
620         lambda value_enum:
621             enum_from_str(
622                 value_enum,
623                 lambda value_comment:
624                     value_comment_from_str(
625                         value_comment,
626                         lambda v: choice_from_str(v, LIFT_GERMAN),
627                         opt_str_from_str,
628                         comment_optional=True)
629             ), use_tuple=False, no_value=[], empty=None)
630
631
632 def lift_german_to_str(value: Optional[List[Tuple[str, Optional[str]]]]) -> str:
633     return opt_no_german_to_str(
634         value,
635         lambda value_enum:
636             enum_to_str(
637                 value_enum,
638                 lambda value_comment:
639                     value_comment_to_str(
640                         value_comment,
641                         str_to_str,
642                         opt_str_to_str,
643                         comment_optional=True)
644             ),
645         use_tuple=False, no_value=[], empty=None)
646
647
648 lift_german_converter = FromToConverter(lift_german_from_str, lift_german_to_str)
649
650
651 # public transport converter
652 # --------------------------
653
654 PUBLIC_TRANSPORT_GERMAN = OrderedDict(
655     [(1, 'Sehr gut'), (2, 'Gut'), (3, 'Mittelmäßig'), (4, 'Schlecht'), (5, 'Nein'), (6, 'Ja')])
656
657
658 def public_transport_german_from_str(value: str) -> int:
659     return dictkey_from_str(value, PUBLIC_TRANSPORT_GERMAN)
660
661
662 def public_transport_german_to_str(value: int) -> str:
663     return dictkey_to_str(value, PUBLIC_TRANSPORT_GERMAN)
664
665
666 def opt_public_transport_german_from_str(value: str) -> Optional[int]:
667     return opt_from_str(value, public_transport_german_from_str)
668
669
670 def opt_public_transport_german_to_str(value: Optional[int]) -> str:
671     return opt_to_str(value, public_transport_german_to_str)
672
673
674 opt_public_transport_german_converter = FromToConverter(opt_public_transport_german_from_str,
675                                                         opt_public_transport_german_to_str)
676
677
678 # cachet converter
679 # ----------------
680
681 CACHET_REGEXP = [r'(Tiroler Naturrodelbahn-Gütesiegel) ([12]\d{3}) (leicht|mittel|schwer)$']
682
683
684 def single_cachet_german_from_str(value: str) -> Tuple[str, str, str]:
685     for pattern in CACHET_REGEXP:
686         match = re.match(pattern, value)
687         if match:
688             return match.groups()
689     raise ValueError(f"'{value}' is no valid cachet")
690
691
692 def single_cachet_german_to_str(value: Tuple[str, str, str]) -> str:
693     return ' '.join(value)
694
695
696 def cachet_german_from_str(value):
697     """Converts a "Gütesiegel":
698     '' => None
699     'Nein' => []
700     'Tiroler Naturrodelbahn-Gütesiegel 2009 mittel' => [('Tiroler Naturrodelbahn-Gütesiegel', '2009', 'mittel')]"""
701     return opt_no_german_from_str(value, lambda val: enum_from_str(val, single_cachet_german_from_str), False, [], None)
702
703
704 def cachet_german_to_str(value):
705     return opt_no_german_to_str(value, lambda val: enum_to_str(val, single_cachet_german_to_str), False, [], None)
706
707
708 cachet_german_converter = FromToConverter(cachet_german_from_str, cachet_german_to_str)
709
710
711 # night light days converter
712 # --------------------------
713
714 def nightlightdays_from_str(value: str) -> Tuple[int, Optional[str]]:
715     return value_comment_from_str(
716         value,
717         lambda val:
718             opt_from_str(val, lambda v: int_from_str(v, minimum=0, maximum=7)), opt_str_from_str, comment_optional=True)
719
720
721 def nightlightdays_to_str(value: Tuple[int, Optional[str]]) -> str:
722     return value_comment_to_str(value, lambda val: opt_to_str(val, int_to_str), opt_str_to_str, comment_optional=True)
723
724
725 nightlightdays_converter = FromToConverter(nightlightdays_from_str, nightlightdays_to_str)
726
727
728 # string with optional comment enum/list converter
729 # ------------------------------------------------
730
731 def opt_str_opt_comment_enum_from_str(value: str) -> Optional[List[Tuple[str, Optional[str]]]]:
732     """The value can be an empty string, 'Nein' or a semicolon-separated list of strings with optional comments.
733     ''                                       => None
734     'Nein'                                   => []
735     'Talstation (nur mit Ticket); Schneealm' => [('Talstation', 'nur mit Ticket'), ('Schneealm', None)]"""
736     return opt_no_german_from_str(
737         value,
738         lambda val:
739             enum_from_str(
740                 val,
741                 lambda v:
742                     value_comment_from_str(v, req_str_from_str, opt_str_from_str, True)
743             ), False, [], None)
744
745
746 def opt_str_opt_comment_enum_to_str(value: Optional[List[Tuple[str, Optional[str]]]]) -> str:
747     return opt_no_german_to_str(
748         value,
749         lambda val:
750             enum_to_str(
751                 val, lambda v:
752                     value_comment_to_str(v, str_to_str, opt_str_to_str, True)
753             ), False, [], None)
754
755
756 opt_str_opt_comment_enum_converter = FromToConverter(opt_str_opt_comment_enum_from_str, opt_str_opt_comment_enum_to_str)
757
758
759 # wikibox converter
760 # -----------------
761
762 class ValueErrorList(ValueError):
763     pass
764
765
766 def wikibox_from_template(template, converter_dict):
767     """Returns an ordered dict."""
768     result = OrderedDict()
769     exceptions_dict = OrderedDict()
770     # check values
771     for key, converter in converter_dict.items():
772         try:
773             if not template.has(key):
774                 raise ValueError('Missing parameter "{}"'.format(key))
775             result[key] = converter.from_str(str(template.get(key).value.strip()))
776         except ValueError as e:
777             exceptions_dict[key] = e
778     # check if keys are superfluous
779     superfluous_keys = {str(p.name.strip()) for p in template.params} - set(converter_dict.keys())
780     for key in superfluous_keys:
781         exceptions_dict[key] = ValueError('Superfluous parameter: "{}"'.format(key))
782     if len(exceptions_dict) > 0:
783         raise ValueErrorList('{} error(s) occurred when parsing template parameters.'.format(len(exceptions_dict)), exceptions_dict)
784     return result
785
786
787 def wikibox_to_template(value, name, converter_dict):
788     template = mwparserfromhell.nodes.template.Template(name)
789     for key, converter in converter_dict.items():
790         template.add(key, converter.to_str(value[key]))
791     return template
792
793
794 def template_from_str(value, name):
795     wikicode = mwparserfromhell.parse(value)
796     template_list = wikicode.filter_templates(recursive=False, matches=lambda t: t.name.strip() == name)
797     if len(template_list) == 0:
798         raise ValueError('No "{}" template was found'.format(name))
799     if len(template_list) > 1:
800         raise ValueError('{} "{}" templates were found'.format(len(template_list), name))
801     return template_list[0]
802
803
804 def wikibox_from_str(value, name, converter_dict):
805     template = template_from_str(value, name)
806     return wikibox_from_template(template, converter_dict)
807
808
809 def wikibox_to_str(value, name, converter_dict):
810     return str(wikibox_to_template(value, name, converter_dict))
811
812
813 # Rodelbahnbox converter
814 # ----------------------
815
816 RODELBAHNBOX_TEMPLATE_NAME = 'Rodelbahnbox'
817
818
819 RODELBAHNBOX_DICT = OrderedDict([
820     ('Position', opt_lonlat_converter),  # '47.583333 N 15.75 E'
821     ('Position oben', opt_lonlat_converter),  # '47.583333 N 15.75 E'
822     ('Höhe oben', opt_uint_converter),  # '2000'
823     ('Position unten', opt_lonlat_converter),  # '47.583333 N 15.75 E'
824     ('Höhe unten', opt_uint_converter),  # '1200'
825     ('Länge', opt_uint_converter),  # 3500
826     ('Schwierigkeit', opt_difficulty_german_converter),  # 'mittel'
827     ('Lawinen', opt_avalanches_german_converter),  # 'kaum'
828     ('Betreiber', opt_str_converter),  # 'Max Mustermann'
829     ('Öffentliche Anreise', opt_public_transport_german_converter),  # 'Mittelmäßig'
830     ('Aufstieg möglich', opt_bool_german_converter),  # 'Ja'
831     ('Aufstieg getrennt', opt_tristate_german_comment_converter),  # 'Ja'
832     ('Gehzeit', opt_uint_converter),  # 90
833     ('Aufstiegshilfe', lift_german_converter),  # 'Gondel (unterer Teil)'
834     ('Beleuchtungsanlage', opt_tristate_german_comment_converter),
835     ('Beleuchtungstage', nightlightdays_converter),  # '3 (Montag, Mittwoch, Freitag)'
836     ('Rodelverleih', opt_str_opt_comment_enum_converter),  # 'Talstation Serlesbahnan'
837     ('Gütesiegel', cachet_german_converter),  # 'Tiroler Naturrodelbahn-Gütesiegel 2009 mittel'
838     ('Webauskunft', webauskunft_converter),  # 'http://www.nösslachhütte.at/page9.php'
839     ('Telefonauskunft', opt_phone_comment_enum_converter),  # '+43-664-5487520 (Mitterer Alm)'
840     ('Bild', opt_str_converter),
841     ('In Übersichtskarte', opt_bool_german_converter),
842     ('Forumid', opt_uint_converter)
843 ])
844
845
846 def rodelbahnbox_from_template(template):
847     """Returns an ordered dict."""
848     return wikibox_from_template(template, RODELBAHNBOX_DICT)
849
850
851 def rodelbahnbox_to_template(value):
852     return wikibox_to_template(value, RODELBAHNBOX_TEMPLATE_NAME, RODELBAHNBOX_DICT)
853
854
855 def rodelbahnbox_from_str(value):
856     """Returns an ordered dict."""
857     return wikibox_from_str(value, RODELBAHNBOX_TEMPLATE_NAME, RODELBAHNBOX_DICT)
858
859
860 def rodelbahnbox_to_str(value):
861     template = rodelbahnbox_to_template(value)
862     format_template_table(template, 20)
863     return str(template)
864
865
866 # Gasthausbox converter
867 # ---------------------
868
869 GASTHAUSBOX_TEMPLATE_NAME = 'Gasthausbox'
870
871
872 GASTHAUSBOX_DICT = OrderedDict([
873     ('Position', opt_lonlat_converter), # '47.583333 N 15.75 E'
874     ('Höhe', opt_uint_converter),
875     ('Betreiber', opt_str_converter),
876     ('Sitzplätze', opt_uint_converter),
877     ('Übernachtung', opt_no_or_str_converter),
878     ('Rauchfrei', opt_tristate_german_converter),
879     ('Rodelverleih', opt_no_or_str_converter),
880     ('Handyempfang', opt_str_opt_comment_enum_converter),
881     ('Homepage', webauskunft_converter),
882     ('E-Mail', emails_converter),
883     ('Telefon', opt_phone_comment_opt_enum_converter),
884     ('Bild', opt_str_converter),
885     ('Rodelbahnen', opt_wikipage_enum_converter)])
886
887
888 def gasthausbox_from_template(template):
889     """Returns an ordered dict."""
890     return wikibox_from_template(template, GASTHAUSBOX_DICT)
891
892
893 def gasthausbox_to_template(value):
894     return wikibox_to_template(value, GASTHAUSBOX_TEMPLATE_NAME, GASTHAUSBOX_DICT)
895
896
897 def gasthausbox_from_str(value):
898     """Returns an ordered dict."""
899     return wikibox_from_str(value, GASTHAUSBOX_TEMPLATE_NAME, GASTHAUSBOX_DICT)
900
901
902 def gasthausbox_to_str(value):
903     template = gasthausbox_to_template(value)
904     format_template_table(template, 17)
905     return str(template)
906
907
908 # Helper function to make page title pretty
909 # -----------------------------------------
910
911 def sledrun_page_title_to_pretty_url(page_title):
912     """Converts a page_title from the page_title column of wrsledruncache to name_url.
913     name_url is not used by MediaWiki but by new applications like wrweb."""
914     return page_title.lower().replace(' ', '-').replace('_', '-').replace('(', '').replace(')', '')