From fb8a9f49117e17c81cec2af690cd12895ea73bcd Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Thu, 30 Mar 2017 02:38:53 -0400 Subject: [PATCH 1/3] Make session token reusable across multiple object instances via cache file. With this patch, we can have more than one Ring object pointing to the same cache file to share credentials to avoid multiple authentications. --- ring_doorbell/__init__.py | 128 ++++++++++++++++++++++++-------------- ring_doorbell/const.py | 9 +++ ring_doorbell/utils.py | 21 +++++-- tests/test_ring.py | 22 ++++--- 4 files changed, 119 insertions(+), 61 deletions(-) diff --git a/ring_doorbell/__init__.py b/ring_doorbell/__init__.py index d6ebf6eb..e0d9f7fc 100644 --- a/ring_doorbell/__init__.py +++ b/ring_doorbell/__init__.py @@ -14,9 +14,10 @@ import pytz from ring_doorbell.utils import ( - _locator, _clean_cache, _save_cache, _read_cache) + _locator, _exists_cache, _save_cache, _read_cache) from ring_doorbell.const import ( - API_VERSION, API_URI, CHIMES_ENDPOINT, CHIME_VOL_MIN, CHIME_VOL_MAX, + API_VERSION, API_URI, CACHE_ATTRS, CACHE_FILE, CHIMES_ENDPOINT, + CHIME_VOL_MIN, CHIME_VOL_MAX, DEVICES_ENDPOINT, DOORBELLS_ENDPOINT, DOORBELL_VOL_MIN, DOORBELL_VOL_MAX, DOORBELL_EXISTING_TYPE, DINGS_ENDPOINT, FILE_EXISTS, HEADERS, LINKED_CHIMES_ENDPOINT, LIVE_STREAMING_ENDPOINT, @@ -33,11 +34,10 @@ class Ring(object): """A Python Abstraction object to Ring Door Bell.""" def __init__(self, username, password, debug=False, persist_token=False, - push_token_notify_url="http://localhost/"): + push_token_notify_url="http://localhost/", reuse_session=True, + cache_file=CACHE_FILE): """Initialize the Ring object.""" - self.features = None self.is_connected = None - self._id = None self.token = None self.params = None self._persist_token = persist_token @@ -49,9 +49,46 @@ def __init__(self, username, password, debug=False, persist_token=False, self.session = requests.Session() self.session.auth = (self.username, self.password) - self._authenticate() + self.cache = CACHE_ATTRS + self.cache_file = cache_file + self._reuse_session = reuse_session - def _authenticate(self, attempts=RETRY_TOKEN): + # tries to re-use old session + if self._reuse_session: + self.cache['token'] = self.token + self._process_cached_session() + else: + self._authenticate() + + def _process_cached_session(self): + """Process cache_file to reuse token instead.""" + if _exists_cache(self.cache_file): + self.cache = _read_cache(self.cache_file) + + # if self.cache['token'] is None, the cache file was corrupted. + # In this case, a new auth token is required. + if self.cache['token'] is None: + self._authenticate() + else: + # we need to set the self.token and self.params + # to make use of the self.query() method + self.token = self.cache['token'] + self.params = {'api_version': API_VERSION, + 'auth_token': self.token} + + # test if token from cache_file is still valid and functional + # if not, it should continue to get a new auth token + url = API_URI + DEVICES_ENDPOINT + req = self.query(url, raw=True) + if req.status_code == 200: + self._authenticate(session=req) + else: + self._authenticate() + else: + # first time executing, so we have to create a cache file + self._authenticate() + + def _authenticate(self, attempts=RETRY_TOKEN, session=None): """Authenticate user against Ring API.""" url = API_URI + NEW_SESSION_ENDPOINT @@ -59,17 +96,25 @@ def _authenticate(self, attempts=RETRY_TOKEN): while loop <= attempts: loop += 1 try: - req = self.session.post((url), data=POST_DATA, headers=HEADERS) + if session is None: + req = self.session.post((url), + data=POST_DATA, + headers=HEADERS) + else: + req = session except: raise # if token is expired, refresh credentials and try again - if req.status_code == 201: - data = req.json().get('profile') - self.features = data.get('features') - self._id = data.get('id') + if req.status_code == 200 or req.status_code == 201: + + # the only way to get a JSON with token is via POST, + # so we need a special conditional for 201 code + if req.status_code == 201: + data = req.json().get('profile') + self.token = data.get('authentication_token') + self.is_connected = True - self.token = data.get('authentication_token') self.params = {'api_version': API_VERSION, 'auth_token': self.token} @@ -80,6 +125,12 @@ def _authenticate(self, attempts=RETRY_TOKEN): self._push_token_notify_url req = self.session.put((url), headers=HEADERS, data=PERSIST_TOKEN_DATA) + + # update token if reuse_session is True + if self._reuse_session: + self.cache['token'] = self.token + + _save_cache(self.cache, self.cache_file) return True self.is_connected = False @@ -146,14 +197,6 @@ def query(self, _LOGGER.debug("%s", MSG_GENERIC_FAIL) return response - @property - def has_subscription(self): - """Return if account has subscription.""" - try: - return self.features.get('subscriptions_enabled') - except AttributeError: - return NOT_FOUND - @property def devices(self): """Return all devices.""" @@ -203,13 +246,12 @@ class RingGeneric(object): def __init__(self): """Initialize Ring Generic.""" self._attrs = None + self._ring = None self.debug = None self.family = None self.name = None # alerts notifications - self._alert_cache = None - self.alert = None self.alert_expires_at = None def __repr__(self): @@ -221,26 +263,26 @@ def update(self): self._get_attrs() self._update_alert() + @property + def alert(self): + """Return alert attribute.""" + return self._ring.cache['alerts'] + + @alert.setter + def alert(self, value): + """Set attribute to alert.""" + self._ring.cache['alerts'] = value + _save_cache(self._ring.cache, self._ring.cache_file) + return True + def _update_alert(self): """Verify if alert received is still valid.""" + # alert is no longer valid if self.alert and self.alert_expires_at: if datetime.now() >= self.alert_expires_at: self.alert = None self.alert_expires_at = None - elif self._alert_cache: - aux = _read_cache(self._alert_cache) - if ((isinstance(aux, dict)) and - ('now' in aux) and - ('expires_in' in aux)): - aux_expires_at = datetime.fromtimestamp( - aux.get('now') + aux.get('expires_in')) - - # verify if pickle object is still valid - if datetime.now() <= aux_expires_at: - self.alert = aux - self.alert_expires_at = aux_expires_at - else: - _save_cache(None, self._alert_cache) + _save_cache(self._ring.cache, self._ring.cache_file) def _get_attrs(self): """Return attributes.""" @@ -371,14 +413,8 @@ def battery_life(self): value = 100 return value - def check_alerts(self, cache=None): + def check_alerts(self): """Return JSON when motion or ring is detected.""" - # save alerts attributes to an external pickle file - # when multiple resources are checking for alerts - if cache: - _clean_cache(cache) - self._alert_cache = cache - url = API_URI + DINGS_ENDPOINT self.update() @@ -393,8 +429,8 @@ def check_alerts(self, cache=None): self.alert_expires_at = datetime.fromtimestamp(timestamp) # save to a pickle data - if self._alert_cache: - _save_cache(self.alert, self._alert_cache) + if self.alert: + _save_cache(self._ring.cache, self._ring.cache_file) return True return None diff --git a/ring_doorbell/const.py b/ring_doorbell/const.py index 777d4e3b..a4066908 100644 --- a/ring_doorbell/const.py +++ b/ring_doorbell/const.py @@ -1,6 +1,7 @@ # coding: utf-8 # vim:sw=4:ts=4:et: """Constants.""" +import os from uuid import uuid4 as uuid HEADERS = {'Content-Type': 'application/x-www-form-urlencoded; charset: UTF-8', @@ -10,6 +11,14 @@ # number of attempts to refresh token RETRY_TOKEN = 3 +# default suffix for session cache file +CACHE_ATTRS = {'token': None, 'alerts': None} + +HOMEDIR = os.getenv("HOME") +if not HOMEDIR: + HOMEDIR = '' +CACHE_FILE = os.path.join(HOMEDIR, '.ring_doorbell-session.cache') + # code when item was not found NOT_FOUND = -1 diff --git a/ring_doorbell/utils.py b/ring_doorbell/utils.py index f396a9d4..d03f46e0 100644 --- a/ring_doorbell/utils.py +++ b/ring_doorbell/utils.py @@ -2,7 +2,7 @@ # vim:sw=4:ts=4:et: """Python Ring Doorbell utils.""" import os -from ring_doorbell.const import NOT_FOUND +from ring_doorbell.const import CACHE_ATTRS, NOT_FOUND try: import cPickle as pickle @@ -23,10 +23,19 @@ def _clean_cache(filename): """Remove filename if pickle version mismatch.""" try: if os.path.isfile(filename): - _read_cache(filename) - except ValueError: - os.remove(filename) - return True + os.remove(filename) + except: + raise + + # initialize cache since file was removed + initial_cache_data = CACHE_ATTRS + _save_cache(initial_cache_data, filename) + return initial_cache_data + + +def _exists_cache(filename): + """Check if filename exists and if is pickle object.""" + return bool(os.path.isfile(filename)) def _save_cache(data, filename): @@ -44,5 +53,7 @@ def _read_cache(filename): try: if os.path.isfile(filename): return pickle.load(open(filename, 'rb')) + except EOFError: + return _clean_cache(filename) except: raise diff --git a/tests/test_ring.py b/tests/test_ring.py index 05049433..ac2912f3 100644 --- a/tests/test_ring.py +++ b/tests/test_ring.py @@ -11,7 +11,7 @@ USERNAME = 'foo' PASSWORD = 'bar' -ALERT_CACHE_DB = 'tests/cache.db' +CACHE = 'tests/cache.db' def mocked_requests_get(*args, **kwargs): @@ -245,9 +245,9 @@ def test_basic_attributes(self, get_mock, post_mock): """Test the Ring class and methods.""" from ring_doorbell import Ring - myring = Ring(USERNAME, PASSWORD) + myring = Ring(USERNAME, PASSWORD, cache_file=CACHE) self.assertTrue(myring.is_connected) - self.assertIsInstance(myring.features, dict) + self.assertIsInstance(myring.cache, dict) self.assertFalse(myring.debug) self.assertEqual(1, len(myring.chimes)) self.assertEqual(2, len(myring.doorbells)) @@ -264,7 +264,7 @@ def test_chime_attributes(self, get_mock, post_mock): """Test the Ring Chime class and methods.""" from ring_doorbell import Ring - myring = Ring(USERNAME, PASSWORD) + myring = Ring(USERNAME, PASSWORD, cache_file=CACHE) dev = myring.chimes[0] self.assertEqual('123 Main St', dev.address) @@ -285,7 +285,7 @@ def test_doorbell_attributes(self, get_mock, post_mock): """Test the Ring DoorBell class and methods.""" from ring_doorbell import Ring - myring = Ring(USERNAME, PASSWORD, persist_token=True) + myring = Ring(USERNAME, PASSWORD, cache_file=CACHE, persist_token=True) for dev in myring.doorbells: if not dev.shared: self.assertEqual('Front Door', dev.name) @@ -309,7 +309,7 @@ def test_shared_doorbell_attributes(self, get_mock, post_mock): """Test the Ring Shared DoorBell class and methods.""" from ring_doorbell import Ring - myring = Ring(USERNAME, PASSWORD, persist_token=True) + myring = Ring(USERNAME, PASSWORD, cache_file=CACHE, persist_token=True) for dev in myring.doorbells: if dev.shared: self.assertEqual(987653, dev.account_id) @@ -321,6 +321,8 @@ def test_shared_doorbell_attributes(self, get_mock, post_mock): self.assertEqual(5, dev.volume) self.assertEqual('Digital', dev.existing_doorbell_type) + os.remove(CACHE) + class TestRingDoorBellAlerts(unittest.TestCase): """Test the Ring DoorBell alerts.""" @@ -331,16 +333,16 @@ def test_doorbell_alerts(self, get_mock, post_mock): """Test the Ring DoorBell alerts.""" from ring_doorbell import Ring - myring = Ring(USERNAME, PASSWORD, persist_token=True) + myring = Ring(USERNAME, PASSWORD, cache_file=CACHE, persist_token=True) for dev in myring.doorbells: self.assertEqual('America/New_York', dev.timezone) # call alerts - dev.check_alerts(cache=ALERT_CACHE_DB) + dev.check_alerts() self.assertIsInstance(dev.alert, dict) self.assertIsInstance(dev.alert_expires_at, datetime) self.assertTrue(datetime.now() <= dev.alert_expires_at) - self.assertIsNotNone(dev._alert_cache) + self.assertIsNotNone(dev._ring.cache_file) - os.remove(ALERT_CACHE_DB) + os.remove(CACHE) From 226dfe297011df730c25baa507c9a189aea067de Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Thu, 30 Mar 2017 03:36:09 -0400 Subject: [PATCH 2/3] Added unittest for utils.py --- ring_doorbell/const.py | 10 ++++++---- tests/test_ring_utils.py | 42 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 tests/test_ring_utils.py diff --git a/ring_doorbell/const.py b/ring_doorbell/const.py index a4066908..f7c3d4ad 100644 --- a/ring_doorbell/const.py +++ b/ring_doorbell/const.py @@ -14,10 +14,12 @@ # default suffix for session cache file CACHE_ATTRS = {'token': None, 'alerts': None} -HOMEDIR = os.getenv("HOME") -if not HOMEDIR: - HOMEDIR = '' -CACHE_FILE = os.path.join(HOMEDIR, '.ring_doorbell-session.cache') +try: + CACHE_FILE = os.path.join(os.getenv("HOME"), + '.ring_doorbell-session.cache') +except (AttributeError, TypeError): + CACHE_FILE = os.path.join('.', '.ring_doorbell-session.cache') + # code when item was not found NOT_FOUND = -1 diff --git a/tests/test_ring_utils.py b/tests/test_ring_utils.py new file mode 100644 index 00000000..fca7f45e --- /dev/null +++ b/tests/test_ring_utils.py @@ -0,0 +1,42 @@ +"""The tests utils.py for the Ring platform.""" +import os +import unittest +from ring_doorbell.utils import ( + _locator, _clean_cache, _exists_cache, _save_cache, _read_cache) + +CACHE = 'tests/cache.db' +FAKE = 'tests/fake.db' +DATA = {'key': 'value'} + + +class TestUtils(unittest.TestCase): + """Test utils.py.""" + + def test_locator(self): + """Test _locator method.""" + self.assertEquals(-1, _locator([DATA], 'key', 'bar')) + self.assertEquals(0, _locator([DATA], 'key', 'value')) + + def test_initiliaze_clean_cache(self): + """Test _clean_cache method.""" + self.assertTrue(_save_cache(DATA, CACHE)) + self.assertIsInstance(_clean_cache(CACHE), dict) + os.remove(CACHE) + + def test_exists_cache(self): + """Test _exists_cache method.""" + self.assertTrue(_save_cache(DATA, CACHE)) + self.assertTrue(_exists_cache(CACHE)) + os.remove(CACHE) + + def test_read_cache(self): + """Test _read_cache method.""" + self.assertTrue(_save_cache(DATA, CACHE)) + self.assertIsInstance(_read_cache(CACHE), dict) + os.remove(CACHE) + + def test_read_cache_eoferror(self): + """Test _read_cache method.""" + open(CACHE, 'a').close() + self.assertIsInstance(_read_cache(CACHE), dict) + os.remove(CACHE) From 07ba1066e2a97102fc26f512712cdaa28b4e4e1f Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Fri, 31 Mar 2017 02:27:11 -0400 Subject: [PATCH 3/3] Make sure if a different username is used, a new cache should be initialized. --- ring_doorbell/__init__.py | 9 +++++++-- ring_doorbell/const.py | 2 +- ring_doorbell/utils.py | 8 +++++++- tests/test_ring_utils.py | 7 +++++++ 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/ring_doorbell/__init__.py b/ring_doorbell/__init__.py index e0d9f7fc..4d875044 100644 --- a/ring_doorbell/__init__.py +++ b/ring_doorbell/__init__.py @@ -50,6 +50,7 @@ def __init__(self, username, password, debug=False, persist_token=False, self.session.auth = (self.username, self.password) self.cache = CACHE_ATTRS + self.cache['account'] = self.username self.cache_file = cache_file self._reuse_session = reuse_session @@ -66,8 +67,11 @@ def _process_cached_session(self): self.cache = _read_cache(self.cache_file) # if self.cache['token'] is None, the cache file was corrupted. - # In this case, a new auth token is required. - if self.cache['token'] is None: + # of if self.cache['account'] does not match with self.username + # In both cases, a new auth token is required. + if (self.cache['token'] is None) or \ + (self.cache['account'] is None) or \ + (self.cache['account'] != self.username): self._authenticate() else: # we need to set the self.token and self.params @@ -128,6 +132,7 @@ def _authenticate(self, attempts=RETRY_TOKEN, session=None): # update token if reuse_session is True if self._reuse_session: + self.cache['account'] = self.username self.cache['token'] = self.token _save_cache(self.cache, self.cache_file) diff --git a/ring_doorbell/const.py b/ring_doorbell/const.py index f7c3d4ad..084ccfe1 100644 --- a/ring_doorbell/const.py +++ b/ring_doorbell/const.py @@ -12,7 +12,7 @@ RETRY_TOKEN = 3 # default suffix for session cache file -CACHE_ATTRS = {'token': None, 'alerts': None} +CACHE_ATTRS = {'account': None, 'alerts': None, 'token': None} try: CACHE_FILE = os.path.join(os.getenv("HOME"), diff --git a/ring_doorbell/utils.py b/ring_doorbell/utils.py index d03f46e0..24797781 100644 --- a/ring_doorbell/utils.py +++ b/ring_doorbell/utils.py @@ -52,7 +52,13 @@ def _read_cache(filename): """Read data from a pickle file.""" try: if os.path.isfile(filename): - return pickle.load(open(filename, 'rb')) + data = pickle.load(open(filename, 'rb')) + + # make sure pickle obj has the expected defined keys + # if not reinitialize cache + if data.keys() != CACHE_ATTRS.keys(): + raise EOFError + return data except EOFError: return _clean_cache(filename) except: diff --git a/tests/test_ring_utils.py b/tests/test_ring_utils.py index fca7f45e..4dfe64d5 100644 --- a/tests/test_ring_utils.py +++ b/tests/test_ring_utils.py @@ -3,6 +3,7 @@ import unittest from ring_doorbell.utils import ( _locator, _clean_cache, _exists_cache, _save_cache, _read_cache) +from ring_doorbell.const import CACHE_ATTRS CACHE = 'tests/cache.db' FAKE = 'tests/fake.db' @@ -40,3 +41,9 @@ def test_read_cache_eoferror(self): open(CACHE, 'a').close() self.assertIsInstance(_read_cache(CACHE), dict) os.remove(CACHE) + + def test_read_cache_dict(self): + """Test _read_cache with expected dict.""" + self.assertTrue(_save_cache(CACHE_ATTRS, CACHE)) + self.assertIsInstance(_read_cache(CACHE), dict) + os.remove(CACHE)