From 5aa3c5bc384011462f47482da2b785dde3c3487c Mon Sep 17 00:00:00 2001 From: dromanov Date: Sun, 14 Apr 2024 16:07:43 +0300 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9A=A1=EF=B8=8Fadd=20AsyncClient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- yandex_geocoder/__init__.py | 10 ++++- yandex_geocoder/async_client.py | 68 +++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 yandex_geocoder/async_client.py diff --git a/yandex_geocoder/__init__.py b/yandex_geocoder/__init__.py index 5e6a6a9..d0bed2f 100644 --- a/yandex_geocoder/__init__.py +++ b/yandex_geocoder/__init__.py @@ -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, diff --git a/yandex_geocoder/async_client.py b/yandex_geocoder/async_client.py new file mode 100644 index 0000000..6d25342 --- /dev/null +++ b/yandex_geocoder/async_client.py @@ -0,0 +1,68 @@ +__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: + raise UnexpectedResponse( + f"status_code={response.status}, body={response.content}" + ) + + 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 From c1d211c479653a1262836f03c1b1922c989a57e7 Mon Sep 17 00:00:00 2001 From: dromanov Date: Sun, 14 Apr 2024 16:07:53 +0300 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9E=95=20add=20optional=20aiohttp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 794212e..77e4e39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" @@ -31,6 +32,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" From 43c7e867feafcf5f865be065aa5d3534f382e149 Mon Sep 17 00:00:00 2001 From: dromanov Date: Sun, 14 Apr 2024 16:08:04 +0300 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=93=9D=20add=20ex=20for=20installatio?= =?UTF-8?q?n=20and=20usage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b91c99f..4d64dc2 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Get address coordinates via Yandex geocoder ## Installation +**Synchronous version:** + Install it via `pip` tool: ```shell @@ -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") @@ -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). From d33cd6bdaf45b8888cbdb55dd2461a885baf727a Mon Sep 17 00:00:00 2001 From: dromanov Date: Tue, 16 Apr 2024 01:41:13 +0300 Subject: [PATCH 4/6] =?UTF-8?q?=E2=9E=95=20add=20pytest-asyncio=20and=20ai?= =?UTF-8?q?oresponses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 77e4e39..e139285 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,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" From de106ffe37cc93fd170e401701dd0da66301f730 Mon Sep 17 00:00:00 2001 From: dromanov Date: Tue, 16 Apr 2024 01:41:39 +0300 Subject: [PATCH 5/6] minor fixes --- yandex_geocoder/async_client.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/yandex_geocoder/async_client.py b/yandex_geocoder/async_client.py index 6d25342..920aa72 100644 --- a/yandex_geocoder/async_client.py +++ b/yandex_geocoder/async_client.py @@ -31,8 +31,8 @@ class AsyncClient: 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), + 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"] @@ -40,9 +40,8 @@ async def _request(self, address: str) -> typing.Dict[str, typing.Any]: elif response.status == 403: raise InvalidKey() else: - raise UnexpectedResponse( - f"status_code={response.status}, body={response.content}" - ) + 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.""" From efab869e262ef794f986536abcdab97abd43717f Mon Sep 17 00:00:00 2001 From: dromanov Date: Tue, 16 Apr 2024 01:47:43 +0300 Subject: [PATCH 6/6] =?UTF-8?q?=E2=9C=85=20add=20tests=20for=20AsyncClient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/conftest.py | 35 ++++++++++++++++++++++------- tests/test_address.py | 45 ++++++++++++++++++++++++++++++++++++- tests/test_coordinates.py | 47 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 117 insertions(+), 10 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 63225c6..27a9e7c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_address.py b/tests/test_address.py index 4fdbcd6..20d03e8 100644 --- a/tests/test_address.py +++ b/tests/test_address.py @@ -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: @@ -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") @@ -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"}, @@ -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") @@ -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 diff --git a/tests/test_coordinates.py b/tests/test_coordinates.py index c26ca38..d9751fa 100644 --- a/tests/test_coordinates.py +++ b/tests/test_coordinates.py @@ -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: @@ -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") @@ -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"}, @@ -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") @@ -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