]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/tokenizer/icu_rule_loader.py
0e6e40b4c88dc3109e5aa9fa60cb27925458454b
[nominatim.git] / nominatim / tokenizer / icu_rule_loader.py
1 """
2 Helper class to create ICU rules from a configuration file.
3 """
4 import io
5 import logging
6 import itertools
7 import re
8
9 from icu import Transliterator
10
11 from nominatim.errors import UsageError
12 import nominatim.tokenizer.icu_variants as variants
13
14 LOG = logging.getLogger()
15
16 def _flatten_config_list(content):
17     if not content:
18         return []
19
20     if not isinstance(content, list):
21         raise UsageError("List expected in ICU configuration.")
22
23     output = []
24     for ele in content:
25         if isinstance(ele, list):
26             output.extend(_flatten_config_list(ele))
27         else:
28             output.append(ele)
29
30     return output
31
32
33 class VariantRule:
34     """ Saves a single variant expansion.
35
36         An expansion consists of the normalized replacement term and
37         a dicitonary of properties that describe when the expansion applies.
38     """
39
40     def __init__(self, replacement, properties):
41         self.replacement = replacement
42         self.properties = properties or {}
43
44
45 class ICURuleLoader:
46     """ Compiler for ICU rules from a tokenizer configuration file.
47     """
48
49     def __init__(self, rules):
50         self.variants = set()
51
52         self.normalization_rules = self._cfg_to_icu_rules(rules, 'normalization')
53         self.transliteration_rules = self._cfg_to_icu_rules(rules, 'transliteration')
54         self._parse_variant_list(self._get_section(rules, 'variants'))
55
56
57     def get_search_rules(self):
58         """ Return the ICU rules to be used during search.
59             The rules combine normalization and transliteration.
60         """
61         # First apply the normalization rules.
62         rules = io.StringIO()
63         rules.write(self.normalization_rules)
64
65         # Then add transliteration.
66         rules.write(self.transliteration_rules)
67         return rules.getvalue()
68
69     def get_normalization_rules(self):
70         """ Return rules for normalisation of a term.
71         """
72         return self.normalization_rules
73
74     def get_transliteration_rules(self):
75         """ Return the rules for converting a string into its asciii representation.
76         """
77         return self.transliteration_rules
78
79     def get_replacement_pairs(self):
80         """ Return the list of possible compound decompositions with
81             application of abbreviations included.
82             The result is a list of pairs: the first item is the sequence to
83             replace, the second is a list of replacements.
84         """
85         return self.variants
86
87
88     @staticmethod
89     def _get_section(rules, section):
90         """ Get the section named 'section' from the rules. If the section does
91             not exist, raise a usage error with a meaningful message.
92         """
93         if section not in rules:
94             LOG.fatal("Section '%s' not found in tokenizer config.", section)
95             raise UsageError("Syntax error in tokenizer configuration file.")
96
97         return rules[section]
98
99
100     def _cfg_to_icu_rules(self, rules, section):
101         """ Load an ICU ruleset from the given section. If the section is a
102             simple string, it is interpreted as a file name and the rules are
103             loaded verbatim from the given file. The filename is expected to be
104             relative to the tokenizer rule file. If the section is a list then
105             each line is assumed to be a rule. All rules are concatenated and returned.
106         """
107         content = self._get_section(rules, section)
108
109         if content is None:
110             return ''
111
112         return ';'.join(_flatten_config_list(content)) + ';'
113
114
115     def _parse_variant_list(self, rules):
116         self.variants.clear()
117
118         if not rules:
119             return
120
121         rules = _flatten_config_list(rules)
122
123         vmaker = _VariantMaker(self.normalization_rules)
124
125         properties = []
126         for section in rules:
127             # Create the property field and deduplicate against existing
128             # instances.
129             props = variants.ICUVariantProperties.from_rules(section)
130             for existing in properties:
131                 if existing == props:
132                     props = existing
133                     break
134             else:
135                 properties.append(props)
136
137             for rule in (section.get('words') or []):
138                 self.variants.update(vmaker.compute(rule, props))
139
140
141 class _VariantMaker:
142     """ Generater for all necessary ICUVariants from a single variant rule.
143
144         All text in rules is normalized to make sure the variants match later.
145     """
146
147     def __init__(self, norm_rules):
148         self.norm = Transliterator.createFromRules("rule_loader_normalization",
149                                                    norm_rules)
150
151
152     def compute(self, rule, props):
153         """ Generator for all ICUVariant tuples from a single variant rule.
154         """
155         parts = re.split(r'(\|)?([=-])>', rule)
156         if len(parts) != 4:
157             raise UsageError("Syntax error in variant rule: " + rule)
158
159         decompose = parts[1] is None
160         src_terms = [self._parse_variant_word(t) for t in parts[0].split(',')]
161         repl_terms = (self.norm.transliterate(t.strip()) for t in parts[3].split(','))
162
163         # If the source should be kept, add a 1:1 replacement
164         if parts[2] == '-':
165             for src in src_terms:
166                 if src:
167                     for froms, tos in _create_variants(*src, src[0], decompose):
168                         yield variants.ICUVariant(froms, tos, props)
169
170         for src, repl in itertools.product(src_terms, repl_terms):
171             if src and repl:
172                 for froms, tos in _create_variants(*src, repl, decompose):
173                     yield variants.ICUVariant(froms, tos, props)
174
175
176     def _parse_variant_word(self, name):
177         name = name.strip()
178         match = re.fullmatch(r'([~^]?)([^~$^]*)([~$]?)', name)
179         if match is None or (match.group(1) == '~' and match.group(3) == '~'):
180             raise UsageError("Invalid variant word descriptor '{}'".format(name))
181         norm_name = self.norm.transliterate(match.group(2))
182         if not norm_name:
183             return None
184
185         return norm_name, match.group(1), match.group(3)
186
187
188 _FLAG_MATCH = {'^': '^ ',
189                '$': ' ^',
190                '': ' '}
191
192
193 def _create_variants(src, preflag, postflag, repl, decompose):
194     if preflag == '~':
195         postfix = _FLAG_MATCH[postflag]
196         # suffix decomposition
197         src = src + postfix
198         repl = repl + postfix
199
200         yield src, repl
201         yield ' ' + src, ' ' + repl
202
203         if decompose:
204             yield src, ' ' + repl
205             yield ' ' + src, repl
206     elif postflag == '~':
207         # prefix decomposition
208         prefix = _FLAG_MATCH[preflag]
209         src = prefix + src
210         repl = prefix + repl
211
212         yield src, repl
213         yield src + ' ', repl + ' '
214
215         if decompose:
216             yield src, repl + ' '
217             yield src + ' ', repl
218     else:
219         prefix = _FLAG_MATCH[preflag]
220         postfix = _FLAG_MATCH[postflag]
221
222         yield prefix + src + postfix, prefix + repl + postfix