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/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/docs/modernization-plan.md b/docs/modernization-plan.md
new file mode 100644
index 0000000000..0f8f405daf
--- /dev/null
+++ b/docs/modernization-plan.md
@@ -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).
diff --git a/docs/modernization-tasks.md b/docs/modernization-tasks.md
new file mode 100644
index 0000000000..15d5b3d9af
--- /dev/null
+++ b/docs/modernization-tasks.md
@@ -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
diff --git a/overpass/__init__.py b/overpass/__init__.py
index d28d1cc085..8f586f29f4 100644
--- a/overpass/__init__.py
+++ b/overpass/__init__.py
@@ -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,
diff --git a/overpass/api.py b/overpass/api.py
index a5fe645bb3..215294ce89 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
@@ -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:
@@ -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
@@ -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
@@ -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)')
@@ -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,
diff --git a/overpass/async_api.py b/overpass/async_api.py
new file mode 100644
index 0000000000..4525e4fe17
--- /dev/null
+++ b/overpass/async_api.py
@@ -0,0 +1,265 @@
+# 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="",
+ model: bool = False,
+ ):
+ """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":
+ 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:
+ 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
+
+ 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":
+ if model:
+ from .models import OverpassResponse
+
+ return OverpassResponse.model_validate(response)
+ return 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"
+
+ 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/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/models.py b/overpass/models.py
new file mode 100644
index 0000000000..463de44687
--- /dev/null
+++ b/overpass/models.py
@@ -0,0 +1,75 @@
+# 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
+ bbox: Optional[list[float]] = None
+
+ @property
+ def __geo_interface__(self) -> dict[str, Any]:
+ return self.model_dump()
+
+
+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
+
+ 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"
+ bbox: Optional[list[float]] = None
+ 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
+
+
+class CsvResponse(BaseModel):
+ header: list[str]
+ rows: list[list[str]]
+
+
+class XmlResponse(BaseModel):
+ text: str
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/transport.py b/overpass/transport.py
new file mode 100644
index 0000000000..673ed88250
--- /dev/null
+++ b/overpass/transport.py
@@ -0,0 +1,72 @@
+# 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
+import httpx
+
+
+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)
+
+
+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/overpass/utils.py b/overpass/utils.py
index 3cc1e5d72e..d872cc0cef 100644
--- a/overpass/utils.py
+++ b/overpass/utils.py
@@ -7,9 +7,18 @@
class Utils(object):
@staticmethod
- def to_overpass_id(osmid, area=False):
+ 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/pyproject.toml b/pyproject.toml
index 1b89ee1283..85112161bf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,6 +19,8 @@ classifiers = [
dependencies = [
"osm2geojson>=0.2.5",
"requests>=2.32.3",
+ "httpx>=0.27.0",
+ "pydantic>=2.8.0",
]
[project.urls]
@@ -31,6 +33,9 @@ dev = [
"geojson>=3.1.0",
"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 a7df7c7121..15a4aa396d 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -10,14 +10,23 @@
from typing import Tuple, Union
import geojson
-import os
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,
+ 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():
@@ -53,6 +62,60 @@ 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()
+ 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
+
+ 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()
+ 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"]]
+
+ 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()
+ 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
+
+ model_response = api.get("node(1)", responseformat="xml", model=True)
+ assert model_response.text == xml_body
+
@pytest.mark.integration
def test_multipolygon():
@@ -92,6 +155,52 @@ 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(
+ "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_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"}
+ 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 +328,31 @@ 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)")
+
+
+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")
diff --git a/tests/test_async_api.py b/tests/test_async_api.py
new file mode 100644
index 0000000000..40e2ef88a1
--- /dev/null
+++ b/tests/test_async_api.py
@@ -0,0 +1,168 @@
+# 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
+
+ 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():
+ 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"]]
+
+ 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():
+ 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("")
+
+ 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():
+ 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):
+ 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
+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",
+ [
+ (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..5690abb9ba 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2,6 +2,28 @@ 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"
+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 +120,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,7 +260,9 @@ name = "overpass"
version = "0.7.2"
source = { editable = "." }
dependencies = [
+ { name = "httpx" },
{ name = "osm2geojson" },
+ { name = "pydantic" },
{ name = "requests" },
]
@@ -209,15 +270,20 @@ dependencies = [
dev = [
{ name = "deepdiff" },
{ name = "geojson" },
+ { name = "pydantic" },
{ name = "pytest" },
+ { name = "pytest-asyncio" },
{ name = "requests-mock" },
{ name = "ruff" },
+ { name = "shapely" },
{ name = "ty" },
]
[package.metadata]
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" },
]
@@ -225,9 +291,12 @@ 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" },
{ name = "ruff", specifier = ">=0.6.0" },
+ { name = "shapely", specifier = ">=2.0.0" },
{ name = "ty", specifier = ">=0.0.9" },
]
@@ -249,6 +318,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"
@@ -274,6 +427,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 +561,27 @@ 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 = "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"