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