diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..baccf93 --- /dev/null +++ b/.gitignore @@ -0,0 +1,172 @@ +# Credit to https://github.com/github/gitignore.git +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# PyPI configuration file +.pypirc \ No newline at end of file diff --git a/noonlight/__init__.py b/noonlight/__init__.py index abd34ce..d12fe62 100644 --- a/noonlight/__init__.py +++ b/noonlight/__init__.py @@ -4,130 +4,150 @@ DEFAULT_BASE_URL = "https://api-sandbox.noonlight.com/platform/v1" -NOONLIGHT_SERVICES_POLICE = 'police' -NOONLIGHT_SERVICES_FIRE = 'fire' -NOONLIGHT_SERVICES_MEDICAL = 'medical' +NOONLIGHT_SERVICES_POLICE = "police" +NOONLIGHT_SERVICES_FIRE = "fire" +NOONLIGHT_SERVICES_MEDICAL = "medical" +NOONLIGHT_SERVICES_OTHER = "other" NOONLIGHT_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" + class NoonlightAlarm(object): """ Noonlight API Alarm Object - + :param client: NoonlightClient parent object :type client: NoonlightClient - :param json_data: Parsed JSON dictionary from API response to populate + :param json_data: Parsed JSON dictionary from API response to populate the NoonlightAlarm object :type json_data: dict """ + def __init__(self, client, json_data): """ Creates a new :class:`NoonlightAlarm` instance. """ self._client = client self._json_data = json_data - + @classmethod async def create(cls, client, json_data_future): """ Factory coroutine for creating NoonlightAlarm objects """ return NoonlightAlarm(client, await json_data_future) - + @property def id(self): """Returns the ID of this NoonlightAlarm""" - return self._json_data.get('id') - + return self._json_data.get("id") + @property def status(self): """Returns the last known status of this NoonlightAlarm""" - return self._json_data.get('status') - + return self._json_data.get("status") + @property def services(self): """Returns a list of active services for this NoonlightAlarm""" - services = self._json_data.get('services',{}) + services = self._json_data.get("services", {}) return [key for key in services if services[key]] - + @property def is_police(self): """Returns True if police services are included in this alarm""" return NOONLIGHT_SERVICES_POLICE in self.services - + @property def is_fire(self): """Returns True if fire services are included in this alarm""" return NOONLIGHT_SERVICES_FIRE in self.services - + @property def is_medical(self): """Returns True if medical services are included in this alarm""" return NOONLIGHT_SERVICES_MEDICAL in self.services - + + @property + def is_other(self): + """Returns True if other services are included in this alarm""" + return NOONLIGHT_SERVICES_OTHER in self.services + @property def created_at(self): """Returns the datetime the NoonlightAlarm was created""" try: - return datetime.strptime(self._json_data.get('created_at',"0001-01-01T00:00:00.00Z"),NOONLIGHT_DATETIME_FORMAT) + return datetime.strptime( + self._json_data.get("created_at", "0001-01-01T00:00:00.00Z"), + NOONLIGHT_DATETIME_FORMAT, + ) except: return datetime.min - + @property def locations(self): """ - Returns a list of locations for this NoonlightAlarm, sorted by most + Returns a list of locations for this NoonlightAlarm, sorted by most recent first - - NOTE: Currently the Noonlight API only returns the first location when - the alarm is created, additional locations will be appended by this + + NOTE: Currently the Noonlight API only returns the first location when + the alarm is created, additional locations will be appended by this library. """ - locations_merged = self._json_data.get('locations',{}).get('addresses',[]) + self._json_data.get('locations',{}).get('coordinates',[]) + locations_merged = self._json_data.get("locations", {}).get( + "addresses", [] + ) + self._json_data.get("locations", {}).get("coordinates", []) for location in locations_merged: - if 'created_at' in location and type(location['created_at']) is not datetime: + if ( + "created_at" in location + and type(location["created_at"]) is not datetime + ): try: - location['created_at'] = datetime.strptime(location['created_at'],NOONLIGHT_DATETIME_FORMAT) + location["created_at"] = datetime.strptime( + location["created_at"], NOONLIGHT_DATETIME_FORMAT + ) except: pass - return sorted(locations_merged,key=lambda x: x.get('created_at'), reverse = True) - + return sorted(locations_merged, key=lambda x: x.get("created_at"), reverse=True) + async def cancel(self): """ - Cancels this alarm using the NoonlightClient that created this + Cancels this alarm using the NoonlightClient that created this NoonlightAlarm. - - :returns: True if alarm is cancelled, False if a response does not + + :returns: True if alarm is cancelled, False if a response does not have a 200 status :rtype: boolean """ - response = await self._client.update_alarm(id = self.id, body = {'status': 'CANCELED'}) - if response.get('status') == 200: - self._json_data['status'] = 'CANCELED' + response = await self._client.update_alarm( + id=self.id, body={"status": "CANCELED"} + ) + if response.get("status") == 200: + self._json_data["status"] = "CANCELED" return True return False - - async def update_location_coordinates(self, *, lat, lng, accuracy = 5.0): + + async def update_location_coordinates(self, *, lat, lng, accuracy=5.0): """ Update the alarm location with the provided latitude and longitude. - + :param lat: Latitude of the new location :type lat: double :param lng: Longitude of the new location :type lng: double :param accuracy: (optional) Accuracy of the location in meters (default: 5m) :type accuracy: double - + :returns: True if location is updated and added to the locations list :rtype: boolean """ - data = {'lat':lat, 'lng':lng, 'accuracy': accuracy} - return await self._update_location_by_type('coordinates', data) - - async def update_location_address(self, *, line1, line2 = None, city, state, zip): + data = {"lat": lat, "lng": lng, "accuracy": accuracy} + return await self._update_location_by_type("coordinates", data) + + async def update_location_address(self, *, line1, line2=None, city, state, zip): """ Update the alarm location with the provided address. - + :param line1: Address line 1 :type line1: str :param line2: Address line 2 (provide None or "" if N/A) @@ -138,62 +158,65 @@ async def update_location_address(self, *, line1, line2 = None, city, state, zip :type state: str :param zip: Address zip :type zip: str - + :returns: True if location is updated and added to the locations list :rtype: boolean """ - data = {'line1':line1, 'city':city, 'state': state.upper(), 'zip': zip} + data = {"line1": line1, "city": city, "state": state.upper(), "zip": zip} if line2 and len(line2) > 0: - data['line2'] = line2 - return await self._update_location_by_type('address', data) - + data["line2"] = line2 + return await self._update_location_by_type("address", data) + async def _update_location_by_type(self, type, data): """ - Private method to update alarm location by type (coordinates or + Private method to update alarm location by type (coordinates or address). - + :param type: Location type, 'coordinates' or 'address' :type type: str :param data: Location data, lat/lng or address information :type data: dict """ - if type in ('coordinates','address'): - response = await self._client.update_alarm_location(id = self.id, body = {type: data} ) + if type in ("coordinates", "address"): + response = await self._client.update_alarm_location( + id=self.id, body={type: data} + ) if type in response: self._add_location(type, response[type]) return True return False - + def _add_location(self, type, data): """ - Private method to add a location to the NoonlightAlarm object location + Private method to add a location to the NoonlightAlarm object location collection. - + :param type: Location type, 'coordinates' or 'address' :type type: str :param data: Location data, lat/lng or address information :type data: dict """ - if type in ('coordinates','address'): + if type in ("coordinates", "address"): key = type - if type == 'address': - key = 'addresses' - if 'locations' not in self._json_data: - self._json_data['locations'] = {} - if type not in self._json_data['locations']: - self._json_data['locations'][key] = [] - self._json_data['locations'][key].append(data) - + if type == "address": + key = "addresses" + if "locations" not in self._json_data: + self._json_data["locations"] = {} + if type not in self._json_data["locations"]: + self._json_data["locations"][key] = [] + self._json_data["locations"][key].append(data) + async def get_status(self): """ - Update and return the current status of this NoonlightAlarm from + Update and return the current status of this NoonlightAlarm from the API. """ - response = await self._client.get_alarm_status(id = self.id) - if 'status' in response: + response = await self._client.get_alarm_status(id=self.id) + if "status" in response: self._json_data.update(response) return self.status - + + class NoonlightClient(object): """ NoonlightClient API client @@ -205,44 +228,44 @@ class NoonlightClient(object): :param timeout: seconds to wait for before triggering a timeout :type timeout: integer """ - def __init__(self, token, session=None, - timeout=aiohttp.client.DEFAULT_TIMEOUT): + + def __init__(self, token, session=None, timeout=aiohttp.client.DEFAULT_TIMEOUT): """ Creates a new :class:`NoonlightClient` instance. """ - self._headers = {'Content-Type': 'application/json'} + self._headers = {"Content-Type": "application/json"} if session is not None: self._session = session else: self._session = aiohttp.ClientSession(timeout=timeout) - + self._base_url = DEFAULT_BASE_URL - self.set_token(token = token) + self.set_token(token=token) @property def alarms_url(self): """Noonlight API base URL for alarms.""" return "{}/alarms".format(self._base_url) - + @property def alarm_status_url(self): """Noonlight API URL for alarm status.""" - return "{url}/{id}/status".format(url=self.alarms_url,id='{id}') - + return "{url}/{id}/status".format(url=self.alarms_url, id="{id}") + @property def alarm_location_url(self): """Noonlight API URL for location updates.""" - return "{url}/{id}/locations".format(url=self.alarms_url,id='{id}') - + return "{url}/{id}/locations".format(url=self.alarms_url, id="{id}") + def set_token(self, *, token): """ Sets the API token for this NoonlightClient - + :param token: OAuth2 token for the Noonlight API :type token: str """ self._token = token - self._headers['Authorization'] = "Bearer {}".format(self._token) + self._headers["Authorization"] = "Bearer {}".format(self._token) def set_base_url(self, base_url): """ @@ -252,7 +275,7 @@ def set_base_url(self, base_url): :type base_url: str """ self._base_url = base_url - + async def get_alarm_status(self, *, id): """ Get the status of an alarm by id @@ -321,24 +344,21 @@ def handle_error(status, error): raise NoonlightClient.ClientError(error) async def _get(self, path): - async with self._session.get( - path, headers=self._headers) as resp: + async with self._session.get(path, headers=self._headers) as resp: if 200 <= resp.status < 300: return await resp.json() else: self.handle_error(resp.status, await resp.json()) async def _post(self, path, data): - async with self._session.post( - path, json=data, headers=self._headers) as resp: + async with self._session.post(path, json=data, headers=self._headers) as resp: if 200 <= resp.status < 300: return await resp.json() else: self.handle_error(resp.status, await resp.json()) async def _put(self, path, data): - async with self._session.put( - path, json=data, headers=self._headers) as resp: + async with self._session.put(path, json=data, headers=self._headers) as resp: if 200 <= resp.status < 300: return await resp.json() else: @@ -346,28 +366,35 @@ async def _put(self, path, data): class ClientError(Exception): """Generic Error.""" + pass class Unauthorized(ClientError): """Failed Authentication.""" + pass class BadRequest(ClientError): """Request is malformed.""" + pass class Forbidden(ClientError): """Access is prohibited.""" + pass class TooManyRequests(ClientError): """Too many requests for this time period.""" + pass class InternalServerError(ClientError): """Server Internal Error.""" + pass class InvalidData(ClientError): """Can't parse response data.""" + pass diff --git a/setup.py b/setup.py index 2866c19..5d30dac 100644 --- a/setup.py +++ b/setup.py @@ -3,8 +3,8 @@ import os import sys -if sys.argv[-1] == 'publish': - os.system('python setup.py sdist upload') +if sys.argv[-1] == "publish": + os.system("python setup.py sdist upload") sys.exit() @@ -15,19 +15,19 @@ long_description = fh.read() setup( - name='noonlight', - version='0.1.1', - packages=['noonlight'], - url='https://github.com/konnected-io/noonlight-py', + name="noonlight", + version="0.1.2", + packages=["noonlight"], + url="https://github.com/konnected-io/noonlight-py", license="MIT License", - description='A Python library for interacting with the Noonlight API', + description="A Python library for interacting with the Noonlight API", long_description=long_description, long_description_content_type="text/markdown", - author='Nate Clark, Nick Gordon, Konnected Inc', - author_email='help@konnected.io', - install_requires=['aiohttp'], + author="Nate Clark, Nick Gordon, Konnected Inc", + author_email="help@konnected.io", + install_requires=["aiohttp"], project_urls={ - 'Homepage': 'https://github.com/konnected-io/noonlight-py', - 'API Documentation': 'https://docs.noonlight.com' - } -) \ No newline at end of file + "Homepage": "https://github.com/konnected-io/noonlight-py", + "API Documentation": "https://docs.noonlight.com", + }, +) diff --git a/tests/test_noonlight.py b/tests/test_noonlight.py index df22ba9..14f13fa 100644 --- a/tests/test_noonlight.py +++ b/tests/test_noonlight.py @@ -4,53 +4,69 @@ from aioresponses import aioresponses from noonlight import NoonlightClient, DEFAULT_BASE_URL -alarm_id = 'abcd1234' +alarm_id = "abcd1234" loop = asyncio.get_event_loop() session = aiohttp.ClientSession(loop=loop) -client = NoonlightClient('test-token', session=session) -client._base_url = 'https://api-sandbox.safetrek.io/v1' +client = NoonlightClient("test-token", session=session) +client._base_url = "https://api-sandbox.safetrek.io/v1" def test_get_alarm_status(): with aioresponses() as mocked: mocked.get( - 'https://api-sandbox.safetrek.io/v1/alarms/' + alarm_id + '/status', - status=200, body='{"status": "ACTIVE"}') + "https://api-sandbox.safetrek.io/v1/alarms/" + alarm_id + "/status", + status=200, + body='{"status": "ACTIVE"}', + ) resp = loop.run_until_complete(client.get_alarm_status(id=alarm_id)) - assert {'status': 'ACTIVE'} == resp + assert {"status": "ACTIVE"} == resp def test_update_alarm(): with aioresponses() as mocked: mocked.put( - 'https://api-sandbox.safetrek.io/v1/alarms/' + alarm_id + '/status', - status=200, body='{"status": 200}') + "https://api-sandbox.safetrek.io/v1/alarms/" + alarm_id + "/status", + status=200, + body='{"status": 200}', + ) - resp = loop.run_until_complete(client.update_alarm(id=alarm_id, body={"status": "CANCELED"})) + resp = loop.run_until_complete( + client.update_alarm(id=alarm_id, body={"status": "CANCELED"}) + ) - assert {'status': 200} == resp + assert {"status": 200} == resp def test_create_alarm(): with aioresponses() as mocked: mocked.post( - 'https://api-sandbox.safetrek.io/v1/alarms', - status=200, body='{"status": 200}') + "https://api-sandbox.safetrek.io/v1/alarms", + status=200, + body='{"status": 200}', + ) - alarm = loop.run_until_complete(client.create_alarm(body={"anything": "anything"})) + alarm = loop.run_until_complete( + client.create_alarm(body={"anything": "anything"}) + ) assert alarm.status == 200 + def test_update_alarm_location(): with aioresponses() as mocked: mocked.post( - 'https://api-sandbox.safetrek.io/v1/alarms/' + alarm_id + '/locations', - status=200, body='{"status": 200}') - - resp = loop.run_until_complete(client.update_alarm_location(id=alarm_id, body={"coordinates": {"lat": 1, "lng": 2}})) - - assert {'status': 200} == resp - + "https://api-sandbox.safetrek.io/v1/alarms/" + alarm_id + "/locations", + status=200, + body='{"status": 200}', + ) + + resp = loop.run_until_complete( + client.update_alarm_location( + id=alarm_id, body={"coordinates": {"lat": 1, "lng": 2}} + ) + ) + + assert {"status": 200} == resp