]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/tools/special_phrases.py
Only log a warning if a wrong input is detected on the wiki while importing special...
[nominatim.git] / nominatim / tools / special_phrases.py
1 """
2     Functions to import special phrases into the database.
3 """
4 import logging
5 import os
6 from pathlib import Path
7 import re
8 import subprocess
9 import json
10 from os.path import isfile
11 from icu import Transliterator
12 from psycopg2.sql import Identifier, Literal, SQL
13 from nominatim.tools.exec_utils import get_url
14 from nominatim.errors import UsageError
15
16 LOG = logging.getLogger()
17 class SpecialPhrasesImporter():
18     # pylint: disable-msg=too-many-instance-attributes
19     # pylint: disable-msg=too-few-public-methods
20     """
21         Class handling the process of special phrases importations.
22     """
23     def __init__(self, config, phplib_dir, db_connection) -> None:
24         self.db_connection = db_connection
25         self.config = config
26         self.phplib_dir = phplib_dir
27         self.black_list, self.white_list = self._load_white_and_black_lists()
28         #Compile the regex here to increase performances.
29         self.occurence_pattern = re.compile(
30             r'\| *([^\|]+) *\|\| *([^\|]+) *\|\| *([^\|]+) *\|\| *([^\|]+) *\|\| *([\-YN])'
31         )
32         self.sanity_check_pattern = re.compile(r'^\w+$')
33         self.transliterator = Transliterator.createFromRules("special-phrases normalizer",
34                                                              self.config.TERM_NORMALIZATION)
35
36     def import_from_wiki(self, languages=None):
37         """
38             Iterate through all specified languages and
39             extract corresponding special phrases from the wiki.
40         """
41         if languages is not None and not isinstance(languages, list):
42             raise TypeError('The \'languages\' argument should be of type list.')
43
44         #Get all languages to process.
45         languages = self._load_languages() if not languages else languages
46
47         #Store pairs of class/type for further processing
48         class_type_pairs = set()
49
50         for lang in languages:
51             LOG.warning('Import phrases for lang: %s', lang)
52             wiki_page_xml_content = SpecialPhrasesImporter._get_wiki_content(lang)
53             class_type_pairs.update(self._process_xml_content(wiki_page_xml_content, lang))
54
55         self._create_place_classtype_table_and_indexes(class_type_pairs)
56         self.db_connection.commit()
57         LOG.warning('Import done.')
58
59     def _load_white_and_black_lists(self):
60         """
61             Load white and black lists from phrases-settings.json.
62         """
63         settings_path = (self.config.config_dir / 'phrase-settings.json').resolve()
64
65         if self.config.PHRASE_CONFIG:
66             settings_path = self._convert_php_settings_if_needed(self.config.PHRASE_CONFIG)
67
68         with settings_path.open("r") as json_settings:
69             settings = json.load(json_settings)
70         return settings['blackList'], settings['whiteList']
71
72     def _load_languages(self):
73         """
74             Get list of all languages from env config file
75             or default if there is no languages configured.
76             The system will extract special phrases only from all specified languages.
77         """
78         default_languages = [
79             'af', 'ar', 'br', 'ca', 'cs', 'de', 'en', 'es',
80             'et', 'eu', 'fa', 'fi', 'fr', 'gl', 'hr', 'hu',
81             'ia', 'is', 'it', 'ja', 'mk', 'nl', 'no', 'pl',
82             'ps', 'pt', 'ru', 'sk', 'sl', 'sv', 'uk', 'vi']
83         return self.config.LANGUAGES.split(',') if self.config.LANGUAGES else default_languages
84
85     @staticmethod
86     def _get_wiki_content(lang):
87         """
88             Request and return the wiki page's content
89             corresponding to special phrases for a given lang.
90             Requested URL Example :
91                 https://wiki.openstreetmap.org/wiki/Special:Export/Nominatim/Special_Phrases/EN
92         """
93         url = 'https://wiki.openstreetmap.org/wiki/Special:Export/Nominatim/Special_Phrases/' + lang.upper() # pylint: disable=line-too-long
94         return get_url(url)
95
96     def _check_sanity(self, lang, phrase_class, phrase_type):
97         """
98             Check sanity of given inputs in case somebody added garbage in the wiki.
99             If a bad class/type is detected the system will exit with an error.
100         """
101         type_matchs = self.sanity_check_pattern.findall(phrase_type)
102         class_matchs = self.sanity_check_pattern.findall(phrase_class)
103
104         if len(class_matchs) < 1 or len(type_matchs) < 1:
105             LOG.warning("Bad class/type for language %s: %s=%s. It will not be imported",
106                         lang, phrase_class, phrase_type)
107             return False
108         return True
109
110     def _process_xml_content(self, xml_content, lang):
111         """
112             Process given xml content by extracting matching patterns.
113             Matching patterns are processed there and returned in a
114             set of class/type pairs.
115         """
116         #One match will be of format [label, class, type, operator, plural]
117         matches = self.occurence_pattern.findall(xml_content)
118         #Store pairs of class/type for further processing
119         class_type_pairs = set()
120
121         for match in matches:
122             phrase_label = match[0].strip()
123             normalized_label = self.transliterator.transliterate(phrase_label)
124             phrase_class = match[1].strip()
125             phrase_type = match[2].strip()
126             phrase_operator = match[3].strip()
127             #hack around a bug where building=yes was imported with quotes into the wiki
128             phrase_type = re.sub(r'\"|&quot;', '', phrase_type)
129
130             #sanity check, in case somebody added garbage in the wiki
131             self._check_sanity(lang, phrase_class, phrase_type)
132
133             #blacklisting: disallow certain class/type combinations
134             if (
135                     phrase_class in self.black_list.keys() and
136                     phrase_type in self.black_list[phrase_class]
137             ):
138                 continue
139             #whitelisting: if class is in whitelist, allow only tags in the list
140             if (
141                     phrase_class in self.white_list.keys() and
142                     phrase_type not in self.white_list[phrase_class]
143             ):
144                 continue
145
146             #add class/type to the pairs dict
147             class_type_pairs.add((phrase_class, phrase_type))
148
149             self._process_amenity(
150                 phrase_label, normalized_label, phrase_class,
151                 phrase_type, phrase_operator
152             )
153
154         return class_type_pairs
155
156     def _process_amenity(self, phrase_label, normalized_label,
157                          phrase_class, phrase_type, phrase_operator):
158         # pylint: disable-msg=too-many-arguments
159         """
160             Add phrase lookup and corresponding class and
161             type to the word table based on the operator.
162         """
163         with self.db_connection.cursor() as db_cursor:
164             if phrase_operator == 'near':
165                 db_cursor.execute("""SELECT getorcreate_amenityoperator(
166                                   make_standard_name(%s), %s, %s, %s, 'near')""",
167                                   (phrase_label, normalized_label, phrase_class, phrase_type))
168             elif phrase_operator == 'in':
169                 db_cursor.execute("""SELECT getorcreate_amenityoperator(
170                                   make_standard_name(%s), %s, %s, %s, 'in')""",
171                                   (phrase_label, normalized_label, phrase_class, phrase_type))
172             else:
173                 db_cursor.execute("""SELECT getorcreate_amenity(
174                                   make_standard_name(%s), %s, %s, %s)""",
175                                   (phrase_label, normalized_label, phrase_class, phrase_type))
176
177
178     def _create_place_classtype_table_and_indexes(self, class_type_pairs):
179         """
180             Create table place_classtype for each given pair.
181             Also create indexes on place_id and centroid.
182         """
183         LOG.warning('Create tables and indexes...')
184
185         sql_tablespace = self.config.TABLESPACE_AUX_DATA
186         if sql_tablespace:
187             sql_tablespace = ' TABLESPACE '+sql_tablespace
188
189         with self.db_connection.cursor() as db_cursor:
190             db_cursor.execute("CREATE INDEX idx_placex_classtype ON placex (class, type)")
191
192         for pair in class_type_pairs:
193             phrase_class = pair[0]
194             phrase_type = pair[1]
195
196             #Table creation
197             self._create_place_classtype_table(sql_tablespace, phrase_class, phrase_type)
198
199             #Indexes creation
200             self._create_place_classtype_indexes(sql_tablespace, phrase_class, phrase_type)
201
202             #Grant access on read to the web user.
203             self._grant_access_to_webuser(phrase_class, phrase_type)
204
205         with self.db_connection.cursor() as db_cursor:
206             db_cursor.execute("DROP INDEX idx_placex_classtype")
207
208
209     def _create_place_classtype_table(self, sql_tablespace, phrase_class, phrase_type):
210         """
211             Create table place_classtype of the given phrase_class/phrase_type if doesn't exit.
212         """
213         table_name = 'place_classtype_{}_{}'.format(phrase_class, phrase_type)
214         with self.db_connection.cursor() as db_cursor:
215             db_cursor.execute(SQL("""
216                     CREATE TABLE IF NOT EXISTS {{}} {} 
217                     AS SELECT place_id AS place_id,st_centroid(geometry) AS centroid FROM placex 
218                     WHERE class = {{}} AND type = {{}}""".format(sql_tablespace))
219                               .format(Identifier(table_name), Literal(phrase_class),
220                                       Literal(phrase_type)))
221
222
223     def _create_place_classtype_indexes(self, sql_tablespace, phrase_class, phrase_type):
224         """
225             Create indexes on centroid and place_id for the place_classtype table.
226         """
227         index_prefix = 'idx_place_classtype_{}_{}_'.format(phrase_class, phrase_type)
228         base_table = 'place_classtype_{}_{}'.format(phrase_class, phrase_type)
229         #Index on centroid
230         if not self.db_connection.index_exists(index_prefix + 'centroid'):
231             with self.db_connection.cursor() as db_cursor:
232                 db_cursor.execute(SQL("""
233                     CREATE INDEX {{}} ON {{}} USING GIST (centroid) {}""".format(sql_tablespace))
234                                   .format(Identifier(index_prefix + 'centroid'),
235                                           Identifier(base_table)), sql_tablespace)
236
237         #Index on place_id
238         if not self.db_connection.index_exists(index_prefix + 'place_id'):
239             with self.db_connection.cursor() as db_cursor:
240                 db_cursor.execute(SQL(
241                     """CREATE INDEX {{}} ON {{}} USING btree(place_id) {}""".format(sql_tablespace))
242                                   .format(Identifier(index_prefix + 'place_id'),
243                                           Identifier(base_table)))
244
245
246     def _grant_access_to_webuser(self, phrase_class, phrase_type):
247         """
248             Grant access on read to the table place_classtype for the webuser.
249         """
250         table_name = 'place_classtype_{}_{}'.format(phrase_class, phrase_type)
251         with self.db_connection.cursor() as db_cursor:
252             db_cursor.execute(SQL("""GRANT SELECT ON {} TO {}""")
253                               .format(Identifier(table_name),
254                                       Identifier(self.config.DATABASE_WEBUSER)))
255
256     def _convert_php_settings_if_needed(self, file_path):
257         """
258             Convert php settings file of special phrases to json file if it is still in php format.
259         """
260         if not isfile(file_path):
261             raise UsageError(str(file_path) + ' is not a valid file.')
262
263         file, extension = os.path.splitext(file_path)
264         json_file_path = Path(file + '.json').resolve()
265
266         if extension not in('.php', '.json'):
267             raise UsageError('The custom NOMINATIM_PHRASE_CONFIG file has not a valid extension.')
268
269         if extension == '.php' and not isfile(json_file_path):
270             try:
271                 subprocess.run(['/usr/bin/env', 'php', '-Cq',
272                                 (self.phplib_dir / 'migration/PhraseSettingsToJson.php').resolve(),
273                                 file_path], check=True)
274                 LOG.warning('special_phrase configuration file has been converted to json.')
275                 return json_file_path
276             except subprocess.CalledProcessError:
277                 LOG.error('Error while converting %s to json.', file_path)
278                 raise
279         else:
280             return json_file_path