Skip to content

Commit 968f1cd

Browse files
authored
Merge pull request #3519 from lonvia/api-error-handling
Improve error handling around CLI api commands
2 parents 8b41b80 + adce726 commit 968f1cd

9 files changed

+120
-114
lines changed

src/nominatim_api/core.py

+23-2
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ class NominatimAPIAsync: #pylint: disable=too-many-instance-attributes
3838
This class shares most of the functions with its synchronous
3939
version. There are some additional functions or parameters,
4040
which are documented below.
41+
42+
This class should usually be used as a context manager in 'with' context.
4143
"""
4244
def __init__(self, project_dir: Path,
4345
environ: Optional[Mapping[str, str]] = None,
@@ -166,6 +168,14 @@ async def close(self) -> None:
166168
await self._engine.dispose()
167169

168170

171+
async def __aenter__(self) -> 'NominatimAPIAsync':
172+
return self
173+
174+
175+
async def __aexit__(self, *_: Any) -> None:
176+
await self.close()
177+
178+
169179
@contextlib.asynccontextmanager
170180
async def begin(self) -> AsyncIterator[SearchConnection]:
171181
""" Create a new connection with automatic transaction handling.
@@ -351,6 +361,8 @@ class NominatimAPI:
351361
""" This class provides a thin synchronous wrapper around the asynchronous
352362
Nominatim functions. It creates its own event loop and runs each
353363
synchronous function call to completion using that loop.
364+
365+
This class should usually be used as a context manager in 'with' context.
354366
"""
355367

356368
def __init__(self, project_dir: Path,
@@ -376,8 +388,17 @@ def close(self) -> None:
376388
This function also closes the asynchronous worker loop making
377389
the NominatimAPI object unusable.
378390
"""
379-
self._loop.run_until_complete(self._async_api.close())
380-
self._loop.close()
391+
if not self._loop.is_closed():
392+
self._loop.run_until_complete(self._async_api.close())
393+
self._loop.close()
394+
395+
396+
def __enter__(self) -> 'NominatimAPI':
397+
return self
398+
399+
400+
def __exit__(self, *_: Any) -> None:
401+
self.close()
381402

382403

383404
@property

src/nominatim_db/clicmd/api.py

+67-49
Original file line numberDiff line numberDiff line change
@@ -180,29 +180,32 @@ def run(self, args: NominatimArgs) -> int:
180180
raise UsageError(f"Unsupported format '{args.format}'. "
181181
'Use --list-formats to see supported formats.')
182182

183-
api = napi.NominatimAPI(args.project_dir)
184-
params: Dict[str, Any] = {'max_results': args.limit + min(args.limit, 10),
185-
'address_details': True, # needed for display name
186-
'geometry_output': _get_geometry_output(args),
187-
'geometry_simplification': args.polygon_threshold,
188-
'countries': args.countrycodes,
189-
'excluded': args.exclude_place_ids,
190-
'viewbox': args.viewbox,
191-
'bounded_viewbox': args.bounded,
192-
'locales': _get_locales(args, api.config.DEFAULT_LANGUAGE)
193-
}
194-
195-
if args.query:
196-
results = api.search(args.query, **params)
197-
else:
198-
results = api.search_address(amenity=args.amenity,
199-
street=args.street,
200-
city=args.city,
201-
county=args.county,
202-
state=args.state,
203-
postalcode=args.postalcode,
204-
country=args.country,
205-
**params)
183+
try:
184+
with napi.NominatimAPI(args.project_dir) as api:
185+
params: Dict[str, Any] = {'max_results': args.limit + min(args.limit, 10),
186+
'address_details': True, # needed for display name
187+
'geometry_output': _get_geometry_output(args),
188+
'geometry_simplification': args.polygon_threshold,
189+
'countries': args.countrycodes,
190+
'excluded': args.exclude_place_ids,
191+
'viewbox': args.viewbox,
192+
'bounded_viewbox': args.bounded,
193+
'locales': _get_locales(args, api.config.DEFAULT_LANGUAGE)
194+
}
195+
196+
if args.query:
197+
results = api.search(args.query, **params)
198+
else:
199+
results = api.search_address(amenity=args.amenity,
200+
street=args.street,
201+
city=args.city,
202+
county=args.county,
203+
state=args.state,
204+
postalcode=args.postalcode,
205+
country=args.country,
206+
**params)
207+
except napi.UsageError as ex:
208+
raise UsageError(ex) from ex
206209

207210
if args.dedupe and len(results) > 1:
208211
results = deduplicate_results(results, args.limit)
@@ -260,14 +263,19 @@ def run(self, args: NominatimArgs) -> int:
260263
if args.lat is None or args.lon is None:
261264
raise UsageError("lat' and 'lon' parameters are required.")
262265

263-
api = napi.NominatimAPI(args.project_dir)
264-
result = api.reverse(napi.Point(args.lon, args.lat),
265-
max_rank=zoom_to_rank(args.zoom or 18),
266-
layers=_get_layers(args, napi.DataLayer.ADDRESS | napi.DataLayer.POI),
267-
address_details=True, # needed for display name
268-
geometry_output=_get_geometry_output(args),
269-
geometry_simplification=args.polygon_threshold,
270-
locales=_get_locales(args, api.config.DEFAULT_LANGUAGE))
266+
layers = _get_layers(args, napi.DataLayer.ADDRESS | napi.DataLayer.POI)
267+
268+
try:
269+
with napi.NominatimAPI(args.project_dir) as api:
270+
result = api.reverse(napi.Point(args.lon, args.lat),
271+
max_rank=zoom_to_rank(args.zoom or 18),
272+
layers=layers,
273+
address_details=True, # needed for display name
274+
geometry_output=_get_geometry_output(args),
275+
geometry_simplification=args.polygon_threshold,
276+
locales=_get_locales(args, api.config.DEFAULT_LANGUAGE))
277+
except napi.UsageError as ex:
278+
raise UsageError(ex) from ex
271279

272280
if args.format == 'debug':
273281
print(loglib.get_and_disable())
@@ -323,12 +331,15 @@ def run(self, args: NominatimArgs) -> int:
323331

324332
places = [napi.OsmID(o[0], int(o[1:])) for o in args.ids]
325333

326-
api = napi.NominatimAPI(args.project_dir)
327-
results = api.lookup(places,
328-
address_details=True, # needed for display name
329-
geometry_output=_get_geometry_output(args),
330-
geometry_simplification=args.polygon_threshold or 0.0,
331-
locales=_get_locales(args, api.config.DEFAULT_LANGUAGE))
334+
try:
335+
with napi.NominatimAPI(args.project_dir) as api:
336+
results = api.lookup(places,
337+
address_details=True, # needed for display name
338+
geometry_output=_get_geometry_output(args),
339+
geometry_simplification=args.polygon_threshold or 0.0,
340+
locales=_get_locales(args, api.config.DEFAULT_LANGUAGE))
341+
except napi.UsageError as ex:
342+
raise UsageError(ex) from ex
332343

333344
if args.format == 'debug':
334345
print(loglib.get_and_disable())
@@ -410,17 +421,20 @@ def run(self, args: NominatimArgs) -> int:
410421
raise UsageError('One of the arguments --node/-n --way/-w '
411422
'--relation/-r --place_id/-p is required/')
412423

413-
api = napi.NominatimAPI(args.project_dir)
414-
locales = _get_locales(args, api.config.DEFAULT_LANGUAGE)
415-
result = api.details(place,
416-
address_details=args.addressdetails,
417-
linked_places=args.linkedplaces,
418-
parented_places=args.hierarchy,
419-
keywords=args.keywords,
420-
geometry_output=napi.GeometryFormat.GEOJSON
421-
if args.polygon_geojson
422-
else napi.GeometryFormat.NONE,
423-
locales=locales)
424+
try:
425+
with napi.NominatimAPI(args.project_dir) as api:
426+
locales = _get_locales(args, api.config.DEFAULT_LANGUAGE)
427+
result = api.details(place,
428+
address_details=args.addressdetails,
429+
linked_places=args.linkedplaces,
430+
parented_places=args.hierarchy,
431+
keywords=args.keywords,
432+
geometry_output=napi.GeometryFormat.GEOJSON
433+
if args.polygon_geojson
434+
else napi.GeometryFormat.NONE,
435+
locales=locales)
436+
except napi.UsageError as ex:
437+
raise UsageError(ex) from ex
424438

425439
if args.format == 'debug':
426440
print(loglib.get_and_disable())
@@ -465,7 +479,11 @@ def run(self, args: NominatimArgs) -> int:
465479
raise UsageError(f"Unsupported format '{args.format}'. "
466480
'Use --list-formats to see supported formats.')
467481

468-
status = napi.NominatimAPI(args.project_dir).status()
482+
try:
483+
with napi.NominatimAPI(args.project_dir) as api:
484+
status = api.status()
485+
except napi.UsageError as ex:
486+
raise UsageError(ex) from ex
469487

470488
if args.format == 'debug':
471489
print(loglib.get_and_disable())

test/python/api/conftest.py

+7
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"""
1010
from pathlib import Path
1111
import pytest
12+
import pytest_asyncio
1213
import time
1314
import datetime as dt
1415

@@ -244,3 +245,9 @@ def mkapi(apiobj, options=None):
244245

245246
for api in testapis:
246247
api.close()
248+
249+
250+
@pytest_asyncio.fixture
251+
async def api(temp_db):
252+
async with napi.NominatimAPIAsync(Path('/invalid')) as api:
253+
yield api

test/python/api/search/test_icu_query_analyzer.py

+3-4
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,9 @@ async def conn(table_factory):
4040
table_factory('word',
4141
definition='word_id INT, word_token TEXT, type TEXT, word TEXT, info JSONB')
4242

43-
api = NominatimAPIAsync(Path('/invalid'), {})
44-
async with api.begin() as conn:
45-
yield conn
46-
await api.close()
43+
async with NominatimAPIAsync(Path('/invalid'), {}) as api:
44+
async with api.begin() as conn:
45+
yield conn
4746

4847

4948
@pytest.mark.asyncio

test/python/api/search/test_legacy_query_analyzer.py

+3-4
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,9 @@ class TEXT, type TEXT, country_code TEXT,
7474
temp_db_cursor.execute("""CREATE OR REPLACE FUNCTION make_standard_name(name TEXT)
7575
RETURNS TEXT AS $$ SELECT lower(name); $$ LANGUAGE SQL;""")
7676

77-
api = NominatimAPIAsync(Path('/invalid'), {})
78-
async with api.begin() as conn:
79-
yield conn
80-
await api.close()
77+
async with NominatimAPIAsync(Path('/invalid'), {}) as api:
78+
async with api.begin() as conn:
79+
yield conn
8180

8281

8382
@pytest.mark.asyncio

test/python/api/search/test_query_analyzer_factory.py

+3-11
Original file line numberDiff line numberDiff line change
@@ -11,47 +11,39 @@
1111

1212
import pytest
1313

14-
from nominatim_api import NominatimAPIAsync
1514
from nominatim_api.search.query_analyzer_factory import make_query_analyzer
1615
from nominatim_api.search.icu_tokenizer import ICUQueryAnalyzer
1716

1817
@pytest.mark.asyncio
19-
async def test_import_icu_tokenizer(table_factory):
18+
async def test_import_icu_tokenizer(table_factory, api):
2019
table_factory('nominatim_properties',
2120
definition='property TEXT, value TEXT',
2221
content=(('tokenizer', 'icu'),
2322
('tokenizer_import_normalisation', ':: lower();'),
2423
('tokenizer_import_transliteration', "'1' > '/1/'; 'ä' > 'ä '")))
2524

26-
api = NominatimAPIAsync(Path('/invalid'), {})
2725
async with api.begin() as conn:
2826
ana = await make_query_analyzer(conn)
2927

3028
assert isinstance(ana, ICUQueryAnalyzer)
31-
await api.close()
3229

3330

3431
@pytest.mark.asyncio
35-
async def test_import_missing_property(table_factory):
36-
api = NominatimAPIAsync(Path('/invalid'), {})
32+
async def test_import_missing_property(table_factory, api):
3733
table_factory('nominatim_properties',
3834
definition='property TEXT, value TEXT')
3935

4036
async with api.begin() as conn:
4137
with pytest.raises(ValueError, match='Property.*not found'):
4238
await make_query_analyzer(conn)
43-
await api.close()
4439

4540

4641
@pytest.mark.asyncio
47-
async def test_import_missing_module(table_factory):
48-
api = NominatimAPIAsync(Path('/invalid'), {})
42+
async def test_import_missing_module(table_factory, api):
4943
table_factory('nominatim_properties',
5044
definition='property TEXT, value TEXT',
5145
content=(('tokenizer', 'missing'),))
5246

5347
async with api.begin() as conn:
5448
with pytest.raises(RuntimeError, match='Tokenizer not found'):
5549
await make_query_analyzer(conn)
56-
await api.close()
57-

test/python/api/test_api_connection.py

+14-25
Original file line numberDiff line numberDiff line change
@@ -9,45 +9,34 @@
99
"""
1010
from pathlib import Path
1111
import pytest
12-
import pytest_asyncio
1312

1413
import sqlalchemy as sa
1514

16-
from nominatim_api import NominatimAPIAsync
17-
18-
@pytest_asyncio.fixture
19-
async def apiobj(temp_db):
20-
""" Create an asynchronous SQLAlchemy engine for the test DB.
21-
"""
22-
api = NominatimAPIAsync(Path('/invalid'), {})
23-
yield api
24-
await api.close()
25-
2615

2716
@pytest.mark.asyncio
28-
async def test_run_scalar(apiobj, table_factory):
17+
async def test_run_scalar(api, table_factory):
2918
table_factory('foo', definition='that TEXT', content=(('a', ),))
3019

31-
async with apiobj.begin() as conn:
20+
async with api.begin() as conn:
3221
assert await conn.scalar(sa.text('SELECT * FROM foo')) == 'a'
3322

3423

3524
@pytest.mark.asyncio
36-
async def test_run_execute(apiobj, table_factory):
25+
async def test_run_execute(api, table_factory):
3726
table_factory('foo', definition='that TEXT', content=(('a', ),))
3827

39-
async with apiobj.begin() as conn:
28+
async with api.begin() as conn:
4029
result = await conn.execute(sa.text('SELECT * FROM foo'))
4130
assert result.fetchone()[0] == 'a'
4231

4332

4433
@pytest.mark.asyncio
45-
async def test_get_property_existing_cached(apiobj, table_factory):
34+
async def test_get_property_existing_cached(api, table_factory):
4635
table_factory('nominatim_properties',
4736
definition='property TEXT, value TEXT',
4837
content=(('dbv', '96723'), ))
4938

50-
async with apiobj.begin() as conn:
39+
async with api.begin() as conn:
5140
assert await conn.get_property('dbv') == '96723'
5241

5342
await conn.execute(sa.text('TRUNCATE nominatim_properties'))
@@ -56,12 +45,12 @@ async def test_get_property_existing_cached(apiobj, table_factory):
5645

5746

5847
@pytest.mark.asyncio
59-
async def test_get_property_existing_uncached(apiobj, table_factory):
48+
async def test_get_property_existing_uncached(api, table_factory):
6049
table_factory('nominatim_properties',
6150
definition='property TEXT, value TEXT',
6251
content=(('dbv', '96723'), ))
6352

64-
async with apiobj.begin() as conn:
53+
async with api.begin() as conn:
6554
assert await conn.get_property('dbv') == '96723'
6655

6756
await conn.execute(sa.text("UPDATE nominatim_properties SET value = '1'"))
@@ -71,23 +60,23 @@ async def test_get_property_existing_uncached(apiobj, table_factory):
7160

7261
@pytest.mark.asyncio
7362
@pytest.mark.parametrize('param', ['foo', 'DB:server_version'])
74-
async def test_get_property_missing(apiobj, table_factory, param):
63+
async def test_get_property_missing(api, table_factory, param):
7564
table_factory('nominatim_properties',
7665
definition='property TEXT, value TEXT')
7766

78-
async with apiobj.begin() as conn:
67+
async with api.begin() as conn:
7968
with pytest.raises(ValueError):
8069
await conn.get_property(param)
8170

8271

8372
@pytest.mark.asyncio
84-
async def test_get_db_property_existing(apiobj):
85-
async with apiobj.begin() as conn:
73+
async def test_get_db_property_existing(api):
74+
async with api.begin() as conn:
8675
assert await conn.get_db_property('server_version') > 0
8776

8877

8978
@pytest.mark.asyncio
90-
async def test_get_db_property_existing(apiobj):
91-
async with apiobj.begin() as conn:
79+
async def test_get_db_property_existing(api):
80+
async with api.begin() as conn:
9281
with pytest.raises(ValueError):
9382
await conn.get_db_property('dfkgjd.rijg')

0 commit comments

Comments
 (0)