]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/cli.py
487206c1562e7d796f35a30c6b1799e47cb2109d
[nominatim.git] / nominatim / cli.py
1 """
2 Command-line interface to the Nominatim functions for import, update,
3 database administration and querying.
4 """
5 import datetime as dt
6 import os
7 import sys
8 import time
9 import argparse
10 import logging
11 from pathlib import Path
12
13 from .config import Configuration
14 from .tools.exec_utils import run_legacy_script, run_api_script
15 from .db.connection import connect
16 from .db import status
17 from .errors import UsageError
18
19 LOG = logging.getLogger()
20
21 def _num_system_cpus():
22     try:
23         cpus = len(os.sched_getaffinity(0))
24     except NotImplementedError:
25         cpus = None
26
27     return cpus or os.cpu_count()
28
29
30 class CommandlineParser:
31     """ Wraps some of the common functions for parsing the command line
32         and setting up subcommands.
33     """
34     def __init__(self, prog, description):
35         self.parser = argparse.ArgumentParser(
36             prog=prog,
37             description=description,
38             formatter_class=argparse.RawDescriptionHelpFormatter)
39
40         self.subs = self.parser.add_subparsers(title='available commands',
41                                                dest='subcommand')
42
43         # Arguments added to every sub-command
44         self.default_args = argparse.ArgumentParser(add_help=False)
45         group = self.default_args.add_argument_group('Default arguments')
46         group.add_argument('-h', '--help', action='help',
47                            help='Show this help message and exit')
48         group.add_argument('-q', '--quiet', action='store_const', const=0,
49                            dest='verbose', default=1,
50                            help='Print only error messages')
51         group.add_argument('-v', '--verbose', action='count', default=1,
52                            help='Increase verboseness of output')
53         group.add_argument('--project-dir', metavar='DIR', default='.',
54                            help='Base directory of the Nominatim installation (default:.)')
55         group.add_argument('-j', '--threads', metavar='NUM', type=int,
56                            help='Number of parallel threads to use')
57
58
59     def add_subcommand(self, name, cmd):
60         """ Add a subcommand to the parser. The subcommand must be a class
61             with a function add_args() that adds the parameters for the
62             subcommand and a run() function that executes the command.
63         """
64         parser = self.subs.add_parser(name, parents=[self.default_args],
65                                       help=cmd.__doc__.split('\n', 1)[0],
66                                       description=cmd.__doc__,
67                                       formatter_class=argparse.RawDescriptionHelpFormatter,
68                                       add_help=False)
69         parser.set_defaults(command=cmd)
70         cmd.add_args(parser)
71
72     def run(self, **kwargs):
73         """ Parse the command line arguments of the program and execute the
74             appropriate subcommand.
75         """
76         args = self.parser.parse_args(args=kwargs.get('cli_args'))
77
78         if args.subcommand is None:
79             self.parser.print_help()
80             return 1
81
82         for arg in ('module_dir', 'osm2pgsql_path', 'phplib_dir', 'data_dir', 'phpcgi_path'):
83             setattr(args, arg, Path(kwargs[arg]))
84         args.project_dir = Path(args.project_dir)
85
86         logging.basicConfig(stream=sys.stderr,
87                             format='%(asctime)s: %(message)s',
88                             datefmt='%Y-%m-%d %H:%M:%S',
89                             level=max(4 - args.verbose, 1) * 10)
90
91         args.config = Configuration(args.project_dir, args.data_dir / 'settings')
92
93         try:
94             return args.command.run(args)
95         except UsageError as e:
96             log = logging.getLogger()
97             if log.isEnabledFor(logging.DEBUG):
98                 raise # use Python's exception printing
99             log.fatal('FATAL: ' + str(e))
100
101         # If we get here, then execution has failed in some way.
102         return 1
103
104
105 def _osm2pgsql_options_from_args(args, default_cache, default_threads):
106     """ Set up the stanadrd osm2pgsql from the command line arguments.
107     """
108     return dict(osm2pgsql=args.osm2pgsql_path,
109                 osm2pgsql_cache=args.osm2pgsql_cache or default_cache,
110                 osm2pgsql_style=args.config.get_import_style_file(),
111                 threads=args.threads or default_threads,
112                 dsn=args.config.get_libpq_dsn(),
113                 flatnode_file=args.config.FLATNODE_FILE)
114
115 ##### Subcommand classes
116 #
117 # Each class needs to implement two functions: add_args() adds the CLI parameters
118 # for the subfunction, run() executes the subcommand.
119 #
120 # The class documentation doubles as the help text for the command. The
121 # first line is also used in the summary when calling the program without
122 # a subcommand.
123 #
124 # No need to document the functions each time.
125 # pylint: disable=C0111
126 # Using non-top-level imports to make pyosmium optional for replication only.
127 # pylint: disable=C0415
128
129
130 class SetupAll:
131     """\
132     Create a new Nominatim database from an OSM file.
133     """
134
135     @staticmethod
136     def add_args(parser):
137         group_name = parser.add_argument_group('Required arguments')
138         group = group_name.add_mutually_exclusive_group(required=True)
139         group.add_argument('--osm-file',
140                            help='OSM file to be imported.')
141         group.add_argument('--continue', dest='continue_at',
142                            choices=['load-data', 'indexing', 'db-postprocess'],
143                            help='Continue an import that was interrupted')
144         group = parser.add_argument_group('Optional arguments')
145         group.add_argument('--osm2pgsql-cache', metavar='SIZE', type=int,
146                            help='Size of cache to be used by osm2pgsql (in MB)')
147         group.add_argument('--reverse-only', action='store_true',
148                            help='Do not create tables and indexes for searching')
149         group.add_argument('--enable-debug-statements', action='store_true',
150                            help='Include debug warning statements in SQL code')
151         group.add_argument('--no-partitions', action='store_true',
152                            help="""Do not partition search indices
153                                    (speeds up import of single country extracts)""")
154         group.add_argument('--no-updates', action='store_true',
155                            help="""Do not keep tables that are only needed for
156                                    updating the database later""")
157         group = parser.add_argument_group('Expert options')
158         group.add_argument('--ignore-errors', action='store_true',
159                            help='Continue import even when errors in SQL are present')
160         group.add_argument('--index-noanalyse', action='store_true',
161                            help='Do not perform analyse operations during index')
162
163
164     @staticmethod
165     def run(args):
166         params = ['setup.php']
167         if args.osm_file:
168             params.extend(('--all', '--osm-file', args.osm_file))
169         else:
170             if args.continue_at == 'load-data':
171                 params.append('--load-data')
172             if args.continue_at in ('load-data', 'indexing'):
173                 params.append('--index')
174             params.extend(('--create-search-indices', '--create-country-names',
175                            '--setup-website'))
176         if args.osm2pgsql_cache:
177             params.extend(('--osm2pgsql-cache', args.osm2pgsql_cache))
178         if args.reverse_only:
179             params.append('--reverse-only')
180         if args.enable_debug_statements:
181             params.append('--enable-debug-statements')
182         if args.no_partitions:
183             params.append('--no-partitions')
184         if args.no_updates:
185             params.append('--drop')
186         if args.ignore_errors:
187             params.append('--ignore-errors')
188         if args.index_noanalyse:
189             params.append('--index-noanalyse')
190
191         return run_legacy_script(*params, nominatim_env=args)
192
193
194 class SetupFreeze:
195     """\
196     Make database read-only.
197
198     About half of data in the Nominatim database is kept only to be able to
199     keep the data up-to-date with new changes made in OpenStreetMap. This
200     command drops all this data and only keeps the part needed for geocoding
201     itself.
202
203     This command has the same effect as the `--no-updates` option for imports.
204     """
205
206     @staticmethod
207     def add_args(parser):
208         pass # No options
209
210     @staticmethod
211     def run(args):
212         return run_legacy_script('setup.php', '--drop', nominatim_env=args)
213
214
215 class SetupSpecialPhrases:
216     """\
217     Maintain special phrases.
218     """
219
220     @staticmethod
221     def add_args(parser):
222         group = parser.add_argument_group('Input arguments')
223         group.add_argument('--from-wiki', action='store_true',
224                            help='Pull special phrases from the OSM wiki.')
225         group = parser.add_argument_group('Output arguments')
226         group.add_argument('-o', '--output', default='-',
227                            help="""File to write the preprocessed phrases to.
228                                    If omitted, it will be written to stdout.""")
229
230     @staticmethod
231     def run(args):
232         if args.output != '-':
233             raise NotImplementedError('Only output to stdout is currently implemented.')
234         return run_legacy_script('specialphrases.php', '--wiki-import', nominatim_env=args)
235
236
237 class UpdateReplication:
238     """\
239     Update the database using an online replication service.
240     """
241
242     @staticmethod
243     def add_args(parser):
244         group = parser.add_argument_group('Arguments for initialisation')
245         group.add_argument('--init', action='store_true',
246                            help='Initialise the update process')
247         group.add_argument('--no-update-functions', dest='update_functions',
248                            action='store_false',
249                            help="""Do not update the trigger function to
250                                    support differential updates.""")
251         group = parser.add_argument_group('Arguments for updates')
252         group.add_argument('--check-for-updates', action='store_true',
253                            help='Check if new updates are available and exit')
254         group.add_argument('--once', action='store_true',
255                            help="""Download and apply updates only once. When
256                                    not set, updates are continuously applied""")
257         group.add_argument('--no-index', action='store_false', dest='do_index',
258                            help="""Do not index the new data. Only applicable
259                                    together with --once""")
260         group.add_argument('--osm2pgsql-cache', metavar='SIZE', type=int,
261                            help='Size of cache to be used by osm2pgsql (in MB)')
262
263     @staticmethod
264     def _init_replication(args):
265         from .tools import replication, refresh
266
267         LOG.warning("Initialising replication updates")
268         conn = connect(args.config.get_libpq_dsn())
269         replication.init_replication(conn, base_url=args.config.REPLICATION_URL)
270         if args.update_functions:
271             LOG.warning("Create functions")
272             refresh.create_functions(conn, args.config, args.data_dir,
273                                      True, False)
274         conn.close()
275         return 0
276
277
278     @staticmethod
279     def _check_for_updates(args):
280         from .tools import replication
281
282         conn = connect(args.config.get_libpq_dsn())
283         ret = replication.check_for_updates(conn, base_url=args.config.REPLICATION_URL)
284         conn.close()
285         return ret
286
287
288     @staticmethod
289     def _update(args):
290         from .tools import replication
291         from .indexer.indexer import Indexer
292
293         params = _osm2pgsql_options_from_args(args, 2000, 1)
294         params.update(base_url=args.config.REPLICATION_URL,
295                       update_interval=args.config.get_int('REPLICATION_UPDATE_INTERVAL'),
296                       import_file=args.project_dir / 'osmosischange.osc',
297                       max_diff_size=args.config.get_int('REPLICATION_MAX_DIFF'),
298                       indexed_only=not args.once)
299
300         # Sanity check to not overwhelm the Geofabrik servers.
301         if 'download.geofabrik.de'in params['base_url']\
302            and params['update_interval'] < 86400:
303             LOG.fatal("Update interval too low for download.geofabrik.de.\n"
304                       "Please check install documentation "
305                       "(https://nominatim.org/release-docs/latest/admin/Import-and-Update#"
306                       "setting-up-the-update-process).")
307             raise UsageError("Invalid replication update interval setting.")
308
309         if not args.once:
310             if not args.do_index:
311                 LOG.fatal("Indexing cannot be disabled when running updates continuously.")
312                 raise UsageError("Bad argument '--no-index'.")
313             recheck_interval = args.config.get_int('REPLICATION_RECHECK_INTERVAL')
314
315         while True:
316             conn = connect(args.config.get_libpq_dsn())
317             start = dt.datetime.now(dt.timezone.utc)
318             state = replication.update(conn, params)
319             status.log_status(conn, start, 'import')
320             conn.close()
321
322             if state is not replication.UpdateState.NO_CHANGES and args.do_index:
323                 start = dt.datetime.now(dt.timezone.utc)
324                 indexer = Indexer(args.config.get_libpq_dsn(),
325                                   args.threads or 1)
326                 indexer.index_boundaries(0, 30)
327                 indexer.index_by_rank(0, 30)
328
329                 conn = connect(args.config.get_libpq_dsn())
330                 status.set_indexed(conn, True)
331                 status.log_status(conn, start, 'index')
332                 conn.close()
333
334             if args.once:
335                 break
336
337             if state is replication.UpdateState.NO_CHANGES:
338                 LOG.warning("No new changes. Sleeping for %d sec.", recheck_interval)
339                 time.sleep(recheck_interval)
340
341         return state.value
342
343     @staticmethod
344     def run(args):
345         try:
346             import osmium # pylint: disable=W0611
347         except ModuleNotFoundError:
348             LOG.fatal("pyosmium not installed. Replication functions not available.\n"
349                       "To install pyosmium via pip: pip3 install osmium")
350             return 1
351
352         if args.init:
353             return UpdateReplication._init_replication(args)
354
355         if args.check_for_updates:
356             return UpdateReplication._check_for_updates(args)
357
358         return UpdateReplication._update(args)
359
360 class UpdateAddData:
361     """\
362     Add additional data from a file or an online source.
363
364     Data is only imported, not indexed. You need to call `nominatim-update index`
365     to complete the process.
366     """
367
368     @staticmethod
369     def add_args(parser):
370         group_name = parser.add_argument_group('Source')
371         group = group_name.add_mutually_exclusive_group(required=True)
372         group.add_argument('--file', metavar='FILE',
373                            help='Import data from an OSM file')
374         group.add_argument('--diff', metavar='FILE',
375                            help='Import data from an OSM diff file')
376         group.add_argument('--node', metavar='ID', type=int,
377                            help='Import a single node from the API')
378         group.add_argument('--way', metavar='ID', type=int,
379                            help='Import a single way from the API')
380         group.add_argument('--relation', metavar='ID', type=int,
381                            help='Import a single relation from the API')
382         group.add_argument('--tiger-data', metavar='DIR',
383                            help='Add housenumbers from the US TIGER census database.')
384         group = parser.add_argument_group('Extra arguments')
385         group.add_argument('--use-main-api', action='store_true',
386                            help='Use OSM API instead of Overpass to download objects')
387
388     @staticmethod
389     def run(args):
390         if args.tiger_data:
391             os.environ['NOMINATIM_TIGER_DATA_PATH'] = args.tiger_data
392             return run_legacy_script('setup.php', '--import-tiger-data', nominatim_env=args)
393
394         params = ['update.php']
395         if args.file:
396             params.extend(('--import-file', args.file))
397         elif args.diff:
398             params.extend(('--import-diff', args.diff))
399         elif args.node:
400             params.extend(('--import-node', args.node))
401         elif args.way:
402             params.extend(('--import-way', args.way))
403         elif args.relation:
404             params.extend(('--import-relation', args.relation))
405         if args.use_main_api:
406             params.append('--use-main-api')
407         return run_legacy_script(*params, nominatim_env=args)
408
409
410 class UpdateIndex:
411     """\
412     Reindex all new and modified data.
413     """
414
415     @staticmethod
416     def add_args(parser):
417         group = parser.add_argument_group('Filter arguments')
418         group.add_argument('--boundaries-only', action='store_true',
419                            help="""Index only administrative boundaries.""")
420         group.add_argument('--no-boundaries', action='store_true',
421                            help="""Index everything except administrative boundaries.""")
422         group.add_argument('--minrank', '-r', type=int, metavar='RANK', default=0,
423                            help='Minimum/starting rank')
424         group.add_argument('--maxrank', '-R', type=int, metavar='RANK', default=30,
425                            help='Maximum/finishing rank')
426
427     @staticmethod
428     def run(args):
429         from .indexer.indexer import Indexer
430
431         indexer = Indexer(args.config.get_libpq_dsn(),
432                           args.threads or _num_system_cpus() or 1)
433
434         if not args.no_boundaries:
435             indexer.index_boundaries(args.minrank, args.maxrank)
436         if not args.boundaries_only:
437             indexer.index_by_rank(args.minrank, args.maxrank)
438
439         if not args.no_boundaries and not args.boundaries_only \
440            and args.minrank == 0 and args.maxrank == 30:
441             conn = connect(args.config.get_libpq_dsn())
442             status.set_indexed(conn, True)
443             conn.close()
444
445         return 0
446
447
448 class UpdateRefresh:
449     """\
450     Recompute auxiliary data used by the indexing process.
451
452     These functions must not be run in parallel with other update commands.
453     """
454
455     @staticmethod
456     def add_args(parser):
457         group = parser.add_argument_group('Data arguments')
458         group.add_argument('--postcodes', action='store_true',
459                            help='Update postcode centroid table')
460         group.add_argument('--word-counts', action='store_true',
461                            help='Compute frequency of full-word search terms')
462         group.add_argument('--address-levels', action='store_true',
463                            help='Reimport address level configuration')
464         group.add_argument('--functions', action='store_true',
465                            help='Update the PL/pgSQL functions in the database')
466         group.add_argument('--wiki-data', action='store_true',
467                            help='Update Wikipedia/data importance numbers.')
468         group.add_argument('--importance', action='store_true',
469                            help='Recompute place importances (expensive!)')
470         group.add_argument('--website', action='store_true',
471                            help='Refresh the directory that serves the scripts for the web API')
472         group = parser.add_argument_group('Arguments for function refresh')
473         group.add_argument('--no-diff-updates', action='store_false', dest='diffs',
474                            help='Do not enable code for propagating updates')
475         group.add_argument('--enable-debug-statements', action='store_true',
476                            help='Enable debug warning statements in functions')
477
478     @staticmethod
479     def run(args):
480         from .tools import refresh
481
482         if args.postcodes:
483             LOG.warning("Update postcodes centroid")
484             conn = connect(args.config.get_libpq_dsn())
485             refresh.update_postcodes(conn, args.data_dir)
486             conn.close()
487
488         if args.word_counts:
489             LOG.warning('Recompute frequency of full-word search terms')
490             conn = connect(args.config.get_libpq_dsn())
491             refresh.recompute_word_counts(conn, args.data_dir)
492             conn.close()
493
494         if args.address_levels:
495             cfg = Path(args.config.ADDRESS_LEVEL_CONFIG)
496             LOG.warning('Updating address levels from %s', cfg)
497             conn = connect(args.config.get_libpq_dsn())
498             refresh.load_address_levels_from_file(conn, cfg)
499             conn.close()
500
501         if args.functions:
502             LOG.warning('Create functions')
503             conn = connect(args.config.get_libpq_dsn())
504             refresh.create_functions(conn, args.config, args.data_dir,
505                                      args.diffs, args.enable_debug_statements)
506             conn.close()
507
508         if args.wiki_data:
509             run_legacy_script('setup.php', '--import-wikipedia-articles',
510                               nominatim_env=args, throw_on_fail=True)
511         # Attention: importance MUST come after wiki data import.
512         if args.importance:
513             run_legacy_script('update.php', '--recompute-importance',
514                               nominatim_env=args, throw_on_fail=True)
515         if args.website:
516             run_legacy_script('setup.php', '--setup-website',
517                               nominatim_env=args, throw_on_fail=True)
518
519         return 0
520
521
522 class AdminCheckDatabase:
523     """\
524     Check that the database is complete and operational.
525     """
526
527     @staticmethod
528     def add_args(parser):
529         pass # No options
530
531     @staticmethod
532     def run(args):
533         return run_legacy_script('check_import_finished.php', nominatim_env=args)
534
535
536 class AdminWarm:
537     """\
538     Warm database caches for search and reverse queries.
539     """
540
541     @staticmethod
542     def add_args(parser):
543         group = parser.add_argument_group('Target arguments')
544         group.add_argument('--search-only', action='store_const', dest='target',
545                            const='search',
546                            help="Only pre-warm tables for search queries")
547         group.add_argument('--reverse-only', action='store_const', dest='target',
548                            const='reverse',
549                            help="Only pre-warm tables for reverse queries")
550
551     @staticmethod
552     def run(args):
553         params = ['warm.php']
554         if args.target == 'reverse':
555             params.append('--reverse-only')
556         if args.target == 'search':
557             params.append('--search-only')
558         return run_legacy_script(*params, nominatim_env=args)
559
560
561 class QueryExport:
562     """\
563     Export addresses as CSV file from the database.
564     """
565
566     @staticmethod
567     def add_args(parser):
568         group = parser.add_argument_group('Output arguments')
569         group.add_argument('--output-type', default='street',
570                            choices=('continent', 'country', 'state', 'county',
571                                     'city', 'suburb', 'street', 'path'),
572                            help='Type of places to output (default: street)')
573         group.add_argument('--output-format',
574                            default='street;suburb;city;county;state;country',
575                            help="""Semicolon-separated list of address types
576                                    (see --output-type). Multiple ranks can be
577                                    merged into one column by simply using a
578                                    comma-separated list.""")
579         group.add_argument('--output-all-postcodes', action='store_true',
580                            help="""List all postcodes for address instead of
581                                    just the most likely one""")
582         group.add_argument('--language',
583                            help="""Preferred language for output
584                                    (use local name, if omitted)""")
585         group = parser.add_argument_group('Filter arguments')
586         group.add_argument('--restrict-to-country', metavar='COUNTRY_CODE',
587                            help='Export only objects within country')
588         group.add_argument('--restrict-to-osm-node', metavar='ID', type=int,
589                            help='Export only children of this OSM node')
590         group.add_argument('--restrict-to-osm-way', metavar='ID', type=int,
591                            help='Export only children of this OSM way')
592         group.add_argument('--restrict-to-osm-relation', metavar='ID', type=int,
593                            help='Export only children of this OSM relation')
594
595
596     @staticmethod
597     def run(args):
598         params = ['export.php',
599                   '--output-type', args.output_type,
600                   '--output-format', args.output_format]
601         if args.output_all_postcodes:
602             params.append('--output-all-postcodes')
603         if args.language:
604             params.extend(('--language', args.language))
605         if args.restrict_to_country:
606             params.extend(('--restrict-to-country', args.restrict_to_country))
607         if args.restrict_to_osm_node:
608             params.extend(('--restrict-to-osm-node', args.restrict_to_osm_node))
609         if args.restrict_to_osm_way:
610             params.extend(('--restrict-to-osm-way', args.restrict_to_osm_way))
611         if args.restrict_to_osm_relation:
612             params.extend(('--restrict-to-osm-relation', args.restrict_to_osm_relation))
613
614         return run_legacy_script(*params, nominatim_env=args)
615
616 STRUCTURED_QUERY = (
617     ('street', 'housenumber and street'),
618     ('city', 'city, town or village'),
619     ('county', 'county'),
620     ('state', 'state'),
621     ('country', 'country'),
622     ('postalcode', 'postcode')
623 )
624
625 EXTRADATA_PARAMS = (
626     ('addressdetails', 'Include a breakdown of the address into elements.'),
627     ('extratags', """Include additional information if available
628                      (e.g. wikipedia link, opening hours)."""),
629     ('namedetails', 'Include a list of alternative names.')
630 )
631
632 DETAILS_SWITCHES = (
633     ('addressdetails', 'Include a breakdown of the address into elements.'),
634     ('keywords', 'Include a list of name keywords and address keywords.'),
635     ('linkedplaces', 'Include a details of places that are linked with this one.'),
636     ('hierarchy', 'Include details of places lower in the address hierarchy.'),
637     ('group_hierarchy', 'Group the places by type.'),
638     ('polygon_geojson', 'Include geometry of result.')
639 )
640
641 def _add_api_output_arguments(parser):
642     group = parser.add_argument_group('Output arguments')
643     group.add_argument('--format', default='jsonv2',
644                        choices=['xml', 'json', 'jsonv2', 'geojson', 'geocodejson'],
645                        help='Format of result')
646     for name, desc in EXTRADATA_PARAMS:
647         group.add_argument('--' + name, action='store_true', help=desc)
648
649     group.add_argument('--lang', '--accept-language', metavar='LANGS',
650                        help='Preferred language order for presenting search results')
651     group.add_argument('--polygon-output',
652                        choices=['geojson', 'kml', 'svg', 'text'],
653                        help='Output geometry of results as a GeoJSON, KML, SVG or WKT.')
654     group.add_argument('--polygon-threshold', type=float, metavar='TOLERANCE',
655                        help="""Simplify output geometry.
656                                Parameter is difference tolerance in degrees.""")
657
658
659 class APISearch:
660     """\
661     Execute API search query.
662     """
663
664     @staticmethod
665     def add_args(parser):
666         group = parser.add_argument_group('Query arguments')
667         group.add_argument('--query',
668                            help='Free-form query string')
669         for name, desc in STRUCTURED_QUERY:
670             group.add_argument('--' + name, help='Structured query: ' + desc)
671
672         _add_api_output_arguments(parser)
673
674         group = parser.add_argument_group('Result limitation')
675         group.add_argument('--countrycodes', metavar='CC,..',
676                            help='Limit search results to one or more countries.')
677         group.add_argument('--exclude_place_ids', metavar='ID,..',
678                            help='List of search object to be excluded')
679         group.add_argument('--limit', type=int,
680                            help='Limit the number of returned results')
681         group.add_argument('--viewbox', metavar='X1,Y1,X2,Y2',
682                            help='Preferred area to find search results')
683         group.add_argument('--bounded', action='store_true',
684                            help='Strictly restrict results to viewbox area')
685
686         group = parser.add_argument_group('Other arguments')
687         group.add_argument('--no-dedupe', action='store_false', dest='dedupe',
688                            help='Do not remove duplicates from the result list')
689
690
691     @staticmethod
692     def run(args):
693         if args.query:
694             params = dict(q=args.query)
695         else:
696             params = {k : getattr(args, k) for k, _ in STRUCTURED_QUERY if getattr(args, k)}
697
698         for param, _ in EXTRADATA_PARAMS:
699             if getattr(args, param):
700                 params[param] = '1'
701         for param in ('format', 'countrycodes', 'exclude_place_ids', 'limit', 'viewbox'):
702             if getattr(args, param):
703                 params[param] = getattr(args, param)
704         if args.lang:
705             params['accept-language'] = args.lang
706         if args.polygon_output:
707             params['polygon_' + args.polygon_output] = '1'
708         if args.polygon_threshold:
709             params['polygon_threshold'] = args.polygon_threshold
710         if args.bounded:
711             params['bounded'] = '1'
712         if not args.dedupe:
713             params['dedupe'] = '0'
714
715         return run_api_script('search', args.project_dir,
716                               phpcgi_bin=args.phpcgi_path, params=params)
717
718 class APIReverse:
719     """\
720     Execute API reverse query.
721     """
722
723     @staticmethod
724     def add_args(parser):
725         group = parser.add_argument_group('Query arguments')
726         group.add_argument('--lat', type=float, required=True,
727                            help='Latitude of coordinate to look up (in WGS84)')
728         group.add_argument('--lon', type=float, required=True,
729                            help='Longitude of coordinate to look up (in WGS84)')
730         group.add_argument('--zoom', type=int,
731                            help='Level of detail required for the address')
732
733         _add_api_output_arguments(parser)
734
735
736     @staticmethod
737     def run(args):
738         params = dict(lat=args.lat, lon=args.lon)
739         if args.zoom is not None:
740             params['zoom'] = args.zoom
741
742         for param, _ in EXTRADATA_PARAMS:
743             if getattr(args, param):
744                 params[param] = '1'
745         if args.format:
746             params['format'] = args.format
747         if args.lang:
748             params['accept-language'] = args.lang
749         if args.polygon_output:
750             params['polygon_' + args.polygon_output] = '1'
751         if args.polygon_threshold:
752             params['polygon_threshold'] = args.polygon_threshold
753
754         return run_api_script('reverse', args.project_dir,
755                               phpcgi_bin=args.phpcgi_path, params=params)
756
757
758 class APILookup:
759     """\
760     Execute API reverse query.
761     """
762
763     @staticmethod
764     def add_args(parser):
765         group = parser.add_argument_group('Query arguments')
766         group.add_argument('--id', metavar='OSMID',
767                            action='append', required=True, dest='ids',
768                            help='OSM id to lookup in format <NRW><id> (may be repeated)')
769
770         _add_api_output_arguments(parser)
771
772
773     @staticmethod
774     def run(args):
775         params = dict(osm_ids=','.join(args.ids))
776
777         for param, _ in EXTRADATA_PARAMS:
778             if getattr(args, param):
779                 params[param] = '1'
780         if args.format:
781             params['format'] = args.format
782         if args.lang:
783             params['accept-language'] = args.lang
784         if args.polygon_output:
785             params['polygon_' + args.polygon_output] = '1'
786         if args.polygon_threshold:
787             params['polygon_threshold'] = args.polygon_threshold
788
789         return run_api_script('lookup', args.project_dir,
790                               phpcgi_bin=args.phpcgi_path, params=params)
791
792
793 class APIDetails:
794     """\
795     Execute API lookup query.
796     """
797
798     @staticmethod
799     def add_args(parser):
800         group = parser.add_argument_group('Query arguments')
801         objs = group.add_mutually_exclusive_group(required=True)
802         objs.add_argument('--node', '-n', type=int,
803                           help="Look up the OSM node with the given ID.")
804         objs.add_argument('--way', '-w', type=int,
805                           help="Look up the OSM way with the given ID.")
806         objs.add_argument('--relation', '-r', type=int,
807                           help="Look up the OSM relation with the given ID.")
808         objs.add_argument('--place_id', '-p', type=int,
809                           help='Database internal identifier of the OSM object to look up.')
810         group.add_argument('--class', dest='object_class',
811                            help="""Class type to disambiguated multiple entries
812                                    of the same object.""")
813
814         group = parser.add_argument_group('Output arguments')
815         for name, desc in DETAILS_SWITCHES:
816             group.add_argument('--' + name, action='store_true', help=desc)
817         group.add_argument('--lang', '--accept-language', metavar='LANGS',
818                            help='Preferred language order for presenting search results')
819
820     @staticmethod
821     def run(args):
822         if args.node:
823             params = dict(osmtype='N', osmid=args.node)
824         elif args.way:
825             params = dict(osmtype='W', osmid=args.node)
826         elif args.relation:
827             params = dict(osmtype='R', osmid=args.node)
828         else:
829             params = dict(place_id=args.place_id)
830         if args.object_class:
831             params['class'] = args.object_class
832         for name, _ in DETAILS_SWITCHES:
833             params[name] = '1' if getattr(args, name) else '0'
834
835         return run_api_script('details', args.project_dir,
836                               phpcgi_bin=args.phpcgi_path, params=params)
837
838
839 class APIStatus:
840     """\
841     Execute API status query.
842     """
843
844     @staticmethod
845     def add_args(parser):
846         group = parser.add_argument_group('API parameters')
847         group.add_argument('--format', default='text', choices=['text', 'json'],
848                            help='Format of result')
849
850     @staticmethod
851     def run(args):
852         return run_api_script('status', args.project_dir,
853                               phpcgi_bin=args.phpcgi_path,
854                               params=dict(format=args.format))
855
856
857 def nominatim(**kwargs):
858     """\
859     Command-line tools for importing, updating, administrating and
860     querying the Nominatim database.
861     """
862     parser = CommandlineParser('nominatim', nominatim.__doc__)
863
864     parser.add_subcommand('import', SetupAll)
865     parser.add_subcommand('freeze', SetupFreeze)
866     parser.add_subcommand('replication', UpdateReplication)
867
868     parser.add_subcommand('check-database', AdminCheckDatabase)
869     parser.add_subcommand('warm', AdminWarm)
870
871     parser.add_subcommand('special-phrases', SetupSpecialPhrases)
872
873     parser.add_subcommand('add-data', UpdateAddData)
874     parser.add_subcommand('index', UpdateIndex)
875     parser.add_subcommand('refresh', UpdateRefresh)
876
877     parser.add_subcommand('export', QueryExport)
878
879     if kwargs.get('phpcgi_path'):
880         parser.add_subcommand('search', APISearch)
881         parser.add_subcommand('reverse', APIReverse)
882         parser.add_subcommand('lookup', APILookup)
883         parser.add_subcommand('details', APIDetails)
884         parser.add_subcommand('status', APIStatus)
885     else:
886         parser.parser.epilog = 'php-cgi not found. Query commands not available.'
887
888     return parser.run(**kwargs)