Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add AsyncClient #38

Closed
wants to merge 6 commits into from
Closed
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
42 changes: 40 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Get address coordinates via Yandex geocoder

## Installation

**Synchronous version:**

Install it via `pip` tool:

```shell
Expand All @@ -22,16 +24,30 @@ or Poetry:
poetry add yandex-geocoder
```

**Asynchronous version:**

```shell
pip install "yandex-geocoder[async]"
```

or Poetry:

```shell
poetry add "yandex-geocoder[async]"
```

## Usage example

Yandex Geocoder requires an API developer key, you can get it [here](https://developer.tech.yandex.ru/services/) to use this library.
Yandex Geocoder requires an API developer key, you can get it [here](https://developer.tech.yandex.ru/services/) to use
this library.

**Synchronous version:**

```python
from decimal import Decimal

from yandex_geocoder import Client


client = Client("your-api-key")

coordinates = client.coordinates("Москва Льва Толстого 16")
Expand All @@ -41,6 +57,28 @@ address = client.address(Decimal("37.587093"), Decimal("55.733969"))
assert address == "Россия, Москва, улица Льва Толстого, 16"
```

**Asynchronous version:**

```python
import asyncio
from decimal import Decimal

from yandex_geocoder import AsyncClient


async def main():
aclient = AsyncClient(api_key="your-api-key")

coordinates = await aclient.coordinates("Москва Льва Толстого 16")
assert coordinates == (Decimal("37.587093"), Decimal("55.733974"))
address = await aclient.address(Decimal("37.587093"), Decimal("55.733974"))
assert address == "Россия, Москва, улица Льва Толстого, 16"


if __name__ == '__main__':
asyncio.run(main())
```

## Development and contribution

First of all you should install [Poetry](https://python-poetry.org).
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ repository = "https://github.com/sivakov512/yandex-geocoder"
[tool.poetry.dependencies]
python = "^3.9"
requests = "^2.28.1"
aiohttp = { version = "^3.9.4", optional = true }

[tool.poetry.group.dev.dependencies]
flake8 = "^7.0.0"
Expand All @@ -23,6 +24,8 @@ flake8-quotes = "^3.3.2"
isort = "^5.11.4"
mypy = "^1.0.0"
pytest = "^8.0.0"
pytest-asyncio = "0.23.6"
aioresponses = "0.7.6"
pytest-deadfixtures = "^2.2.1"
flake8-black = "^0.3.6"
black = "^24.0.0"
Expand All @@ -31,6 +34,9 @@ coveralls = "^3.3.1"
types-requests = "^2.28.11.7"
requests-mock = "^1.10.0"

[tool.poetry.extras]
async = ["aiohttp"]

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
Expand Down
35 changes: 27 additions & 8 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,42 @@

import pytest
import requests_mock
from aioresponses import aioresponses


@pytest.fixture
def mock_api() -> typing.Any:
def _encode(geocode: str, api_key: str = "well-known-key") -> str:
params = {"format": "json", "apikey": api_key, "geocode": geocode}
query = urlencode(params)
return f"https://geocode-maps.yandex.ru/1.x/?{query}"
def _encode(geocode: str, api_key: str = "well-known-key") -> str:
params = {"format": "json", "apikey": api_key, "geocode": geocode}
query = urlencode(params)
return f"https://geocode-maps.yandex.ru/1.x/?{query}"

with requests_mock.mock() as _m:
yield lambda resp, status, **encode_kw: _m.get(

def _mock_setup(mock: typing.Any, resp: str, status: str, **encode_kw: typing.Any) -> typing.Any:
if isinstance(mock, aioresponses):
return mock.get(
_encode(**encode_kw),
payload=load_fixture(resp) if isinstance(resp, str) else resp,
status=status,
)
else:
return mock.get(
_encode(**encode_kw),
json=load_fixture(resp) if isinstance(resp, str) else resp,
status_code=status,
)


@pytest.fixture
def mock_api() -> typing.Any:
with requests_mock.mock() as _m:
yield lambda resp, status, **encode_kw: _mock_setup(_m, resp, status, **encode_kw)


@pytest.fixture
def async_mock_api() -> typing.Any:
with aioresponses() as _m:
yield lambda resp, status, **encode_kw: _mock_setup(_m, resp, status, **encode_kw)


def load_fixture(fixture_name: str) -> dict[str, typing.Any]:
with open(f"./tests/fixtures/{fixture_name}.json") as fixture:
return json.load(fixture) # type: ignore
Expand Down
45 changes: 44 additions & 1 deletion tests/test_address.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import pytest

from yandex_geocoder import Client, InvalidKey, NothingFound, UnexpectedResponse
from yandex_geocoder import AsyncClient, Client, InvalidKey, NothingFound, UnexpectedResponse


def test_returns_found_address(mock_api: typing.Any) -> None:
Expand All @@ -16,6 +16,15 @@ def test_returns_found_address(mock_api: typing.Any) -> None:
)


@pytest.mark.asyncio
async def test_returns_found_address_async(async_mock_api: typing.Any) -> None:
async_mock_api("address_found", 200, geocode="37.587093,55.733969")
client = AsyncClient("well-known-key")

resp = await client.address(Decimal("37.587093"), Decimal("55.733969"))
assert resp == "Россия, Москва, улица Льва Толстого, 16"


def test_raises_if_address_not_found(mock_api: typing.Any) -> None:
mock_api("address_not_found", 200, geocode="337.587093,55.733969")
client = Client("well-known-key")
Expand All @@ -24,6 +33,15 @@ def test_raises_if_address_not_found(mock_api: typing.Any) -> None:
client.address(Decimal("337.587093"), Decimal("55.733969"))


@pytest.mark.asyncio
async def test_raises_if_address_not_found_async(async_mock_api: typing.Any) -> None:
async_mock_api("address_not_found", 200, geocode="337.587093,55.733969")
client = AsyncClient("well-known-key")

with pytest.raises(NothingFound, match='Nothing found for "337.587093 55.733969"'):
await client.address(Decimal("337.587093"), Decimal("55.733969"))


def test_raises_for_invalid_api_key(mock_api: typing.Any) -> None:
mock_api(
{"statusCode": 403, "error": "Forbidden", "message": "Invalid key"},
Expand All @@ -37,6 +55,20 @@ def test_raises_for_invalid_api_key(mock_api: typing.Any) -> None:
client.address(Decimal("37.587093"), Decimal("55.733969"))


@pytest.mark.asyncio
async def test_raises_for_invalid_api_key_async(async_mock_api: typing.Any) -> None:
async_mock_api(
{"statusCode": 403, "error": "Forbidden", "message": "Invalid key"},
403,
geocode="37.587093,55.733969",
api_key="unkown-api-key",
)
client = AsyncClient("unkown-api-key")

with pytest.raises(InvalidKey):
await client.address(Decimal("37.587093"), Decimal("55.733969"))


def test_raises_for_unknown_response(mock_api: typing.Any) -> None:
mock_api({}, 500, geocode="37.587093,55.733969")
client = Client("well-known-key")
Expand All @@ -45,3 +77,14 @@ def test_raises_for_unknown_response(mock_api: typing.Any) -> None:
client.address(Decimal("37.587093"), Decimal("55.733969"))

assert "status_code=500, body=b'{}'" in exc_info.value.args


@pytest.mark.asyncio
async def test_raises_for_unknown_response_async(async_mock_api: typing.Any) -> None:
async_mock_api({}, 500, geocode="37.587093,55.733969")
client = AsyncClient("well-known-key")

with pytest.raises(UnexpectedResponse) as exc_info:
await client.address(Decimal("37.587093"), Decimal("55.733969"))

assert "status_code=500, body=b'{}'" in exc_info.value.args
47 changes: 46 additions & 1 deletion tests/test_coordinates.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import pytest

from yandex_geocoder import Client, InvalidKey, NothingFound, UnexpectedResponse
from yandex_geocoder import AsyncClient, Client, InvalidKey, NothingFound, UnexpectedResponse


def test_returns_found_coordinates(mock_api: typing.Any) -> None:
Expand All @@ -16,6 +16,17 @@ def test_returns_found_coordinates(mock_api: typing.Any) -> None:
)


@pytest.mark.asyncio
async def test_returns_found_coordinates_async(async_mock_api: typing.Any) -> None:
async_mock_api("coords_found", 200, geocode="Москва Льва Толстого 16")
client = AsyncClient("well-known-key")

assert await client.coordinates("Москва Льва Толстого 16") == (
Decimal("37.587093"),
Decimal("55.733969"),
)


def test_raises_if_coordinates_not_found(mock_api: typing.Any) -> None:
mock_api("coords_not_found", 200, geocode="абырвалг")
client = Client("well-known-key")
Expand All @@ -24,6 +35,15 @@ def test_raises_if_coordinates_not_found(mock_api: typing.Any) -> None:
client.coordinates("абырвалг")


@pytest.mark.asyncio
async def test_raises_if_coordinates_not_found_async(async_mock_api: typing.Any) -> None:
async_mock_api("coords_not_found", 200, geocode="абырвалг")
client = AsyncClient("well-known-key")

with pytest.raises(NothingFound, match='Nothing found for "абырвалг"'):
await client.coordinates("абырвалг")


def test_raises_for_invalid_api_key(mock_api: typing.Any) -> None:
mock_api(
{"statusCode": 403, "error": "Forbidden", "message": "Invalid key"},
Expand All @@ -37,6 +57,20 @@ def test_raises_for_invalid_api_key(mock_api: typing.Any) -> None:
client.coordinates("Москва Льва Толстого 16")


@pytest.mark.asyncio
async def test_raises_for_invalid_api_key_async(async_mock_api: typing.Any) -> None:
async_mock_api(
{"statusCode": 403, "error": "Forbidden", "message": "Invalid key"},
403,
geocode="Москва Льва Толстого 16",
api_key="unkown-api-key",
)
client = AsyncClient("unkown-api-key")

with pytest.raises(InvalidKey):
await client.coordinates("Москва Льва Толстого 16")


def test_raises_for_unknown_response(mock_api: typing.Any) -> None:
mock_api({}, 500, geocode="Москва Льва Толстого 16")
client = Client("well-known-key")
Expand All @@ -45,3 +79,14 @@ def test_raises_for_unknown_response(mock_api: typing.Any) -> None:
client.coordinates("Москва Льва Толстого 16")

assert "status_code=500, body=b'{}'" in exc_info.value.args


@pytest.mark.asyncio
async def test_raises_for_unknown_response_async(async_mock_api: typing.Any) -> None:
async_mock_api({}, 500, geocode="Москва Льва Толстого 16")
client = AsyncClient("well-known-key")

with pytest.raises(UnexpectedResponse) as exc_info:
await client.coordinates("Москва Льва Толстого 16")

assert "status_code=500, body=b'{}'" in exc_info.value.args
10 changes: 9 additions & 1 deletion yandex_geocoder/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
__all__ = ["Client", "InvalidKey", "NothingFound", "UnexpectedResponse", "YandexGeocoderException"]
__all__ = [
"Client",
"AsyncClient",
"InvalidKey",
"NothingFound",
"UnexpectedResponse",
"YandexGeocoderException",
]

from yandex_geocoder.async_client import AsyncClient
from yandex_geocoder.client import Client
from yandex_geocoder.exceptions import (
InvalidKey,
Expand Down
67 changes: 67 additions & 0 deletions yandex_geocoder/async_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
__all__ = ["AsyncClient"]

import dataclasses
import typing
from decimal import Decimal

import aiohttp

from .exceptions import InvalidKey, NothingFound, UnexpectedResponse


@dataclasses.dataclass
class AsyncClient:
"""Yandex geocoder API async client.

:Example:
>>> from yandex_geocoder import AsyncClient

>>> async def main():
>>> aclient = AsyncClient(api_key="your-api-key")
>>> coordinates = await aclient.coordinates("Москва Льва Толстого 16")
>>> assert coordinates == (Decimal("37.587093"), Decimal("55.733974"))
>>> address = await aclient.address(Decimal("37.587093"), Decimal("55.733974"))
>>> assert address == "Россия, Москва, улица Льва Толстого, 16"
"""

__slots__ = ("api_key",)

api_key: str

async def _request(self, address: str) -> typing.Dict[str, typing.Any]:
async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=False)) as session:
async with session.get(
url="https://geocode-maps.yandex.ru/1.x/",
params=dict(format="json", apikey=self.api_key, geocode=address),
) as response:
if response.status == 200:
a: typing.Dict[str, typing.Any] = (await response.json())["response"]
return a
elif response.status == 403:
raise InvalidKey()
else:
body = await response.content.read()
raise UnexpectedResponse(f"status_code={response.status}, body={body!r}")

async def coordinates(self, address: str) -> typing.Tuple[Decimal, ...]:
"""Fetch coordinates (longitude, latitude) for passed address."""
d = await self._request(address)
data = d["GeoObjectCollection"]["featureMember"]

if not data:
raise NothingFound(f'Nothing found for "{address}" not found')

coordinates = data[0]["GeoObject"]["Point"]["pos"]
longitude, latitude = tuple(coordinates.split(" "))
return Decimal(longitude), Decimal(latitude)

async def address(self, longitude: Decimal, latitude: Decimal) -> str:
"""Fetch address for passed coordinates."""
response = await self._request(f"{longitude},{latitude}")
data = response.get("GeoObjectCollection", {}).get("featureMember", [])

if not data:
raise NothingFound(f'Nothing found for "{longitude} {latitude}"')

address_details: str = data[0]["GeoObject"]["metaDataProperty"]["GeocoderMetaData"]["text"]
return address_details