Skip to content
This repository was archived by the owner on Jan 8, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ venv/
.venv/
.tox
.ruff_cache/
docs/Overpass API_Overpass QL - OpenStreetMap Wiki.pdf
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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`
Expand Down
39 changes: 39 additions & 0 deletions docs/modernization-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 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.

## 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`).
- 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
- GeoJSON hardening for relations/multipolygons/routes/boundaries (#181).
13 changes: 13 additions & 0 deletions docs/modernization-tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# 0.8 Modernization Tasks

## Plan
- [x] Add transport abstraction shared by sync/async clients
- [x] Introduce `AsyncAPI` with httpx
- [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
- [x] Update docs for async usage and model opt-in

## Tracking
- [ ] Close #181 GeoJSON hardening (relations/multipolygons/routes/boundaries)
- [ ] Address open bugs: #172, #176
2 changes: 2 additions & 0 deletions overpass/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
__license__ = "Apache 2.0"

from .api import API
from .async_api import AsyncAPI
from .models import GeoJSONFeatureCollection, OverpassResponse, CsvResponse, XmlResponse
from .queries import MapQuery, WayQuery
from .errors import (
OverpassError,
Expand Down
60 changes: 50 additions & 10 deletions overpass/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,6 +23,7 @@
TimeoutError,
UnknownOverpassError,
)
from .transport import RequestsTransport


class API(object):
Expand All @@ -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"]
Expand All @@ -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:
Expand All @@ -70,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
Expand Down Expand Up @@ -107,11 +118,27 @@ def get(self, query, responseformat="geojson", verbosity="body", build=True, dat
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:
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
Expand All @@ -127,19 +154,32 @@ 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

@staticmethod
def _api_status() -> dict:
from .models import GeoJSONFeatureCollection

return GeoJSONFeatureCollection.model_validate(geojson_response)

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)')
Expand Down Expand Up @@ -261,7 +301,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,
Expand Down
Loading
Loading