diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 2b967ad..82911d3 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -17,6 +17,7 @@ jobs: uses: neongeckocom/.github/.github/workflows/docker_build_tests.yml@master unit_tests: strategy: + max-parallel: 1 matrix: python-version: [3.7, 3.8, 3.9, '3.10'] runs-on: ubuntu-latest @@ -29,8 +30,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements/requirements.txt - pip install -r requirements/test_requirements.txt + pip install . -r requirements/test_requirements.txt - name: Export credentials run: | mkdir -p ~/.local/share/neon @@ -43,6 +43,13 @@ jobs: AV_API_KEY: ${{secrets.alpha_vantage_key}} OWM_KEY: ${{secrets.open_weather_map_key}} GENERIC_CONTROLLER_CONFIG: ${{secrets.generic_controller_config}} + + - name: Test Client + run: | + pytest tests/test_client.py --doctest-modules --junitxml=tests/client-test-results.xml + env: + MAP_MAKER_KEY: ${{secrets.map_maker_key}} + - name: Test Cached API run: | pytest tests/test_cached_api.py --doctest-modules --junitxml=tests/cached-api-test-results.xml @@ -79,6 +86,17 @@ jobs: name: owm-api-test-results path: tests/owm-api-test-results.xml + - name: Test Map Maker API + run: | + pytest tests/test_map_maker_api.py --doctest-modules --junitxml=tests/map-maker-api-test-results.xml + env: + MAP_MAKER_KEY: ${{secrets.map_maker_key}} + - name: Upload Map Maker API test results + uses: actions/upload-artifact@v2 + with: + name: map-maker-api-test-results + path: tests/map-maker-api-test-results.xml + - name: Test Generic API run: | pytest tests/test_generic_controller.py --doctest-modules --junitxml=tests/generic-controller-test-results.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 6463dce..072699d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,20 @@ # Changelog -## [0.4.2a1](https://github.com/NeonGeckoCom/neon_api_proxy/tree/0.4.2a1) (2023-07-04) +## [0.4.3a2](https://github.com/NeonGeckoCom/neon_api_proxy/tree/0.4.3a2) (2024-02-26) -[Full Changelog](https://github.com/NeonGeckoCom/neon_api_proxy/compare/0.4.1a1...0.4.2a1) +[Full Changelog](https://github.com/NeonGeckoCom/neon_api_proxy/compare/0.4.3a1...0.4.3a2) **Merged pull requests:** -- Fix unit test automation to run on PR to `master` [\#86](https://github.com/NeonGeckoCom/neon_api_proxy/pull/86) ([NeonDaniel](https://github.com/NeonDaniel)) +- Update geolocation tests [\#91](https://github.com/NeonGeckoCom/neon_api_proxy/pull/91) ([NeonDaniel](https://github.com/NeonDaniel)) -## [0.4.1a1](https://github.com/NeonGeckoCom/neon_api_proxy/tree/0.4.1a1) (2023-07-04) +## [0.4.3a1](https://github.com/NeonGeckoCom/neon_api_proxy/tree/0.4.3a1) (2023-12-28) -[Full Changelog](https://github.com/NeonGeckoCom/neon_api_proxy/compare/0.4.0...0.4.1a1) +[Full Changelog](https://github.com/NeonGeckoCom/neon_api_proxy/compare/0.4.2...0.4.3a1) **Merged pull requests:** -- Add docker publication to release workflow [\#84](https://github.com/NeonGeckoCom/neon_api_proxy/pull/84) ([NeonDaniel](https://github.com/NeonDaniel)) +- Add Map Maker API with unit tests [\#88](https://github.com/NeonGeckoCom/neon_api_proxy/pull/88) ([NeonDaniel](https://github.com/NeonDaniel)) diff --git a/neon_api_proxy/client/__init__.py b/neon_api_proxy/client/__init__.py index bcbddc0..306763b 100644 --- a/neon_api_proxy/client/__init__.py +++ b/neon_api_proxy/client/__init__.py @@ -41,6 +41,7 @@ def __str__(self): ALPHA_VANTAGE = "alpha_vantage" OPEN_WEATHER_MAP = "open_weather_map" WOLFRAM_ALPHA = "wolfram_alpha" + MAP_MAKER = "map_maker" FINANCIAL_MODELING_PREP = "financial_modeling_prep" NOT_IMPLEMENTED = "not_implemented" TEST_API = "api_test_endpoint" diff --git a/neon_api_proxy/client/map_maker.py b/neon_api_proxy/client/map_maker.py new file mode 100644 index 0000000..257c3f4 --- /dev/null +++ b/neon_api_proxy/client/map_maker.py @@ -0,0 +1,72 @@ +# NEON AI (TM) SOFTWARE, Software Development Kit & Application Framework +# All trademark and other rights reserved by their respective owners +# Copyright 2008-2022 Neongecko.com Inc. +# Contributors: Daniel McKnight, Guy Daniels, Elon Gasper, Richard Leeds, +# Regina Bloomstine, Casimiro Ferreira, Andrii Pernatii, Kirill Hrymailo +# BSD-3 License +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import json + +from ovos_utils.log import LOG +from neon_api_proxy.client import NeonAPI, request_api + + +def get_coordinates(location: str) -> (float, float): + """ + Get coordinates for the requested location + @param location: Search term, i.e. City, Address, Landmark + @returns: coordinate latitude, longitude + """ + resp = _make_api_call({'address': location}) + if resp['status_code'] != 200: + raise RuntimeError(f"API Request failed: {resp['content']}") + coords = resp['content'][0]['lat'], resp['content'][0]['lon'] + LOG.info(f"Resolved: {coords}") + return float(coords[0]), float(coords[1]) + + +def get_address(lat: float, lon: float) -> dict: + """ + Get a dict location for the specified coordinates + @param lat: latitude of point to look up + @param lon: longitude of point to look up + @returns: dict location (equivalent to Geopy Location.raw) + """ + resp = _make_api_call({'lat': lat, "lon": lon}) + if resp['status_code'] != 200: + raise RuntimeError(f"API Request failed: {resp['content']}") + address = resp['content']['address'] + if not address.get('city'): + LOG.debug(f"Response missing city, trying to find alternate tag in: " + f"{address.keys()}") + address['city'] = address.get('town') or address.get('village') + LOG.info(f"Resolved: {address}") + return address + + +def _make_api_call(request_data: dict) -> dict: + resp = request_api(NeonAPI.MAP_MAKER, request_data) + if resp['status_code'] == 200: + resp['content'] = json.loads(resp['content']) + return resp diff --git a/neon_api_proxy/controller.py b/neon_api_proxy/controller.py index 291ec93..7fb6688 100644 --- a/neon_api_proxy/controller.py +++ b/neon_api_proxy/controller.py @@ -31,6 +31,8 @@ from ovos_config.config import Configuration from neon_utils.configuration_utils import NGIConfig from ovos_config.locations import get_xdg_config_save_path + +from neon_api_proxy.services.map_maker_api import MapMakerAPI from neon_api_proxy.services.owm_api import OpenWeatherAPI from neon_api_proxy.services.alpha_vantage_api import AlphaVantageAPI from neon_api_proxy.services.wolfram_api import WolframAPI @@ -47,6 +49,7 @@ class NeonAPIProxyController: 'wolfram_alpha': WolframAPI, 'alpha_vantage': AlphaVantageAPI, 'open_weather_map': OpenWeatherAPI, + 'map_maker': MapMakerAPI, 'api_test_endpoint': TestAPI } @@ -63,7 +66,7 @@ def _init_config() -> dict: from neon_api_proxy.config import get_proxy_config legacy_config = get_proxy_config() if legacy_config: - return legacy_config + return legacy_config.get("SERVICES") or legacy_config legacy_config_file = join(get_xdg_config_save_path(), "ngi_auth_vars.yml") if isfile(legacy_config_file): @@ -82,16 +85,16 @@ def init_service_instances(self, service_class_mapping: dict) -> dict: and instance of python class representing it """ service_mapping = dict() - for item in list(service_class_mapping): - api_key = self.config.get("SERVICES", - self.config).get(item, - {}).get("api_key") \ - if self.config else None + for item in service_class_mapping: + api_key = self.config.get(item, {}).get("api_key") if self.config \ + else None try: + if api_key is None: + LOG.warning(f"No API key for {item} in {self.config}") service_mapping[item] = \ service_class_mapping[item](api_key=api_key) except Exception as e: - LOG.info(e) + LOG.error(e) return service_mapping def resolve_query(self, query: dict) -> dict: diff --git a/neon_api_proxy/services/map_maker_api.py b/neon_api_proxy/services/map_maker_api.py new file mode 100644 index 0000000..96dc404 --- /dev/null +++ b/neon_api_proxy/services/map_maker_api.py @@ -0,0 +1,109 @@ +# NEON AI (TM) SOFTWARE, Software Development Kit & Application Framework +# All trademark and other rights reserved by their respective owners +# Copyright 2008-2022 Neongecko.com Inc. +# Contributors: Daniel McKnight, Guy Daniels, Elon Gasper, Richard Leeds, +# Regina Bloomstine, Casimiro Ferreira, Andrii Pernatii, Kirill Hrymailo +# BSD-3 License +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import urllib.parse + +from datetime import timedelta +from os import getenv +from time import time, sleep +from requests import Response +from ovos_utils.log import LOG + +from neon_api_proxy.cached_api import CachedAPI + + +class MapMakerAPI(CachedAPI): + """ + API for querying My Maps API (geocoder.maps.co). + """ + + def __init__(self, api_key: str = None, cache_seconds=604800): # Cache week + super().__init__("map_maker") + self._api_key = api_key or getenv("MAP_MAKER_KEY") + if not self._api_key: + raise RuntimeError(f"No API key provided for Map Maker") + self._rate_limit_seconds = 1 + self._last_query = time() + self.cache_timeout = timedelta(seconds=cache_seconds) + self.geocode_url = "https://geocode.maps.co/search" + self.reverse_url = "https://geocode.maps.co/reverse" + + def handle_query(self, **kwargs) -> dict: + """ + Handles an incoming query and provides a response + :param kwargs: + 'lat' - optional str latitude + 'lon' - optional str longitude + 'address' - optional string address/place to resolve + :return: dict containing `status_code`, `content`, `encoding` + from URL response + """ + lat = kwargs.get("lat") + lon = kwargs.get("lon", kwargs.get("lng")) + address = kwargs.get('address') + + if not (address or (lat and lon)): + # Missing data for lookup + return {"status_code": -1, + "content": f"Incomplete request data: {kwargs}", + "encoding": None} + + if self._rate_limit_seconds: + sleep_time = round(self._rate_limit_seconds - + (time() - self._last_query), 3) + if sleep_time > 0: + LOG.info(f"Waiting {sleep_time}s before next API query") + sleep(sleep_time) + + if lat and lon: + # Lookup address for coordinates + try: + response = self._query_reverse(float(lat), float(lon)) + except ValueError as e: + return {"status_code": -1, + "content": repr(e), + "encoding": None} + else: + # Lookup coordinates for search term/address + response = self._query_geocode(address) + self._last_query = time() + return {"status_code": response.status_code, + "content": response.content, + "encoding": response.encoding} + + def _query_geocode(self, address: str) -> Response: + query_str = urllib.parse.urlencode({"q": address, + "api_key": self._api_key}) + request_url = f"{self.geocode_url}?{query_str}" + return self.get_with_cache_timeout(request_url, self.cache_timeout) + + def _query_reverse(self, lat: float, lon: float): + query_str = urllib.parse.urlencode({"lat": lat, "lon": lon, + "api_key": self._api_key}) + request_url = f"{self.reverse_url}?{query_str}" + return self.get_with_cache_timeout(request_url, self.cache_timeout) diff --git a/tests/test_client.py b/tests/test_client.py index d9b8386..ca3fd62 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -74,17 +74,28 @@ def test_request_neon_api_invalid_type_params(self): class NeonAPIClientTests(unittest.TestCase): + map_maker_key = None + @classmethod def setUpClass(cls) -> None: def _override_find_key(*args, **kwargs): - raise Exception + raise Exception("Test Exception; no key found") import neon_utils.authentication_utils neon_utils.authentication_utils.find_generic_keyfile = _override_find_key + if os.getenv("MAP_MAKER_KEY"): + cls.map_maker_key = os.environ.pop("MAP_MAKER_KEY") + + @classmethod + def tearDownClass(cls) -> None: + if cls.map_maker_key: + os.environ["MAP_MAKER_KEY"] = cls.map_maker_key + def test_client_init_no_keys(self): from neon_api_proxy.client import NeonAPIProxyClient client = NeonAPIProxyClient({"test": "test"}) - self.assertEqual(set(client.service_instance_mapping.keys()), {"api_test_endpoint"}) + self.assertEqual(set(client.service_instance_mapping.keys()), + {"api_test_endpoint"}) def test_client_lazy_load(self): from neon_api_proxy.client import NeonAPI, request_api @@ -294,3 +305,29 @@ def test_get_forecast_no_api_key(self): self.assertIsInstance(data["minutely"], list) self.assertIsInstance(data["hourly"], list) self.assertIsInstance(data["daily"], list) + + +class MapMakerTests(unittest.TestCase): + def test_get_coordinates(self): + from neon_api_proxy.client.map_maker import get_coordinates + + # Valid request + lat, lon = get_coordinates("Kirkland") + self.assertIsInstance(lat, float) + self.assertIsInstance(lon, float) + + # Invalid request + with self.assertRaises(RuntimeError): + get_coordinates("") + + def test_get_address(self): + from neon_api_proxy.client.map_maker import get_address + + # Valid Request + address = get_address(VALID_LAT, VALID_LNG) + self.assertEqual(address['state'], "Washington") + self.assertEqual(address['city'], "Renton", address) + + # Invalid Request + with self.assertRaises(RuntimeError): + get_address('', '') diff --git a/tests/test_map_maker_api.py b/tests/test_map_maker_api.py new file mode 100644 index 0000000..2c6648f --- /dev/null +++ b/tests/test_map_maker_api.py @@ -0,0 +1,92 @@ +# NEON AI (TM) SOFTWARE, Software Development Kit & Application Framework +# All trademark and other rights reserved by their respective owners +# Copyright 2008-2022 Neongecko.com Inc. +# Contributors: Daniel McKnight, Guy Daniels, Elon Gasper, Richard Leeds, +# Regina Bloomstine, Casimiro Ferreira, Andrii Pernatii, Kirill Hrymailo +# BSD-3 License +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import json +import os +import sys +import unittest + +from requests import Response + +sys.path.append(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) +from neon_api_proxy.services.map_maker_api import MapMakerAPI + +VALID_LAT = "47.4797" +VALID_LON = "-122.2079" + +INVALID_LAT = "a" +INVALID_LON = "b" + +VALID_ADDRESS = "Kirkland" +VALID_ADDRESS_2 = "New York New York" + +INVALID_ADDRESS = "" + + +class TestMapMakerAPI(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.api = MapMakerAPI() + + def test_geocode_lookup(self): + valid_response = self.api.handle_query(address=VALID_ADDRESS) + self.assertEqual(valid_response['status_code'], 200) + self.assertEqual(valid_response["encoding"].lower(), "utf-8") + valid_location = json.loads(valid_response["content"])[0] + self.assertAlmostEqual(float(valid_location['lat']), 47.69, delta=0.02) + self.assertAlmostEqual(float(valid_location['lon']), -122.19, + delta=0.02) + + valid_response_2 = self.api.handle_query(address=VALID_ADDRESS_2) + self.assertEqual(valid_response_2['status_code'], 200) + self.assertEqual(valid_response_2["encoding"].lower(), "utf-8") + valid_location = json.loads(valid_response_2["content"])[0] + self.assertAlmostEqual(float(valid_location['lat']), 36.10, delta=0.02) + self.assertAlmostEqual(float(valid_location['lon']), -115.17, + delta=0.02) + + invalid_response = self.api.handle_query(address=INVALID_ADDRESS) + self.assertEqual(invalid_response['status_code'], -1) + + def test_reverse_lookup(self): + valid_response = self.api.handle_query(lat=VALID_LAT, lon=VALID_LON) + self.assertEqual(valid_response['status_code'], 200) + self.assertEqual(valid_response["encoding"].lower(), "utf-8") + valid_location = json.loads(valid_response["content"])['address'] + self.assertEqual(valid_location['state'], "Washington", valid_location) + self.assertEqual(valid_location['town'], "Renton", valid_location) + + invalid_response = self.api.handle_query(lat=VALID_LAT, lon=None) + self.assertEqual(invalid_response['status_code'], -1) + + invalid_coords = self.api.handle_query(lat=INVALID_LAT, lon=INVALID_LON) + self.assertNotEqual(invalid_coords['status_code'], 200) + + +if __name__ == '__main__': + unittest.main() diff --git a/version.py b/version.py index 9fdbc89..e3d46d2 100644 --- a/version.py +++ b/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.4.2" +__version__ = "0.5.0"