From bdfbe5172128012f082d08c373e05bd8a0780502 Mon Sep 17 00:00:00 2001 From: Martijn van Exel Date: Mon, 5 Jan 2026 13:35:52 -0700 Subject: [PATCH 1/9] Add modernization docs and shared transport with expanded tests --- docs/modernization-plan.md | 31 +++++++++++++ docs/modernization-tasks.md | 13 ++++++ overpass/api.py | 15 +++++-- overpass/transport.py | 33 ++++++++++++++ tests/test_api.py | 90 ++++++++++++++++++++++++++++++++++++- 5 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 docs/modernization-plan.md create mode 100644 docs/modernization-tasks.md create mode 100644 overpass/transport.py diff --git a/docs/modernization-plan.md b/docs/modernization-plan.md new file mode 100644 index 0000000000..9ac90938dd --- /dev/null +++ b/docs/modernization-plan.md @@ -0,0 +1,31 @@ +# 0.8 Modernization Plan + +## Goals +- Keep sync `overpass.API` stable while adding a parallel async client. +- Introduce opt-in Pydantic models (v2) for responses and helpers for GeoJSON output. +- Improve test coverage to avoid regressions across all response formats and errors. + +## Non-goals (for 0.8 alpha) +- Breaking changes to default return types. +- Full rewrite of the API surface. + +## Design overview +- Split transport from parsing to make sync/async share code. +- Add a typed response layer: + - Pydantic models for Overpass JSON and GeoJSON. + - Dataclasses for configuration and small internal structures. +- Provide helpers on models (`to_geojson()`, `__geo_interface__`) while keeping + existing dict return behavior by default. + +## Testing strategy +- Unit tests for: + - Query construction (`MapQuery`, `WayQuery`, `build`, `verbosity`, `date`). + - Response parsing for CSV/XML/JSON/GeoJSON. + - Error mapping for HTTP status codes (400/429/504/other). + - Overpass status endpoint parsing. +- Async parity tests for the same responses using mocked HTTP. +- Integration tests remain opt-in (`RUN_NETWORK_TESTS=1`). + +## Open questions +- Final API for opting into models (flag vs responseformat). +- Whether to expose `AsyncAPI` in `overpass.__init__`. diff --git a/docs/modernization-tasks.md b/docs/modernization-tasks.md new file mode 100644 index 0000000000..855936ce3c --- /dev/null +++ b/docs/modernization-tasks.md @@ -0,0 +1,13 @@ +# 0.8 Modernization Tasks + +## Plan +- [ ] Add transport abstraction shared by sync/async clients +- [ ] Introduce `AsyncAPI` with httpx +- [ ] Add Pydantic response models (opt-in) +- [ ] Add GeoJSON helpers on models (`to_geojson`, `__geo_interface__`) +- [ ] Expand test coverage for all response formats and error handling +- [ ] Update docs for async usage and model opt-in + +## Tracking +- [ ] Close #181 GeoJSON hardening (relations/multipolygons/routes/boundaries) +- [ ] Address open bugs: #172, #176 diff --git a/overpass/api.py b/overpass/api.py index a5fe645bb3..a900c0521a 100644 --- a/overpass/api.py +++ b/overpass/api.py @@ -23,6 +23,7 @@ TimeoutError, UnknownOverpassError, ) +from .transport import RequestsTransport class API(object): @@ -36,6 +37,7 @@ class API(object): :param debug: Boolean to turn on debugging output :param proxies: Dictionary of proxies to pass to the request library. See requests documentation for details. + :param transport: Optional transport instance for HTTP requests. """ SUPPORTED_FORMATS = ["geojson", "json", "xml", "csv"] @@ -56,6 +58,7 @@ def __init__(self, *args, **kwargs): self.timeout = kwargs.get("timeout", self._timeout) self.debug = kwargs.get("debug", self._debug) self.proxies = kwargs.get("proxies", self._proxies) + self.transport = kwargs.get("transport") or RequestsTransport() self._status = None if self.debug: @@ -132,14 +135,18 @@ def get(self, query, responseformat="geojson", verbosity="body", build=True, dat # construct geojson return json2geojson(response) - @staticmethod - def _api_status() -> dict: + def _api_status(self) -> dict: """ :returns: dict describing the client's status with the API """ endpoint = "https://overpass-api.de/api/status" - r = requests.get(endpoint) + r = self.transport.get( + endpoint, + timeout=None, + proxies=self.proxies, + headers=self.headers, + ) lines = tuple(r.text.splitlines()) available_re = re.compile(r'\d(?= slots? available)') @@ -261,7 +268,7 @@ def _get_from_overpass(self, query): payload = {"data": query} try: - r = requests.post( + r = self.transport.post( self.endpoint, data=payload, timeout=self.timeout, diff --git a/overpass/transport.py b/overpass/transport.py new file mode 100644 index 0000000000..a2eb233bef --- /dev/null +++ b/overpass/transport.py @@ -0,0 +1,33 @@ +# Copyright 2015-2018 Martijn van Exel. +# This file is part of the overpass-api-python-wrapper project +# which is licensed under Apache 2.0. +# See LICENSE.txt for the full license text. + +from __future__ import annotations + +from typing import Any, Optional + +import requests + + +class RequestsTransport: + def get( + self, + url: str, + *, + timeout: Optional[float], + proxies: Optional[dict], + headers: Optional[dict], + ) -> requests.Response: + return requests.get(url, timeout=timeout, proxies=proxies, headers=headers) + + def post( + self, + url: str, + *, + data: dict[str, Any], + timeout: Optional[float], + proxies: Optional[dict], + headers: Optional[dict], + ) -> requests.Response: + return requests.post(url, data=data, timeout=timeout, proxies=proxies, headers=headers) diff --git a/tests/test_api.py b/tests/test_api.py index a7df7c7121..492c79adf0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -10,14 +10,19 @@ from typing import Tuple, Union import geojson -import os import pytest from deepdiff import DeepDiff import overpass +from overpass.errors import ( + MultipleRequestsError, + OverpassSyntaxError, + ServerLoadError, + UnknownOverpassError, +) -USE_LIVE_API = bool(os.getenv("USE_LIVE_API", "false")) +USE_LIVE_API = os.getenv("USE_LIVE_API", "").lower() == "true" def test_initialize_api(): @@ -54,6 +59,44 @@ def test_geojson( assert len(osm_geo["features"]) > length +def test_json_response(requests_mock): + api = overpass.API() + mock_response = {"elements": [{"id": 1, "type": "node"}]} + requests_mock.post( + "https://overpass-api.de/api/interpreter", + json=mock_response, + headers={"content-type": "application/json"}, + ) + response = api.get("node(1)", responseformat="json") + assert response == mock_response + + +def test_csv_response(requests_mock): + api = overpass.API() + csv_body = "name\t@lon\t@lat\nSpringfield\t-3.0\t56.2\n" + requests_mock.post( + "https://overpass-api.de/api/interpreter", + text=csv_body, + headers={"content-type": "text/csv"}, + ) + response = api.get( + 'node["name"="Springfield"]["place"]', responseformat="csv(name,::lon,::lat)" + ) + assert response == [["name", "@lon", "@lat"], ["Springfield", "-3.0", "56.2"]] + + +def test_xml_response(requests_mock): + api = overpass.API() + xml_body = "" + requests_mock.post( + "https://overpass-api.de/api/interpreter", + text=xml_body, + headers={"content-type": "application/osm3s+xml"}, + ) + response = api.get("node(1)", responseformat="xml") + assert response == xml_body + + @pytest.mark.integration def test_multipolygon(): """ @@ -92,6 +135,28 @@ def test_geojson_extended(verbosity, response, output, requests_mock): assert osm_geo == ref_geo +def test_invalid_overpass_response_raises(requests_mock): + api = overpass.API() + requests_mock.post( + "https://overpass-api.de/api/interpreter", + json={"foo": "bar"}, + headers={"content-type": "application/json"}, + ) + with pytest.raises(UnknownOverpassError): + api.get("node(1)") + + +def test_invalid_overpass_response_build_false(requests_mock): + api = overpass.API() + response = {"foo": "bar"} + requests_mock.post( + "https://overpass-api.de/api/interpreter", + json=response, + headers={"content-type": "application/json"}, + ) + assert api.get("node(1)", build=False) == response + + # You can also comment the pytest decorator to run the test against the live API @pytest.mark.skipif( not USE_LIVE_API, reason="USE_LIVE_API environment variable not set to True" @@ -219,3 +284,24 @@ def test_api_status( assert api.slot_available_datetime is None or isinstance( api.slot_available_datetime, datetime ) + + +@pytest.mark.parametrize( + "status_code,exception", + [ + (400, OverpassSyntaxError), + (429, MultipleRequestsError), + (504, ServerLoadError), + (500, UnknownOverpassError), + ], +) +def test_http_errors(status_code, exception, requests_mock): + api = overpass.API() + requests_mock.post( + "https://overpass-api.de/api/interpreter", + status_code=status_code, + text="error", + headers={"content-type": "text/plain"}, + ) + with pytest.raises(exception): + api.get("node(1)") From cefbc2ae8e001fca4974ee24c6310dd9c8ce6523 Mon Sep 17 00:00:00 2001 From: Martijn van Exel Date: Mon, 5 Jan 2026 13:36:07 -0700 Subject: [PATCH 2/9] Mark transport and test coverage tasks complete --- docs/modernization-tasks.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/modernization-tasks.md b/docs/modernization-tasks.md index 855936ce3c..1ef23bc017 100644 --- a/docs/modernization-tasks.md +++ b/docs/modernization-tasks.md @@ -1,11 +1,11 @@ # 0.8 Modernization Tasks ## Plan -- [ ] Add transport abstraction shared by sync/async clients +- [x] Add transport abstraction shared by sync/async clients - [ ] Introduce `AsyncAPI` with httpx - [ ] Add Pydantic response models (opt-in) - [ ] Add GeoJSON helpers on models (`to_geojson`, `__geo_interface__`) -- [ ] Expand test coverage for all response formats and error handling +- [x] Expand test coverage for all response formats and error handling - [ ] Update docs for async usage and model opt-in ## Tracking From 76ed1cb4fcbc1d59465a7f549661f6ebd32883f7 Mon Sep 17 00:00:00 2001 From: Martijn van Exel Date: Mon, 5 Jan 2026 13:40:16 -0700 Subject: [PATCH 3/9] Add AsyncAPI with httpx and async tests --- docs/modernization-tasks.md | 2 +- overpass/__init__.py | 1 + overpass/async_api.py | 231 ++++++++++++++++++++++++++++++++++++ overpass/transport.py | 39 ++++++ pyproject.toml | 2 + tests/test_async_api.py | 124 +++++++++++++++++++ uv.lock | 76 ++++++++++++ 7 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 overpass/async_api.py create mode 100644 tests/test_async_api.py diff --git a/docs/modernization-tasks.md b/docs/modernization-tasks.md index 1ef23bc017..9950662976 100644 --- a/docs/modernization-tasks.md +++ b/docs/modernization-tasks.md @@ -2,7 +2,7 @@ ## Plan - [x] Add transport abstraction shared by sync/async clients -- [ ] Introduce `AsyncAPI` with httpx +- [x] Introduce `AsyncAPI` with httpx - [ ] Add Pydantic response models (opt-in) - [ ] Add GeoJSON helpers on models (`to_geojson`, `__geo_interface__`) - [x] Expand test coverage for all response formats and error handling diff --git a/overpass/__init__.py b/overpass/__init__.py index d28d1cc085..e0f62b5099 100644 --- a/overpass/__init__.py +++ b/overpass/__init__.py @@ -12,6 +12,7 @@ __license__ = "Apache 2.0" from .api import API +from .async_api import AsyncAPI from .queries import MapQuery, WayQuery from .errors import ( OverpassError, diff --git a/overpass/async_api.py b/overpass/async_api.py new file mode 100644 index 0000000000..1dcaf1fc9d --- /dev/null +++ b/overpass/async_api.py @@ -0,0 +1,231 @@ +# Copyright 2015-2018 Martijn van Exel. +# This file is part of the overpass-api-python-wrapper project +# which is licensed under Apache 2.0. +# See LICENSE.txt for the full license text. + +import csv +import json +import logging +import re +from datetime import datetime, timezone +from io import StringIO +from math import ceil +from typing import Optional + +import httpx +from osm2geojson import json2geojson + +from .errors import ( + MultipleRequestsError, + OverpassSyntaxError, + ServerLoadError, + ServerRuntimeError, + TimeoutError, + UnknownOverpassError, +) +from .transport import HttpxAsyncTransport + + +class AsyncAPI: + """Async wrapper for the OpenStreetMap Overpass API. + + :param timeout: If a single number, the TCP connection timeout for the request. If a tuple + of two numbers, the connection timeout and the read timeout respectively. + Timeouts can be integers or floats. + :param endpoint: URL of overpass interpreter + :param headers: HTTP headers to include when making requests to the overpass endpoint + :param debug: Boolean to turn on debugging output + :param proxies: Dictionary of proxies to pass to the request library. + :param transport: Optional async transport instance for HTTP requests. + """ + + SUPPORTED_FORMATS = ["geojson", "json", "xml", "csv"] + + _timeout = 25 # second + _endpoint = "https://overpass-api.de/api/interpreter" + _headers = {"Accept-Charset": "utf-8;q=0.7,*;q=0.7"} + _debug = False + _proxies = None + + _QUERY_TEMPLATE = "[out:{out}]{date};{query}out {verbosity};" + _GEOJSON_QUERY_TEMPLATE = "[out:json]{date};{query}out {verbosity};" + + def __init__(self, *args, **kwargs): + self.endpoint = kwargs.get("endpoint", self._endpoint) + self.headers = kwargs.get("headers", self._headers) + self.timeout = kwargs.get("timeout", self._timeout) + self.debug = kwargs.get("debug", self._debug) + self.proxies = kwargs.get("proxies", self._proxies) + self.transport = kwargs.get("transport") or HttpxAsyncTransport( + proxies=self.proxies, headers=self.headers + ) + self._status = None + + if self.debug: + logging.basicConfig() + logging.getLogger().setLevel(logging.DEBUG) + + async def __aenter__(self) -> "AsyncAPI": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + await self.aclose() + + async def aclose(self) -> None: + if hasattr(self.transport, "aclose"): + await self.transport.aclose() + + async def get(self, query, responseformat="geojson", verbosity="body", build=True, date=""): + """Pass in an Overpass query in Overpass QL (async).""" + if date and isinstance(date, str): + try: + date = datetime.fromisoformat(date) + except ValueError: + date = datetime.strptime(date, "%Y-%m-%dT%H:%M:%SZ") + + if build: + full_query = self._construct_ql_query( + query, responseformat=responseformat, verbosity=verbosity, date=date + ) + else: + full_query = query + if self.debug: + logging.getLogger().info(query) + + r = await self._get_from_overpass(full_query) + content_type = r.headers.get("content-type") + + if self.debug: + print(content_type) + if content_type == "text/csv": + return list(csv.reader(StringIO(r.text), delimiter="\t")) + elif content_type in ("text/xml", "application/xml", "application/osm3s+xml"): + return r.text + else: + response = json.loads(r.text) + + if not build: + return response + + if "elements" not in response: + raise UnknownOverpassError("Received an invalid answer from Overpass.") + + overpass_remark = response.get("remark", None) + if overpass_remark and overpass_remark.startswith("runtime error"): + raise ServerRuntimeError(overpass_remark) + + if responseformat != "geojson": + return response + + return json2geojson(response) + + async def _api_status(self) -> dict: + endpoint = "https://overpass-api.de/api/status" + + r = await self.transport.get( + endpoint, + timeout=None, + proxies=self.proxies, + headers=self.headers, + ) + lines = tuple(r.text.splitlines()) + + available_re = re.compile(r"\d(?= slots? available)") + available_slots = int( + next((m.group() for line in lines if (m := available_re.search(line))), 0) + ) + + waiting_re = re.compile(r"(?<=Slot available after: )[\d\-TZ:]{20}") + waiting_slots = tuple( + datetime.strptime(m.group(), "%Y-%m-%dT%H:%M:%S%z") + for line in lines + if (m := waiting_re.search(line)) + ) + + current_idx = next( + i for i, word in enumerate(lines) if word.startswith("Currently running queries") + ) + running_slots = tuple(tuple(line.split()) for line in lines[current_idx + 1 :]) + running_slots_datetimes = tuple( + datetime.strptime(slot[3], "%Y-%m-%dT%H:%M:%S%z") for slot in running_slots + ) + + return { + "available_slots": available_slots, + "waiting_slots": waiting_slots, + "running_slots": running_slots_datetimes, + } + + async def slots_available(self) -> int: + return (await self._api_status())["available_slots"] + + async def slots_waiting(self) -> tuple: + return (await self._api_status())["waiting_slots"] + + async def slots_running(self) -> tuple: + return (await self._api_status())["running_slots"] + + async def slot_available_datetime(self) -> Optional[datetime]: + if await self.slots_available(): + return None + return min((await self.slots_running()) + (await self.slots_waiting())) + + async def slot_available_countdown(self) -> int: + try: + return max( + ceil((await self.slot_available_datetime() - datetime.now(timezone.utc)).total_seconds()), + 0, + ) + except TypeError: + return 0 + + def _construct_ql_query(self, userquery, responseformat, verbosity, date): + raw_query = str(userquery).rstrip() + if not raw_query.endswith(";"): + raw_query += ";" + + if date: + date = f'[date:"{date:%Y-%m-%dT%H:%M:%SZ}"]' + + if responseformat == "geojson": + template = self._GEOJSON_QUERY_TEMPLATE + complete_query = template.format(query=raw_query, verbosity=verbosity, date=date) + else: + template = self._QUERY_TEMPLATE + complete_query = template.format( + query=raw_query, out=responseformat, verbosity=verbosity, date=date + ) + + if self.debug: + print(complete_query) + return complete_query + + async def _get_from_overpass(self, query): + payload = {"data": query} + + try: + r = await self.transport.post( + self.endpoint, + data=payload, + timeout=self.timeout, + proxies=self.proxies, + headers=self.headers, + ) + except httpx.TimeoutException: + raise TimeoutError(self._timeout) + + self._status = r.status_code + + if self._status != 200: + if self._status == 400: + raise OverpassSyntaxError(query) + elif self._status == 429: + raise MultipleRequestsError() + elif self._status == 504: + raise ServerLoadError(self._timeout) + raise UnknownOverpassError( + "The request returned status code {code}".format(code=self._status) + ) + else: + r.encoding = "utf-8" + return r diff --git a/overpass/transport.py b/overpass/transport.py index a2eb233bef..673ed88250 100644 --- a/overpass/transport.py +++ b/overpass/transport.py @@ -8,6 +8,7 @@ from typing import Any, Optional import requests +import httpx class RequestsTransport: @@ -31,3 +32,41 @@ def post( headers: Optional[dict], ) -> requests.Response: return requests.post(url, data=data, timeout=timeout, proxies=proxies, headers=headers) + + +class HttpxAsyncTransport: + def __init__( + self, + client: Optional[httpx.AsyncClient] = None, + *, + proxies: Optional[dict] = None, + headers: Optional[dict] = None, + ) -> None: + if client is None: + self._client = httpx.AsyncClient(proxies=proxies, headers=headers) + else: + self._client = client + + async def get( + self, + url: str, + *, + timeout: Optional[float], + proxies: Optional[dict], + headers: Optional[dict], + ) -> httpx.Response: + return await self._client.get(url, timeout=timeout, headers=headers) + + async def post( + self, + url: str, + *, + data: dict[str, Any], + timeout: Optional[float], + proxies: Optional[dict], + headers: Optional[dict], + ) -> httpx.Response: + return await self._client.post(url, data=data, timeout=timeout, headers=headers) + + async def aclose(self) -> None: + await self._client.aclose() diff --git a/pyproject.toml b/pyproject.toml index 1b89ee1283..7979b2e1c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ dependencies = [ "osm2geojson>=0.2.5", "requests>=2.32.3", + "httpx>=0.27.0", ] [project.urls] @@ -31,6 +32,7 @@ dev = [ "geojson>=3.1.0", "requests-mock>=1.12.1", "deepdiff>=7.0.1", + "pytest-asyncio>=0.23.0", "ruff>=0.6.0", "ty>=0.0.9", ] diff --git a/tests/test_async_api.py b/tests/test_async_api.py new file mode 100644 index 0000000000..67ced91f92 --- /dev/null +++ b/tests/test_async_api.py @@ -0,0 +1,124 @@ +# Copyright 2015-2018 Martijn van Exel. +# This file is part of the overpass-api-python-wrapper project +# which is licensed under Apache 2.0. +# See LICENSE.txt for the full license text. + +from datetime import datetime, timezone +from pathlib import Path + +import httpx +import pytest + +from overpass.async_api import AsyncAPI +from overpass.errors import ( + MultipleRequestsError, + OverpassSyntaxError, + ServerLoadError, + UnknownOverpassError, +) +from overpass.transport import HttpxAsyncTransport + + +def _transport_for(handler): + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + return HttpxAsyncTransport(client=client) + + +@pytest.mark.asyncio +async def test_async_json_response(): + async def handler(request): + return httpx.Response( + 200, + json={"elements": [{"id": 1, "type": "node"}]}, + headers={"content-type": "application/json"}, + ) + + async with AsyncAPI(transport=_transport_for(handler)) as api: + response = await api.get("node(1)", responseformat="json") + assert response["elements"][0]["id"] == 1 + + +@pytest.mark.asyncio +async def test_async_csv_response(): + async def handler(request): + return httpx.Response( + 200, + text="name\t@lon\t@lat\nSpringfield\t-3.0\t56.2\n", + headers={"content-type": "text/csv"}, + ) + + async with AsyncAPI(transport=_transport_for(handler)) as api: + response = await api.get( + 'node["name"="Springfield"]["place"]', responseformat="csv(name,::lon,::lat)" + ) + assert response == [["name", "@lon", "@lat"], ["Springfield", "-3.0", "56.2"]] + + +@pytest.mark.asyncio +async def test_async_xml_response(): + async def handler(request): + return httpx.Response( + 200, + text="", + headers={"content-type": "application/osm3s+xml"}, + ) + + async with AsyncAPI(transport=_transport_for(handler)) as api: + response = await api.get("node(1)", responseformat="xml") + assert response.startswith("") + + +@pytest.mark.asyncio +async def test_async_invalid_response_raises(): + async def handler(request): + return httpx.Response( + 200, + json={"foo": "bar"}, + headers={"content-type": "application/json"}, + ) + + async with AsyncAPI(transport=_transport_for(handler)) as api: + with pytest.raises(UnknownOverpassError): + await api.get("node(1)") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status_code,exception", + [ + (400, OverpassSyntaxError), + (429, MultipleRequestsError), + (504, ServerLoadError), + (500, UnknownOverpassError), + ], +) +async def test_async_http_errors(status_code, exception): + async def handler(request): + return httpx.Response(status_code, text="error", headers={"content-type": "text/plain"}) + + async with AsyncAPI(transport=_transport_for(handler)) as api: + with pytest.raises(exception): + await api.get("node(1)") + + +@pytest.mark.asyncio +async def test_async_api_status(): + status_text = Path("tests/overpass_status/no_slots_waiting.txt").read_text() + + async def handler(request): + if request.url.path.endswith("/api/status"): + return httpx.Response(200, text=status_text) + return httpx.Response(200, json={"elements": []}) + + async with AsyncAPI(transport=_transport_for(handler)) as api: + slots_available = await api.slots_available() + slots_running = await api.slots_running() + slots_waiting = await api.slots_waiting() + countdown = await api.slot_available_countdown() + slot_dt = await api.slot_available_datetime() + + assert slots_available == 2 + assert slots_running == () + assert slots_waiting == () + assert isinstance(countdown, int) + assert slot_dt is None or isinstance(slot_dt, datetime) diff --git a/uv.lock b/uv.lock index e9e57f13bc..91d198bfc5 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,19 @@ version = 1 revision = 3 requires-python = ">=3.11, <3.14" +[[package]] +name = "anyio" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -98,6 +111,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/a7fa2d650602731c90e0a86279841b4586e14228199e8c09165ba4863e29/geojson-3.2.0-py3-none-any.whl", hash = "sha256:69d14156469e13c79479672eafae7b37e2dcd19bdfd77b53f74fa8fe29910b52", size = 15040, upload-time = "2024-12-21T19:37:02.149Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -201,6 +251,7 @@ name = "overpass" version = "0.7.2" source = { editable = "." } dependencies = [ + { name = "httpx" }, { name = "osm2geojson" }, { name = "requests" }, ] @@ -210,6 +261,7 @@ dev = [ { name = "deepdiff" }, { name = "geojson" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "requests-mock" }, { name = "ruff" }, { name = "ty" }, @@ -217,6 +269,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "httpx", specifier = ">=0.27.0" }, { name = "osm2geojson", specifier = ">=0.2.5" }, { name = "requests", specifier = ">=2.32.3" }, ] @@ -226,6 +279,7 @@ dev = [ { name = "deepdiff", specifier = ">=7.0.1" }, { name = "geojson", specifier = ">=3.1.0" }, { name = "pytest", specifier = ">=7.4.0" }, + { name = "pytest-asyncio", specifier = ">=0.23.0" }, { name = "requests-mock", specifier = ">=1.12.1" }, { name = "ruff", specifier = ">=0.6.0" }, { name = "ty", specifier = ">=0.0.9" }, @@ -274,6 +328,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -395,6 +462,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/8f/abc75c4bb774b12698629f02d0d12501b0a7dff9c31dc3bd6b6c6467e90a/ty-0.0.9-py3-none-win_arm64.whl", hash = "sha256:48e339d794542afeed710ea4f846ead865cc38cecc335a9c781804d02eaa2722", size = 9543127, upload-time = "2026-01-05T12:24:11.731Z" }, ] +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + [[package]] name = "urllib3" version = "2.6.2" From 72bee6f37ec0398f51b86f37f49d81cad74fc686 Mon Sep 17 00:00:00 2001 From: Martijn van Exel Date: Mon, 5 Jan 2026 13:47:20 -0700 Subject: [PATCH 4/9] Add type hints and harden JSON parsing --- .gitignore | 1 + overpass/api.py | 9 +++++++-- overpass/async_api.py | 7 ++++++- overpass/errors.py | 10 +++++----- overpass/queries.py | 8 ++++---- overpass/utils.py | 2 +- tests/test_api.py | 11 +++++++++++ tests/test_async_api.py | 14 ++++++++++++++ 8 files changed, 49 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 666caf2883..9bf49a3fd1 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ venv/ .venv/ .tox .ruff_cache/ +docs/Overpass API_Overpass QL - OpenStreetMap Wiki.pdf diff --git a/overpass/api.py b/overpass/api.py index a900c0521a..038847f4f9 100644 --- a/overpass/api.py +++ b/overpass/api.py @@ -10,7 +10,7 @@ from datetime import datetime, timezone from io import StringIO from math import ceil -from typing import Optional +from typing import Any, Optional import requests from osm2geojson import json2geojson @@ -114,7 +114,12 @@ def get(self, query, responseformat="geojson", verbosity="body", build=True, dat elif content_type in ("text/xml", "application/xml", "application/osm3s+xml"): return r.text else: - response = json.loads(r.text) + try: + response = json.loads(r.text) + except json.JSONDecodeError as exc: + raise UnknownOverpassError( + "Received a non-JSON response when JSON was expected." + ) from exc if not build: return response diff --git a/overpass/async_api.py b/overpass/async_api.py index 1dcaf1fc9d..382481fe08 100644 --- a/overpass/async_api.py +++ b/overpass/async_api.py @@ -102,7 +102,12 @@ async def get(self, query, responseformat="geojson", verbosity="body", build=Tru elif content_type in ("text/xml", "application/xml", "application/osm3s+xml"): return r.text else: - response = json.loads(r.text) + try: + response = json.loads(r.text) + except json.JSONDecodeError as exc: + raise UnknownOverpassError( + "Received a non-JSON response when JSON was expected." + ) from exc if not build: return response diff --git a/overpass/errors.py b/overpass/errors.py index 4dbc16c47f..440da1b017 100644 --- a/overpass/errors.py +++ b/overpass/errors.py @@ -13,14 +13,14 @@ class OverpassError(Exception): class OverpassSyntaxError(OverpassError, ValueError): """The request contains a syntax error.""" - def __init__(self, request): + def __init__(self, request: str) -> None: self.request = request class TimeoutError(OverpassError): """A request timeout occurred.""" - def __init__(self, timeout): + def __init__(self, timeout: int | float) -> None: self.timeout = timeout @@ -33,19 +33,19 @@ class ServerLoadError(OverpassError): """The Overpass server is currently under load and declined the request. Try again later or retry with reduced timeout value.""" - def __init__(self, timeout): + def __init__(self, timeout: int | float) -> None: self.timeout = timeout class UnknownOverpassError(OverpassError): """An unknown kind of error happened during the request.""" - def __init__(self, message): + def __init__(self, message: str) -> None: self.message = message class ServerRuntimeError(OverpassError): """The Overpass server returned a runtime error""" - def __init__(self, message): + def __init__(self, message: str) -> None: self.message = message diff --git a/overpass/queries.py b/overpass/queries.py index 6bee8e0f9f..37c51b7665 100644 --- a/overpass/queries.py +++ b/overpass/queries.py @@ -11,7 +11,7 @@ class MapQuery(object): _QUERY_TEMPLATE = "(node({south},{west},{north},{east});<;>;);" - def __init__(self, south, west, north, east): + def __init__(self, south: float, west: float, north: float, east: float) -> None: """ Initialize query with given bounding box. @@ -23,7 +23,7 @@ def __init__(self, south, west, north, east): self.east = east self.north = north - def __str__(self): + def __str__(self) -> str: return self._QUERY_TEMPLATE.format( west=self.west, south=self.south, east=self.east, north=self.north ) @@ -34,12 +34,12 @@ class WayQuery(object): _QUERY_TEMPLATE = "(way{query_parameters});(._;>;);" - def __init__(self, query_parameters): + def __init__(self, query_parameters: str) -> None: """Initialize a query for a set of ways satisfying the given parameters. :param query_parameters Overpass QL query parameters """ self.query_parameters = query_parameters - def __str__(self): + def __str__(self) -> str: return self._QUERY_TEMPLATE.format(query_parameters=self.query_parameters) diff --git a/overpass/utils.py b/overpass/utils.py index 3cc1e5d72e..0de98be9c4 100644 --- a/overpass/utils.py +++ b/overpass/utils.py @@ -7,7 +7,7 @@ class Utils(object): @staticmethod - def to_overpass_id(osmid, area=False): + def to_overpass_id(osmid: int, area: bool = False) -> int: area_base = 2400000000 relation_base = 3600000000 if area: diff --git a/tests/test_api.py b/tests/test_api.py index 492c79adf0..b3f26f24ad 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -146,6 +146,17 @@ def test_invalid_overpass_response_raises(requests_mock): api.get("node(1)") +def test_invalid_json_response_raises(requests_mock): + api = overpass.API() + requests_mock.post( + "https://overpass-api.de/api/interpreter", + text="not json", + headers={"content-type": "application/json"}, + ) + with pytest.raises(UnknownOverpassError): + api.get("node(1)", responseformat="json") + + def test_invalid_overpass_response_build_false(requests_mock): api = overpass.API() response = {"foo": "bar"} diff --git a/tests/test_async_api.py b/tests/test_async_api.py index 67ced91f92..dbb64f0c3a 100644 --- a/tests/test_async_api.py +++ b/tests/test_async_api.py @@ -82,6 +82,20 @@ async def handler(request): await api.get("node(1)") +@pytest.mark.asyncio +async def test_async_invalid_json_response_raises(): + async def handler(request): + return httpx.Response( + 200, + text="not json", + headers={"content-type": "application/json"}, + ) + + async with AsyncAPI(transport=_transport_for(handler)) as api: + with pytest.raises(UnknownOverpassError): + await api.get("node(1)", responseformat="json") + + @pytest.mark.asyncio @pytest.mark.parametrize( "status_code,exception", From c6b4af80dddf2a6c2b0118dacd60bbd77e6e2741 Mon Sep 17 00:00:00 2001 From: Martijn van Exel Date: Mon, 5 Jan 2026 13:51:25 -0700 Subject: [PATCH 5/9] Update to_overpass_id to require source type --- overpass/utils.py | 15 ++++++++++++--- tests/test_api.py | 8 ++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/overpass/utils.py b/overpass/utils.py index 0de98be9c4..d872cc0cef 100644 --- a/overpass/utils.py +++ b/overpass/utils.py @@ -7,9 +7,18 @@ class Utils(object): @staticmethod - def to_overpass_id(osmid: int, area: bool = False) -> int: + def to_overpass_id(osmid: int, source: str = "relation") -> int: + """Return the derived area id for a way or relation. + + Note: area ids are derived and not all ways/relations have a valid area. + Prefer Overpass QL constructs like map_to_area/is_in when possible. + """ area_base = 2400000000 relation_base = 3600000000 - if area: + + if source == "way": return int(osmid) + area_base - return int(osmid) + relation_base + if source == "relation": + return int(osmid) + relation_base + + raise ValueError("source must be 'way' or 'relation'") diff --git a/tests/test_api.py b/tests/test_api.py index b3f26f24ad..8b8e20842b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -15,6 +15,7 @@ from deepdiff import DeepDiff import overpass +from overpass.utils import Utils from overpass.errors import ( MultipleRequestsError, OverpassSyntaxError, @@ -316,3 +317,10 @@ def test_http_errors(status_code, exception, requests_mock): ) with pytest.raises(exception): api.get("node(1)") + + +def test_to_overpass_id(): + assert Utils.to_overpass_id(123, source="way") == 2400000123 + assert Utils.to_overpass_id(123, source="relation") == 3600000123 + with pytest.raises(ValueError): + Utils.to_overpass_id(123, source="node") From b88af654d2f7e68b915c850372900aa8c519481f Mon Sep 17 00:00:00 2001 From: Martijn van Exel Date: Mon, 5 Jan 2026 13:57:25 -0700 Subject: [PATCH 6/9] Add opt-in Pydantic models and GeoJSON helpers --- docs/modernization-tasks.md | 4 +- overpass/__init__.py | 1 + overpass/api.py | 23 +++++++- overpass/async_api.py | 22 +++++++- overpass/models.py | 59 +++++++++++++++++++ pyproject.toml | 2 + tests/test_api.py | 7 +++ tests/test_async_api.py | 19 +++++++ uv.lock | 109 ++++++++++++++++++++++++++++++++++++ 9 files changed, 239 insertions(+), 7 deletions(-) create mode 100644 overpass/models.py diff --git a/docs/modernization-tasks.md b/docs/modernization-tasks.md index 9950662976..6c5cdf4352 100644 --- a/docs/modernization-tasks.md +++ b/docs/modernization-tasks.md @@ -3,8 +3,8 @@ ## Plan - [x] Add transport abstraction shared by sync/async clients - [x] Introduce `AsyncAPI` with httpx -- [ ] Add Pydantic response models (opt-in) -- [ ] Add GeoJSON helpers on models (`to_geojson`, `__geo_interface__`) +- [x] Add Pydantic response models (opt-in) +- [x] Add GeoJSON helpers on models (`to_geojson`, `__geo_interface__`) - [x] Expand test coverage for all response formats and error handling - [ ] Update docs for async usage and model opt-in diff --git a/overpass/__init__.py b/overpass/__init__.py index e0f62b5099..79816e922a 100644 --- a/overpass/__init__.py +++ b/overpass/__init__.py @@ -13,6 +13,7 @@ from .api import API from .async_api import AsyncAPI +from .models import GeoJSONFeatureCollection, OverpassResponse from .queries import MapQuery, WayQuery from .errors import ( OverpassError, diff --git a/overpass/api.py b/overpass/api.py index 038847f4f9..83e105993f 100644 --- a/overpass/api.py +++ b/overpass/api.py @@ -73,7 +73,15 @@ def __init__(self, *args, **kwargs): requests_log.setLevel(logging.DEBUG) requests_log.propagate = True - def get(self, query, responseformat="geojson", verbosity="body", build=True, date=''): + def get( + self, + query, + responseformat="geojson", + verbosity="body", + build=True, + date="", + model: bool = False, + ): """Pass in an Overpass query in Overpass QL. :param query: the Overpass QL query to send to the endpoint @@ -135,10 +143,19 @@ def get(self, query, responseformat="geojson", verbosity="body", build=True, dat raise ServerRuntimeError(overpass_remark) if responseformat != "geojson": + if model: + from .models import OverpassResponse + + return OverpassResponse.model_validate(response) return response - # construct geojson - return json2geojson(response) + geojson_response = json2geojson(response) + if not model: + return geojson_response + + from .models import GeoJSONFeatureCollection + + return GeoJSONFeatureCollection.model_validate(geojson_response) def _api_status(self) -> dict: """ diff --git a/overpass/async_api.py b/overpass/async_api.py index 382481fe08..f20853652b 100644 --- a/overpass/async_api.py +++ b/overpass/async_api.py @@ -75,7 +75,15 @@ async def aclose(self) -> None: if hasattr(self.transport, "aclose"): await self.transport.aclose() - async def get(self, query, responseformat="geojson", verbosity="body", build=True, date=""): + async def get( + self, + query, + responseformat="geojson", + verbosity="body", + build=True, + date="", + model: bool = False, + ): """Pass in an Overpass query in Overpass QL (async).""" if date and isinstance(date, str): try: @@ -120,9 +128,19 @@ async def get(self, query, responseformat="geojson", verbosity="body", build=Tru raise ServerRuntimeError(overpass_remark) if responseformat != "geojson": + if model: + from .models import OverpassResponse + + return OverpassResponse.model_validate(response) return response - return json2geojson(response) + geojson_response = json2geojson(response) + if not model: + return geojson_response + + from .models import GeoJSONFeatureCollection + + return GeoJSONFeatureCollection.model_validate(geojson_response) async def _api_status(self) -> dict: endpoint = "https://overpass-api.de/api/status" diff --git a/overpass/models.py b/overpass/models.py new file mode 100644 index 0000000000..157848fd6d --- /dev/null +++ b/overpass/models.py @@ -0,0 +1,59 @@ +# Copyright 2015-2018 Martijn van Exel. +# This file is part of the overpass-api-python-wrapper project +# which is licensed under Apache 2.0. +# See LICENSE.txt for the full license text. + +from __future__ import annotations + +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field + + +class GeoJSONGeometry(BaseModel): + type: str + coordinates: Any + + +class GeoJSONFeature(BaseModel): + type: Literal["Feature"] = "Feature" + id: Optional[Any] = None + properties: dict[str, Any] = Field(default_factory=dict) + geometry: Optional[GeoJSONGeometry] = None + + def to_geojson(self) -> str: + return self.model_dump_json() + + @property + def __geo_interface__(self) -> dict[str, Any]: + return self.model_dump() + + +class GeoJSONFeatureCollection(BaseModel): + type: Literal["FeatureCollection"] = "FeatureCollection" + features: list[GeoJSONFeature] = Field(default_factory=list) + + def to_geojson(self) -> str: + return self.model_dump_json() + + @property + def __geo_interface__(self) -> dict[str, Any]: + return self.model_dump() + + +class OverpassElement(BaseModel): + type: str + id: int + tags: Optional[dict[str, Any]] = None + lat: Optional[float] = None + lon: Optional[float] = None + nodes: Optional[list[int]] = None + members: Optional[list[dict[str, Any]]] = None + + +class OverpassResponse(BaseModel): + elements: list[OverpassElement] = Field(default_factory=list) + version: Optional[float] = None + generator: Optional[str] = None + osm3s: Optional[dict[str, Any]] = None + remark: Optional[str] = None diff --git a/pyproject.toml b/pyproject.toml index 7979b2e1c7..d70a99ea42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "osm2geojson>=0.2.5", "requests>=2.32.3", "httpx>=0.27.0", + "pydantic>=2.8.0", ] [project.urls] @@ -32,6 +33,7 @@ dev = [ "geojson>=3.1.0", "requests-mock>=1.12.1", "deepdiff>=7.0.1", + "pydantic>=2.8.0", "pytest-asyncio>=0.23.0", "ruff>=0.6.0", "ty>=0.0.9", diff --git a/tests/test_api.py b/tests/test_api.py index 8b8e20842b..eeb8604fe8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -59,6 +59,9 @@ def test_geojson( osm_geo = api.get(query) assert len(osm_geo["features"]) > length + osm_model = api.get(query, model=True) + assert osm_model.features + def test_json_response(requests_mock): api = overpass.API() @@ -71,6 +74,10 @@ def test_json_response(requests_mock): response = api.get("node(1)", responseformat="json") assert response == mock_response + model_response = api.get("node(1)", responseformat="json", model=True) + assert model_response.elements[0].id == 1 + assert model_response.elements[0].type == "node" + def test_csv_response(requests_mock): api = overpass.API() diff --git a/tests/test_async_api.py b/tests/test_async_api.py index dbb64f0c3a..1b6c8ad6f4 100644 --- a/tests/test_async_api.py +++ b/tests/test_async_api.py @@ -37,6 +37,11 @@ async def handler(request): response = await api.get("node(1)", responseformat="json") assert response["elements"][0]["id"] == 1 + async with AsyncAPI(transport=_transport_for(handler)) as api: + model_response = await api.get("node(1)", responseformat="json", model=True) + assert model_response.elements[0].id == 1 + assert model_response.elements[0].type == "node" + @pytest.mark.asyncio async def test_async_csv_response(): @@ -68,6 +73,20 @@ async def handler(request): assert response.startswith("") +@pytest.mark.asyncio +async def test_async_geojson_model(): + async def handler(request): + return httpx.Response( + 200, + json={"elements": [{"id": 1, "type": "node", "lat": 0.0, "lon": 0.0}]}, + headers={"content-type": "application/json"}, + ) + + async with AsyncAPI(transport=_transport_for(handler)) as api: + response = await api.get("node(1)", model=True) + assert response.features + + @pytest.mark.asyncio async def test_async_invalid_response_raises(): async def handler(request): diff --git a/uv.lock b/uv.lock index 91d198bfc5..367f00b386 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.11, <3.14" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "anyio" version = "4.12.0" @@ -253,6 +262,7 @@ source = { editable = "." } dependencies = [ { name = "httpx" }, { name = "osm2geojson" }, + { name = "pydantic" }, { name = "requests" }, ] @@ -260,6 +270,7 @@ dependencies = [ dev = [ { name = "deepdiff" }, { name = "geojson" }, + { name = "pydantic" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "requests-mock" }, @@ -271,6 +282,7 @@ dev = [ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "osm2geojson", specifier = ">=0.2.5" }, + { name = "pydantic", specifier = ">=2.8.0" }, { name = "requests", specifier = ">=2.32.3" }, ] @@ -278,6 +290,7 @@ requires-dist = [ dev = [ { name = "deepdiff", specifier = ">=7.0.1" }, { name = "geojson", specifier = ">=3.1.0" }, + { name = "pydantic", specifier = ">=2.8.0" }, { name = "pytest", specifier = ">=7.4.0" }, { name = "pytest-asyncio", specifier = ">=0.23.0" }, { name = "requests-mock", specifier = ">=1.12.1" }, @@ -303,6 +316,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -471,6 +568,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "urllib3" version = "2.6.2" From be145db0ae79a97785f9d8c787ff0e6d5e500019 Mon Sep 17 00:00:00 2001 From: Martijn van Exel Date: Mon, 5 Jan 2026 14:02:56 -0700 Subject: [PATCH 7/9] Add CSV/XML models and document model usage --- README.md | 24 ++++++++++++++++++++++++ overpass/__init__.py | 2 +- overpass/api.py | 13 ++++++++++++- overpass/async_api.py | 13 ++++++++++++- overpass/models.py | 12 ++++++++++++ tests/test_api.py | 9 +++++++++ tests/test_async_api.py | 11 +++++++++++ 7 files changed, 81 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cb3dd2f4bb..71d20818c4 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,14 @@ Most users will only ever need to use the `get()` method. There are some conveni response = api.get('node["name"="Salt Lake City"]') ``` +You can opt into Pydantic models using `model=True`: + +```python +model = api.get('node["name"="Salt Lake City"]', model=True) +print(model.to_geojson()) +geo_interface = model.__geo_interface__ +``` + `response` will be a dictionary representing the JSON output you would get [from the Overpass API directly](https://overpass-api.de/output_formats.html#json). @@ -136,6 +144,22 @@ We will construct a valid Overpass QL query from the parameters you set by defau You can query the data as it was on a given date. You can give either a standard ISO date alone (YYYY-MM-DD) or a full overpass date and time (YYYY-MM-DDTHH:MM:SSZ, i.e. 2020-04-28T00:00:00Z). You can also directly pass a `date` or `datetime` object from the `datetime` library. +### Async usage + +```python +import asyncio +import overpass + + +async def main(): + async with overpass.AsyncAPI() as api: + data = await api.get('node["name"="Salt Lake City"]', model=True) + print(data.to_geojson()) + + +asyncio.run(main()) +``` + ### Pre-cooked Queries: `MapQuery`, `WayQuery` In addition to just sending your query and parse the result, `overpass` diff --git a/overpass/__init__.py b/overpass/__init__.py index 79816e922a..8f586f29f4 100644 --- a/overpass/__init__.py +++ b/overpass/__init__.py @@ -13,7 +13,7 @@ from .api import API from .async_api import AsyncAPI -from .models import GeoJSONFeatureCollection, OverpassResponse +from .models import GeoJSONFeatureCollection, OverpassResponse, CsvResponse, XmlResponse from .queries import MapQuery, WayQuery from .errors import ( OverpassError, diff --git a/overpass/api.py b/overpass/api.py index 83e105993f..215294ce89 100644 --- a/overpass/api.py +++ b/overpass/api.py @@ -118,8 +118,19 @@ def get( if self.debug: print(content_type) if content_type == "text/csv": - return list(csv.reader(StringIO(r.text), delimiter="\t")) + csv_rows = list(csv.reader(StringIO(r.text), delimiter="\t")) + if model: + from .models import CsvResponse + + header = csv_rows[0] if csv_rows else [] + rows = csv_rows[1:] if len(csv_rows) > 1 else [] + return CsvResponse(header=header, rows=rows) + return csv_rows elif content_type in ("text/xml", "application/xml", "application/osm3s+xml"): + if model: + from .models import XmlResponse + + return XmlResponse(text=r.text) return r.text else: try: diff --git a/overpass/async_api.py b/overpass/async_api.py index f20853652b..4525e4fe17 100644 --- a/overpass/async_api.py +++ b/overpass/async_api.py @@ -106,8 +106,19 @@ async def get( if self.debug: print(content_type) if content_type == "text/csv": - return list(csv.reader(StringIO(r.text), delimiter="\t")) + csv_rows = list(csv.reader(StringIO(r.text), delimiter="\t")) + if model: + from .models import CsvResponse + + header = csv_rows[0] if csv_rows else [] + rows = csv_rows[1:] if len(csv_rows) > 1 else [] + return CsvResponse(header=header, rows=rows) + return csv_rows elif content_type in ("text/xml", "application/xml", "application/osm3s+xml"): + if model: + from .models import XmlResponse + + return XmlResponse(text=r.text) return r.text else: try: diff --git a/overpass/models.py b/overpass/models.py index 157848fd6d..73f299227a 100644 --- a/overpass/models.py +++ b/overpass/models.py @@ -13,11 +13,13 @@ class GeoJSONGeometry(BaseModel): type: str coordinates: Any + bbox: Optional[list[float]] = None class GeoJSONFeature(BaseModel): type: Literal["Feature"] = "Feature" id: Optional[Any] = None + bbox: Optional[list[float]] = None properties: dict[str, Any] = Field(default_factory=dict) geometry: Optional[GeoJSONGeometry] = None @@ -31,6 +33,7 @@ def __geo_interface__(self) -> dict[str, Any]: class GeoJSONFeatureCollection(BaseModel): type: Literal["FeatureCollection"] = "FeatureCollection" + bbox: Optional[list[float]] = None features: list[GeoJSONFeature] = Field(default_factory=list) def to_geojson(self) -> str: @@ -57,3 +60,12 @@ class OverpassResponse(BaseModel): generator: Optional[str] = None osm3s: Optional[dict[str, Any]] = None remark: Optional[str] = None + + +class CsvResponse(BaseModel): + header: list[str] + rows: list[list[str]] + + +class XmlResponse(BaseModel): + text: str diff --git a/tests/test_api.py b/tests/test_api.py index eeb8604fe8..181c11c214 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -92,6 +92,12 @@ def test_csv_response(requests_mock): ) assert response == [["name", "@lon", "@lat"], ["Springfield", "-3.0", "56.2"]] + model_response = api.get( + 'node["name"="Springfield"]["place"]', responseformat="csv(name,::lon,::lat)", model=True + ) + assert model_response.header == ["name", "@lon", "@lat"] + assert model_response.rows == [["Springfield", "-3.0", "56.2"]] + def test_xml_response(requests_mock): api = overpass.API() @@ -104,6 +110,9 @@ def test_xml_response(requests_mock): response = api.get("node(1)", responseformat="xml") assert response == xml_body + model_response = api.get("node(1)", responseformat="xml", model=True) + assert model_response.text == xml_body + @pytest.mark.integration def test_multipolygon(): diff --git a/tests/test_async_api.py b/tests/test_async_api.py index 1b6c8ad6f4..40e2ef88a1 100644 --- a/tests/test_async_api.py +++ b/tests/test_async_api.py @@ -58,6 +58,13 @@ async def handler(request): ) assert response == [["name", "@lon", "@lat"], ["Springfield", "-3.0", "56.2"]] + async with AsyncAPI(transport=_transport_for(handler)) as api: + model_response = await api.get( + 'node["name"="Springfield"]["place"]', responseformat="csv(name,::lon,::lat)", model=True + ) + assert model_response.header == ["name", "@lon", "@lat"] + assert model_response.rows == [["Springfield", "-3.0", "56.2"]] + @pytest.mark.asyncio async def test_async_xml_response(): @@ -72,6 +79,10 @@ async def handler(request): response = await api.get("node(1)", responseformat="xml") assert response.startswith("") + async with AsyncAPI(transport=_transport_for(handler)) as api: + model_response = await api.get("node(1)", responseformat="xml", model=True) + assert model_response.text.startswith("") + @pytest.mark.asyncio async def test_async_geojson_model(): From adf32b1f5f5eaf0b36eba24e4a66ccc1fe2775b0 Mon Sep 17 00:00:00 2001 From: Martijn van Exel Date: Mon, 5 Jan 2026 14:04:54 -0700 Subject: [PATCH 8/9] Add shapely roundtrip test for geo_interface --- overpass/models.py | 4 ++++ pyproject.toml | 1 + tests/test_api.py | 16 ++++++++++++++++ uv.lock | 2 ++ 4 files changed, 23 insertions(+) diff --git a/overpass/models.py b/overpass/models.py index 73f299227a..463de44687 100644 --- a/overpass/models.py +++ b/overpass/models.py @@ -15,6 +15,10 @@ class GeoJSONGeometry(BaseModel): coordinates: Any bbox: Optional[list[float]] = None + @property + def __geo_interface__(self) -> dict[str, Any]: + return self.model_dump() + class GeoJSONFeature(BaseModel): type: Literal["Feature"] = "Feature" diff --git a/pyproject.toml b/pyproject.toml index d70a99ea42..85112161bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dev = [ "requests-mock>=1.12.1", "deepdiff>=7.0.1", "pydantic>=2.8.0", + "shapely>=2.0.0", "pytest-asyncio>=0.23.0", "ruff>=0.6.0", "ty>=0.0.9", diff --git a/tests/test_api.py b/tests/test_api.py index 181c11c214..15a4aa396d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -13,8 +13,11 @@ import pytest from deepdiff import DeepDiff +from shapely.geometry import shape +from shapely.geometry import mapping as shapely_mapping import overpass +from overpass.models import GeoJSONFeatureCollection from overpass.utils import Utils from overpass.errors import ( MultipleRequestsError, @@ -152,6 +155,19 @@ def test_geojson_extended(verbosity, response, output, requests_mock): assert osm_geo == ref_geo +def test_geo_interface_roundtrip(): + with Path("tests/example_body.geojson").open() as fp: + raw_geojson = json.load(fp) + + model = GeoJSONFeatureCollection.model_validate(raw_geojson) + first_geom = model.features[0].geometry + assert first_geom is not None + + model_shape = shape(first_geom.__geo_interface__) + raw_shape = shape(raw_geojson["features"][0]["geometry"]) + assert shapely_mapping(model_shape) == shapely_mapping(raw_shape) + + def test_invalid_overpass_response_raises(requests_mock): api = overpass.API() requests_mock.post( diff --git a/uv.lock b/uv.lock index 367f00b386..5690abb9ba 100644 --- a/uv.lock +++ b/uv.lock @@ -275,6 +275,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "requests-mock" }, { name = "ruff" }, + { name = "shapely" }, { name = "ty" }, ] @@ -295,6 +296,7 @@ dev = [ { name = "pytest-asyncio", specifier = ">=0.23.0" }, { name = "requests-mock", specifier = ">=1.12.1" }, { name = "ruff", specifier = ">=0.6.0" }, + { name = "shapely", specifier = ">=2.0.0" }, { name = "ty", specifier = ">=0.0.9" }, ] From c5b1de42f77d59841f3546b7bd0b6695e7c53bc6 Mon Sep 17 00:00:00 2001 From: Martijn van Exel Date: Mon, 5 Jan 2026 14:09:08 -0700 Subject: [PATCH 9/9] Update modernization docs with implemented work --- docs/modernization-plan.md | 12 ++++++++++-- docs/modernization-tasks.md | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/modernization-plan.md b/docs/modernization-plan.md index 9ac90938dd..0f8f405daf 100644 --- a/docs/modernization-plan.md +++ b/docs/modernization-plan.md @@ -17,6 +17,15 @@ - Provide helpers on models (`to_geojson()`, `__geo_interface__`) while keeping existing dict return behavior by default. +## Implemented (current branch) +- Shared transport abstraction for sync/async HTTP. +- `AsyncAPI` alongside `API` (httpx-based). +- Opt-in Pydantic models for Overpass JSON + GeoJSON, plus CSV/XML wrappers. +- `to_geojson()` and `__geo_interface__` on GeoJSON models (Shapely round-trip test). +- Extended tests for response formats, error mapping, and async parity. +- Hardened JSON parsing with clearer errors when content is invalid. +- `Utils.to_overpass_id` now requires a source type (`way` or `relation`). + ## Testing strategy - Unit tests for: - Query construction (`MapQuery`, `WayQuery`, `build`, `verbosity`, `date`). @@ -27,5 +36,4 @@ - Integration tests remain opt-in (`RUN_NETWORK_TESTS=1`). ## Open questions -- Final API for opting into models (flag vs responseformat). -- Whether to expose `AsyncAPI` in `overpass.__init__`. +- GeoJSON hardening for relations/multipolygons/routes/boundaries (#181). diff --git a/docs/modernization-tasks.md b/docs/modernization-tasks.md index 6c5cdf4352..15d5b3d9af 100644 --- a/docs/modernization-tasks.md +++ b/docs/modernization-tasks.md @@ -6,7 +6,7 @@ - [x] Add Pydantic response models (opt-in) - [x] Add GeoJSON helpers on models (`to_geojson`, `__geo_interface__`) - [x] Expand test coverage for all response formats and error handling -- [ ] Update docs for async usage and model opt-in +- [x] Update docs for async usage and model opt-in ## Tracking - [ ] Close #181 GeoJSON hardening (relations/multipolygons/routes/boundaries)