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