diff --git a/.mypy.ini b/.mypy.ini index 81a5c2e7..ee7a9ad1 100644 --- a/.mypy.ini +++ b/.mypy.ini @@ -1,9 +1,10 @@ [mypy] +plugins = sqlalchemy.ext.mypy.plugin [mypy-icu.*] ignore_missing_imports = True -[mypy-osmium.*] +[mypy-asyncpg.*] ignore_missing_imports = True [mypy-datrie.*] diff --git a/nominatim/api.py b/nominatim/api.py new file mode 100644 index 00000000..60165d33 --- /dev/null +++ b/nominatim/api.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# This file is part of Nominatim. (https://nominatim.org) +# +# Copyright (C) 2022 by the Nominatim developer community. +# For a full list of authors see the git log. +""" +Implementation of classes for API access via libraries. +""" +from typing import Mapping, Optional, TypeVar, Callable, Any +import functools +import asyncio +from pathlib import Path + +from sqlalchemy.engine.url import URL +from sqlalchemy.ext.asyncio import create_async_engine + +from nominatim.typing import StrPath +from nominatim.config import Configuration +from nominatim.apicmd.status import get_status, StatusResult + +class NominatimAPIAsync: + """ API loader asynchornous version. + """ + def __init__(self, project_dir: Path, + environ: Optional[Mapping[str, str]] = None) -> None: + self.config = Configuration(project_dir, environ) + + dsn = self.config.get_database_params() + + dburl = URL.create( + 'postgresql+asyncpg', + database=dsn.get('dbname'), + username=dsn.get('user'), password=dsn.get('password'), + host=dsn.get('host'), port=int(dsn['port']) if 'port' in dsn else None, + query={k: v for k, v in dsn.items() + if k not in ('user', 'password', 'dbname', 'host', 'port')}) + self.engine = create_async_engine(dburl, + connect_args={"server_settings": {"jit": "off"}}, + future=True) + + + async def status(self) -> StatusResult: + """ Return the status of the database. + """ + return await get_status(self.engine) + + +class NominatimAPI: + """ API loader, synchronous version. + """ + + def __init__(self, project_dir: Path, + environ: Optional[Mapping[str, str]] = None) -> None: + self.async_api = NominatimAPIAsync(project_dir, environ) + + + def status(self) -> StatusResult: + return asyncio.get_event_loop().run_until_complete(self.async_api.status()) diff --git a/nominatim/apicmd/__init__.py b/nominatim/apicmd/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nominatim/apicmd/status.py b/nominatim/apicmd/status.py new file mode 100644 index 00000000..628b6ce9 --- /dev/null +++ b/nominatim/apicmd/status.py @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# This file is part of Nominatim. (https://nominatim.org) +# +# Copyright (C) 2022 by the Nominatim developer community. +# For a full list of authors see the git log. +""" +Classes and function releated to status call. +""" +from typing import Optional, cast +import datetime as dt + +import sqlalchemy as sqla +from sqlalchemy.ext.asyncio.engine import AsyncEngine, AsyncConnection +import asyncpg + +from nominatim import version + +class StatusResult: + """ Result of a call to the status API. + """ + + def __init__(self, status: int, msg: str): + self.status = status + self.message = msg + # XXX versions really should stay tuples here + self.software_version = version.version_str() + self.data_updated: Optional[dt.datetime] = None + self.database_version: Optional[str] = None + + +async def _get_database_date(conn: AsyncConnection) -> Optional[dt.datetime]: + """ Query the database date. + """ + sql = sqla.text('SELECT lastimportdate FROM import_status LIMIT 1') + result = await conn.execute(sql) + + for row in result: + return cast(dt.datetime, row[0]) + + return None + + +async def _get_database_version(conn: AsyncConnection) -> Optional[str]: + sql = sqla.text("""SELECT value FROM nominatim_properties + WHERE property = 'database_version'""") + result = await conn.execute(sql) + + for row in result: + return cast(str, row[0]) + + return None + + +async def get_status(engine: AsyncEngine) -> StatusResult: + """ Execute a status API call. + """ + status = StatusResult(0, 'OK') + try: + async with engine.begin() as conn: + status.data_updated = await _get_database_date(conn) + status.database_version = await _get_database_version(conn) + except asyncpg.PostgresError as err: + return StatusResult(700, str(err)) + + return status diff --git a/nominatim/clicmd/api.py b/nominatim/clicmd/api.py index b899afad..9a4828b8 100644 --- a/nominatim/clicmd/api.py +++ b/nominatim/clicmd/api.py @@ -14,6 +14,9 @@ import logging from nominatim.tools.exec_utils import run_api_script from nominatim.errors import UsageError from nominatim.clicmd.args import NominatimArgs +from nominatim.api import NominatimAPI +from nominatim.apicmd.status import StatusResult +import nominatim.result_formatter.v1 as formatting # Do not repeat documentation of subcommand classes. # pylint: disable=C0111 @@ -264,7 +267,7 @@ class APIDetails: class APIStatus: - """\ + """ Execute API status query. This command works exactly the same as if calling the /status endpoint on @@ -274,10 +277,13 @@ class APIStatus: """ def add_args(self, parser: argparse.ArgumentParser) -> None: + formats = formatting.create(StatusResult).list_formats() group = parser.add_argument_group('API parameters') - group.add_argument('--format', default='text', choices=['text', 'json'], + group.add_argument('--format', default=formats[0], choices=formats, help='Format of result') def run(self, args: NominatimArgs) -> int: - return _run_api('status', args, dict(format=args.format)) + status = NominatimAPI(args.project_dir).status() + print(formatting.create(StatusResult).format(status, args.format)) + return 0 diff --git a/nominatim/config.py b/nominatim/config.py index e0f19b04..3a4c3a6b 100644 --- a/nominatim/config.py +++ b/nominatim/config.py @@ -17,6 +17,7 @@ import json import yaml from dotenv import dotenv_values +from psycopg2.extensions import parse_dsn from nominatim.typing import StrPath from nominatim.errors import UsageError @@ -51,7 +52,7 @@ class Configuration: Nominatim uses dotenv to configure the software. Configuration options are resolved in the following order: - * from the OS environment (or the dirctionary given in `environ` + * from the OS environment (or the dictionary given in `environ`) * from the .env file in the project directory of the installation * from the default installation in the configuration directory @@ -164,6 +165,18 @@ class Configuration: return dsn + def get_database_params(self) -> Mapping[str, str]: + """ Get the configured parameters for the database connection + as a mapping. + """ + dsn = self.DATABASE_DSN + + if dsn.startswith('pgsql:'): + return dict((p.split('=', 1) for p in dsn[6:].split(';'))) + + return parse_dsn(dsn) + + def get_import_style_file(self) -> Path: """ Return the import style file as a path object. Translates the name of the standard styles automatically into a file in the diff --git a/nominatim/result_formatter/__init__.py b/nominatim/result_formatter/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nominatim/result_formatter/base.py b/nominatim/result_formatter/base.py new file mode 100644 index 00000000..88f4d918 --- /dev/null +++ b/nominatim/result_formatter/base.py @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# This file is part of Nominatim. (https://nominatim.org) +# +# Copyright (C) 2022 by the Nominatim developer community. +# For a full list of authors see the git log. +""" +Helper classes and function for writing result formatting modules. +""" +from typing import Type, TypeVar, Dict, Mapping, List, Callable, Generic, Any +from collections import defaultdict + +T = TypeVar('T') # pylint: disable=invalid-name +FormatFunc = Callable[[T], str] + +class ResultFormatter(Generic[T]): + """ This class dispatches format calls to the appropriate formatting + function previously defined with the `format_func` decorator. + """ + + def __init__(self, funcs: Mapping[str, FormatFunc[T]]) -> None: + self.functions = funcs + + + def list_formats(self) -> List[str]: + """ Return a list of formats supported by this formatter. + """ + return list(self.functions.keys()) + + + def format(self, result: T, fmt: str) -> str: + """ Convert the given result into a string using the given format. + + The format is expected to be in the list returned by + `list_formats()`. + """ + return self.functions[fmt](result) + + +class FormatDispatcher: + """ A factory class for result formatters. + """ + + def __init__(self) -> None: + self.format_functions: Dict[Type[Any], Dict[str, FormatFunc[Any]]] = defaultdict(dict) + + + def format_func(self, result_class: Type[T], + fmt: str) -> Callable[[FormatFunc[T]], FormatFunc[T]]: + """ Decorator for a function that formats a given type of result into the + selected format. + """ + def decorator(func: FormatFunc[T]) -> FormatFunc[T]: + self.format_functions[result_class][fmt] = func + return func + + return decorator + + + def __call__(self, result_class: Type[T]) -> ResultFormatter[T]: + """ Create an instance of a format class for the given result type. + """ + return ResultFormatter(self.format_functions[result_class]) diff --git a/nominatim/result_formatter/v1.py b/nominatim/result_formatter/v1.py new file mode 100644 index 00000000..d14e3f6c --- /dev/null +++ b/nominatim/result_formatter/v1.py @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# This file is part of Nominatim. (https://nominatim.org) +# +# Copyright (C) 2022 by the Nominatim developer community. +# For a full list of authors see the git log. +""" +Output formatters for API version v1. +""" +from typing import Dict, Any +from collections import OrderedDict +import json + +from nominatim.result_formatter.base import FormatDispatcher +from nominatim.apicmd.status import StatusResult + +create = FormatDispatcher() + +@create.format_func(StatusResult, 'text') +def _format_status_text(result: StatusResult) -> str: + return result.message + + +@create.format_func(StatusResult, 'json') +def _format_status_json(result: StatusResult) -> str: + # XXX write a simple JSON serializer + out: Dict[str, Any] = OrderedDict() + out['status'] = result.status + out['message'] = result.message + if result.data_updated is not None: + out['data_updated'] = result.data_updated + out['software_version'] = result.software_version + if result.database_version is not None: + out['database_version'] = result.database_version + + return json.dumps(out)