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

0.5.0 #90

Closed
wants to merge 2 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
22 changes: 20 additions & 2 deletions .github/workflows/unit_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions neon_api_proxy/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
72 changes: 72 additions & 0 deletions neon_api_proxy/client/map_maker.py
Original file line number Diff line number Diff line change
@@ -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
17 changes: 10 additions & 7 deletions neon_api_proxy/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -47,6 +49,7 @@ class NeonAPIProxyController:
'wolfram_alpha': WolframAPI,
'alpha_vantage': AlphaVantageAPI,
'open_weather_map': OpenWeatherAPI,
'map_maker': MapMakerAPI,
'api_test_endpoint': TestAPI
}

Expand All @@ -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):
Expand All @@ -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:
Expand Down
109 changes: 109 additions & 0 deletions neon_api_proxy/services/map_maker_api.py
Original file line number Diff line number Diff line change
@@ -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)
41 changes: 39 additions & 2 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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('', '')
Loading
Loading